Electron와 React로 Markdown 노트 앱 개발하기

개요

이 튜토리얼은 Electron, React, TypeScript, Tailwind CSS, Jotai를 활용하여 기본적인 Markdown 노트 앱을 구축하는 방법을 다룹니다.

기술 스택 및 의존성

  • Electron: 크로스 플랫폼 데스크톱 애플리케이션 프레임워크
  • React: Facebook이 개발한 프론트엔드 라이브러리
  • Yarn: npm, pnpm과 유사한 패키지 관리자
  • Vite: 차세대 프론트엔드 빌드 도구
  • TypeScript: 타입 시스템이 추가된 JavaScript
  • Tailwind CSS: 유틸리티 우선 CSS 프레임워크
  • PostCSS: CSS 변환을 위한 JavaScript 라이브러리
  • clsx: CSS 클래스명을 동적으로 결합하는 라이브러리
  • tailwind-merge: Tailwind CSS 클래스 자동 병합 도구
  • File system (Node.js): Node.js 파일 시스템 모듈
  • Jotai: 경량 고성능 React 상태 관리 라이브러리
  • Lodash: JavaScript 유틸리티 라이브러리
  • MDXEditor: Markdown 에디터 컴포넌트
  • react-icons: React 아이콘 라이브러리
  • fs-extra: Node.js 비동기 파일 작업 라이브러리

개발 환경

본 튜토리얼은 VSCode를 기준으로 설명합니다.

VSCode 권장 확장 프로그램

  • Auto Import - 자동 임포트 및 의존성 제안
  • Auto Rename Tag - 쌍을 이루는 태그 자동 변경
  • Tailwind CSS IntelliSense - Tailwind 스타일 자동 완성
  • Prettier - 코드 포맷터
  • ESLint - 코드Lint 및 오류 검사
  • vscode-icons - 파일 아이콘

프로젝트 설정

Electron 프로젝트 생성

먼저 yarn을 설치합니다:

$ brew install yarn

macOS에 Homebrew가 없다면:

$ /bin/bash -c "$(curl -fsSL https://gitee.com/ineo6/homebrew-install/raw/master/install.sh)"

electron-vite를 사용하여 프로젝트를 생성합니다:

$ yarn create @quick-start/electron

설정 선택:

 Project name: … markdown-notes
✔ Select a framework: › react
✔ Add TypeScript? … Yes
✔ Add Electron updater plugin? … No 
✔ Enable Electron download mirror proxy? … No 

의존성을 설치합니다:

cd markdown-notes
yarn

디렉토리 구조

프로젝트에 필요한 디렉토리를 생성합니다:

# 공통 타입 및 상수 디렉토리
mkdir src/shared

# 백엔드 유틸리티 메서드 디렉토리
mkdir src/main/lib

# 프론트엔드 커스텀 훅 디렉토리
mkdir src/renderer/src/hooks

# 프론트엔드 유틸리티 메서드 디렉토리
mkdir src/renderer/src/utils

# 프론트엔드 스토어 및 목업 데이터 디렉토리
mkdir src/renderer/src/store
mkdir src/renderer/src/store/mocks

전체 프로젝트 구조:

.
├──.vscode
├──build
├──node_modules
├──resources
│   └── icon.png
├──src
│   ├── main
│   │   ├── index.ts
│   │   └── lib
│   ├── preload
│   │   ├── index.d.ts
│   │   └── index.ts
│   ├── renderer
│   │   ├── index.html
│   │   └── src
│   │       ├── App.tsx
│   │       ├── assets
│   │       ├── components
│   │       ├── env.d.ts
│   │       ├── hooks
│   │       ├── main.tsx
│   │       ├── store
│   │       └── utils
│   └── shared
│       ├── constants.ts
│       ├── models.ts
│       └── types.ts
├── resources
├── electron-builder.yml
├── electron.vite.config.ts
├── package.json
├── tsconfig.json
├── tsconfig.node.json
├── tsconfig.web.json
└── README.md

preload란? Electron의 메인 프로세스와 렌더러 프로세스를 연결하기 위해 사용하는 특수 스크립트입니다.

애플리케이션 실행

yarn dev

Electron 통신 구조

프로젝트 src 디렉토리에는 main, preload, renderer 세 가지 주요 디렉토리가 있습니다.

Electron 애플리케이션은 메인 프로세스(main)와 렌더러 프로세스(renderer)로 구분됩니다. 메인 프로세스는 백엔드, 렌더러 프로세스는 프론트엔드에 해당하며, preload는 이两者 사이의 통신 다리 역할을 합니다.

통신 다이어그램

graph LR
    Config[package.json] -->|시작| Main[src/main/index.ts]
    subgraph 렌더러 프로세스
        RedererIndex[src/renderer/index.html]-->|임포트|RedererMain[src/renderer/src/main.ts]
        RedererMain-->|스타일|RedererCSS[src/renderer/src/assets/index.css]
        RedererMain-->|루트 컴포넌트|App[src/renderer/src/App.tsx]
        App-->|포함|components[src/renderer/src/components/*.tsx]
    end
    subgraph 프리로드
        Preload[src/preload/index.ts] -->|contextBridge|RedererIndex
    end
    subgraph 메인 프로세스
        Main -->|IPC 통신|Preload
    end

윈도우 스타일 설정

애플리케이션의 외형을 개선하기 위해 타이틀 바를 숨기고 커스텀 윈도우 컨트롤을 구현합니다.

src/main/index.ts 파일을 수정합니다:

// ...
function createWindow(): void {
  const mainWindow = new BrowserWindow({
    width: 900,
    height: 670,
    show: false,
    autoHideMenuBar: true,
    ...(process.platform === 'linux' ? { icon } : {}),

    center: true,
    title: 'MarkdownNotes',
    frame: false,
    vibrancy: 'under-window',
    visualEffectState: 'active',
    titleBarStyle: 'hidden',
    trafficLightPosition: { x: 15, y: 10 },

    webPreferences: {
      preload: join(__dirname, '../preload/index.js'),
      sandbox: true,
      contextIsolation: true
    }
  })
// ...

보안 정책을 수정하여 인라인 스크립트 실행을 허용합니다:

src/renderer/index.html:

<!doctype html>
<html>
  <head>
    <meta charset="UTF-8" />
    <meta
      http-equiv="Content-Security-Policy"
      content="default-src 'self'; script-src 'self' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data:"
    />
  </head>
  <body>
    <div id="root"></div>
    <script type="module" src="/src/main.tsx"></script>
  </body>
</html>

불필요한 파일을 정리합니다:

  1. src/renderer/src/assets에서 main.css를 제외한 모든 파일 삭제
  2. main.css 파일 내용을 비웁니다
  3. src/renderer/src/components/Version.tsx 컴포넌트 삭제
  4. App.tsx를 아래와 같이 간소화합니다:
function App(): JSX.Element {
  return (
    <>

    </>
  )
}

export default App

Tailwind CSS 설정

설치

yarn add -D tailwindcss postcss autoprefixer
npx tailwindcss init -p

설치가 완료되면 프로젝트 루트에 tailwind.config.jspostcss.config.js가 생성됩니다.

tailwind.config.js:

/** @type {import('tailwindcss').Config} */
module.exports = {
  content: ['./src/renderer/**/*.{js,ts,jsx,tsx}'],
  theme: {
    extend: {}
  },
  plugins: [require('@tailwindcss/typography')]
}

추가 패키지 설치

yarn add -D tailwind-merge
yarn add -D clsx
yarn add -D react-icons

메인 스타일 정의

src/renderer/src/assets/main.css:

@tailwind base;
@tailwind components;
@tailwind utilities;

@layer base {
  #root {
    @apply h-full;
  }

  html,
  body {
    @apply h-full;
    @apply select-none;
    @apply bg-transparent;
    @apply font-mono antialiased text-white;
    @apply overflow-hidden;
  }

  header {
    -webkit-app-region: drag;
  }

  button {
    -webkit-app-region: no-drag;
  }

  ::-webkit-scrollbar {
    @apply w-2;
  }

  ::-webkit-scrollbar-thumb {
    @apply bg-[#555] rounded-md;
  }

  ::-webkit-scrollbar-track {
    @apply bg-transparent;
  }
}

좌우 레이아웃 구성

React 컴포넌트를 생성하여 좌우 레이아웃을 구현합니다.

파일 확장자 설명

  • .ts: TypeScript 스크립트 파일
  • .d.ts: 기존 JavaScript 코드를 TypeScript에서 사용하기 위한 타입 정의 파일
  • .tsx: React + 타입 체크가 포함된 파일

컴포넌트 기본 템플릿:

import { ComponentProps } from 'react'
import { twMerge } from 'tailwind-merge'

export const 컴포넌트이름 = ({ className, ...props }: ComponentProps<'div'>) => {
  return (
    <div className={twMerge('flex justify-center', className)} {...props}>
      
    </div>
  )
}

src/renderer/src/components/AppLayout.tsx 생성:

import { ComponentProps, forwardRef } from 'react'
import { twMerge } from 'tailwind-merge'

export const RootLayout = ({ children, className, ...props }: ComponentProps<'main'>) => {
  return (
    <main className={twMerge('flex flex-row h-screen', className)} {...props}>
      {children}
    </main>
  )
}

export const Sidebar = ({ className, children, ...props }) => {
  return (
    <aside
      className={twMerge('w-[250px] mt-10 h-[100vh+10px] overflow-auto', className)}
      {...props}
    >
      {children}
    </aside>
  )
}

export const Content = forwardRef<HTMLDivElement, ComponentProps<'div'>>(
  ({ children, className, ...props }, ref) => (
    <div ref={ref} className={twMerge('flex-1 overflow-auto', className)} {...props}>
      {children}
    </div>
  )
)

Content.displayName = 'Content'

App.tsx에 레이아웃 컴포넌트를 임포트합니다:

import { Content, RootLayout, Sidebar } from "./components/AppLayout"

function App(): JSX.Element {
  return (
    <>
      <RootLayout>
        <Sidebar className="p-2">
          사이드바
        </Sidebar>
        <Content className="border-l bg-zinc-900/50 border-l-white/20">
        콘텐츠 영역
        </Content>
      </RootLayout>
    </>
  )
}

export default App

드래그 가능한 헤더 추가

타이틀 바를 숨겼으므로 윈도우 드래그 기능을 위한 커스텀 헤더 컴포넌트가 필요합니다.

src/renderer/src/components/DraggableTopBar.tsx 생성:

export const DraggableTopBar = () => {
  return <header className="absolute inset-0 h-8 bg-transparent" />
}

App.tsx에 헤더 컴포넌트를 추가합니다:

import { Content, RootLayout, Sidebar } from "./components/AppLayout"
import { DraggableTopBar } from "./components/DraggableTopBar"

function App(): JSX.Element {
  return (
    <>
      <DraggableTopBar />
      <RootLayout>
        <Sidebar className="p-2">
          사이드바
        </Sidebar>
        <Content className="border-l bg-zinc-900/50 border-l-white/20">
        콘텐츠 영역
        </Content>
      </RootLayout>
    </>
  )
}

export default App

이제 상단 영역을 클릭하여 윈도우를 드래그할 수 있습니다.

사이드바 버튼 추가

공통 버튼 컴포넌트

src/renderer/src/components/Button/ActionButton.tsx 생성:

import { ComponentProps } from 'react'
import { twMerge } from 'tailwind-merge'

export type ActionButtonProps = ComponentProps<'button'>

export const ActionButton = ({ className, children, ...props }: ActionButtonProps) => {
  return (
    <button
      className={twMerge(
        'px-2 py-1 rounded-md border border-zinc-400/50 hover:bg-zinc-600/50 transition-all duration-100',
        className
      )}
      {...props}
    >
      {children}
    </button>
  )
}

새 노트 버튼

src/renderer/src/components/Button/NewNoteButton.tsx 생성:

import { ActionButton, ActionButtonProps } from './ActionButton'
import { LuFileSignature } from 'react-icons/lu'

export const NewNoteButton = ({ ...props }: ActionButtonProps) => {
  
  const handleCreation = async () => {
    console.info("노트 생성")
  }
  
  return (
    <ActionButton onClick={handleCreation} {...props}>
      <LuFileSignature className="w-5 h-5 text-zinc-300" />
    </ActionButton>
  )
}

노트 삭제 버튼

src/renderer/src/components/Button/DeleteNoteButton.tsx 생성:

import { ActionButton, ActionButtonProps } from './ActionButton'
import { FaRegTrashCan } from 'react-icons/fa6'

export const DeleteNoteButton = ({ ...props }: ActionButtonProps) => {
  
  const handleDelete = async () => {
    console.info("노트 삭제")
  }
  
  return (
    <ActionButton onClick={handleDelete} {...props}>
      <FaRegTrashCan className="w-5 h-5 text-zinc-300" />
    </ActionButton>
  )
}

버튼 행 컴포넌트

src/renderer/src/components/ActionButtonRow.tsx 생성:

import { ComponentProps } from 'react'
import { NewNoteButton } from './Button/NewNoteButton'
import { DeleteNoteButton } from './Button/DeleteNoteButton'

export const ActionButtonRow = ({ ...props }: ComponentProps<'div'>) => {
  return (
    <div {...props}>
      <NewNoteButton />
      <DeleteNoteButton />
    </div>
  )
}

App.tsx에 버튼 행을 추가합니다:

import { ActionButtonRow } from "./components/ActionButtonRow"

// ... 기존 임포트 유지

function App(): JSX.Element {
  return (
    <>
      <DraggableTopBar />
      <RootLayout>
        <Sidebar className="p-2">
          <ActionButtonRow className="flex gap-2 mb-4" />
        </Sidebar>
        <Content className="border-l bg-zinc-900/50 border-l-white/20">
        </Content>
      </RootLayout>
    </>
  )
}

macOS의 다크 모드를 활성화하고 애플리케이션을 실행하면 다음과 같이 표시됩니다:

현재 스타일은 다크 모드 전용으로 최적화되어 있습니다.

Mock 데이터 활용

스토어 디렉토리에 테스트 데이터를 추가합니다.

미리보기 기능 추가

날짜 포맷 함수를 작성합니다.

Markdown 에디터 통합

MDXEditor를 설치합니다:

yarn add @mdxeditor/editor
yarn add -D @tailwindcss/typography

스크롤바 스타일을 개선합니다:

src/renderer/src/assets/index.css:

@layer base {
    // ...
    
    ::-webkit-scrollbar {
        @apply w-2;
    }

    ::-webkit-scrollbar-thumb {
        @apply bg-[#555] rounded-md;
    }

    ::-webkit-scrollbar-track {
        @apply bg-transparent;
    }
}

Markdown 에디터 컴포넌트를 생성합니다:

// ...
return <MDXEditor
        key={selectedNote.title}
        markdown={selectedNote.content}
        // ...
/>

노트 제목 추가

Jotai를 설치합니다:

yarn add jotai

상태 관리를 위한 Jotai atoms을 설정합니다.

노트 생성 및 삭제 기능 구현

파일 시스템을 활용하여 노트를 영구적으로 저장합니다.

파일 시스템 설정

mkdir ~/MarkdownNotes
cd ~/MarkdownNotes
echo "Note1의 내용" > Note1.md
echo "Note2의 내용" > Note2.md

필요한 패키지 설치

yarn add fs-extra
yarn add -D lodash

타입 검사를 실행합니다:

yarn typecheck:web

파일 쓰기 기능 구현

src/shared/types.ts:

// ...

export type WriteNote = (title: NoteInfo['title'], content: NoteContent) => Promise<void>

환영 화면 추가

첫 실행 시 표시할 환영 페이지를 구성합니다.

빌드 및 배포

개발 모드 실행

yarn dev

프로덕션 빌드

yarn build
yarn start

macOS 앱 빌드

yarn build:mac

참조 자료

  • Build a Markdown Notes app with Electron, React, Typescript, Tailwind and Jotai - YouTube
  • 빠른 시작 | Electron

태그: Electron React TypeScript tailwindcss jotai

5월 30일 10:06에 게시됨