[Flutter] 크로스플랫폼 노트 앱 개발기 #1 — 프로젝트 설계와 Flutter 초기 설정

크로스플랫폼 노트 앱 개발기 시리즈 #1 프로젝트 설계와 Flutter 초기 설정 | #2 로컬 DB와 CRUD | #3 UI와 4플랫폼 빌드 | #4 3종 에디터 | #5 검색 기능 | #6 테마/설정


#1 — 프로젝트 설계와 Flutter 초기 설정

이번 편에서는 앱을 만들게 된 배경과 전체 설계, 그리고 Flutter 프로젝트 초기 설정까지 다룬다.




왜 만들게 되었나

회사에서는 맥을 쓰고, 집에서는 윈도우 PC를 쓴다. 핸드폰은 갤럭시.

맥에서는 iCloud 노트를 쓰는데, 이게 Apple 기기 간에만 동기화된다. 윈도우에서 열어볼 수 없고, 안드로이드에서도 쓸 수 없다. 회사에서 메모한 걸 집에서 이어서 보려면 매번 복사해서 옮겨야 하는 불편함이 있었다.

물론 시중에 크로스플랫폼 노트 앱은 많다. Notion, Obsidian, Google Keep 등. 하지만 단순히 텍스트 메모를 빠르게 적고 싶을 뿐인데, 이런 앱들은 기능이 너무 많거나 별도 계정이 필요하거나 무료 범위가 제한적이었다.

그래서 맥, 윈도우, 안드로이드, iOS 어디서든 쓸 수 있는 단순한 노트 앱을 직접 만들기로 했다. 필요한 기능만 딱 넣고, 내 Google Drive에 암호화해서 저장하면 되겠다 싶었다.




기술 스택 선택 — 왜 Flutter인가

4개 플랫폼을 모두 지원해야 하니 크로스플랫폼 프레임워크는 필수다. 후보를 비교해봤다.

프레임워크 모바일 데스크톱 UI 공유 생태계
React Native 안정적 실험적 (Windows는 MS 주도) 부분 공유 npm 생태계, JS 개발자 유리
Kotlin Multiplatform 네이티브 성능 Compose 기반 확장 중 로직만 공유, UI는 플랫폼별 Kotlin 생태계, Android 개발자 유리
Flutter 안정적 안정적 (macOS/Windows) UI까지 완전 공유 pub.dev, Dart 전용

Flutter를 선택한 이유:

  1. 4플랫폼 동일 코드: 하나의 코드베이스로 iOS, Android, macOS, Windows를 모두 커버한다. React Native는 데스크톱 지원이 아직 불안정하고, KMP는 UI를 플랫폼별로 따로 작성해야 한다.

  2. UI까지 공유 가능: Flutter는 자체 렌더링 엔진(Skia/Impeller)을 사용해서 플랫폼에 관계없이 동일한 UI를 그린다. 플랫폼별 분기 코드가 최소화된다.

  3. 핫 리로드: 코드를 수정하면 앱을 재시작하지 않고 바로 반영된다. 특히 UI를 잡을 때 개발 속도 차이가 크다.

  4. Dart 언어: Java/Kotlin 경험이 있다면 Dart는 러닝 커브가 거의 없다. null safety가 기본이고, async/await 패턴도 익숙하다.




앱 설계

코드를 작성하기 전에 전체 구조를 먼저 잡았다. 나중에 기능이 추가되어도 구조가 흔들리지 않도록 초기 설계에 시간을 들였다.

목표로 한 핵심 기능

기능 설명 우선순위
3종 노트 타입 일반 텍스트, 마크다운, 체크리스트 2차
오프라인 우선 네트워크 없이도 모든 기능 동작 1차
전문 검색 제목 + 본문 검색 (SQLite FTS5) 2차
동기화 Google Drive에 암호화된 파일로 저장 3차
4플랫폼 동일 UX iOS, Android, macOS, Windows 1차

모든 걸 한 번에 만들 수는 없으니 단계별로 나눴다. 1차는 로컬 전용 기본 CRUD, 2차는 에디터와 검색, 3차부터 동기화와 인증을 다룬다.

아키텍처 — Clean Architecture 3레이어

┌──────────────────────────────────────┐
│  Presentation (Flutter Widget + Riverpod)  │
│  - 화면 렌더링, 사용자 입력 처리           │
│  - ref.watch로 상태 구독                    │
└──────────────────┬───────────────────┘
                   │
┌──────────────────▼───────────────────┐
│  Domain (Pure Dart)                         │
│  - Note, NoteType 등 엔티티 정의           │
│  - NoteRepository 인터페이스              │
│  - 외부 의존성 없음 (flutter import도 없음) │
└──────────────────┬───────────────────┘
                   │
┌──────────────────▼───────────────────┐
│  Data (Drift + Google Drive)               │
│  - DriftNoteRepository (SQLite 구현체)     │
│  - 추후: DriveApi, CryptoService           │
└──────────────────────────────────────┘

이 구조의 핵심은 Domain 레이어가 어디에도 의존하지 않는다는 것이다.

  • UI는 Domain의 Repository 인터페이스에만 의존한다. 실제 SQLite 구현체를 직접 알 필요가 없다.
  • 테스트할 때 Repository를 Mock으로 교체하면 DB 없이도 UI 로직을 검증할 수 있다.
  • 나중에 동기화 기능을 추가해도 Domain 레이어는 변경할 필요가 없다.

상태 관리 — Riverpod

Flutter의 상태 관리 솔루션은 여러 가지가 있다 — Provider, BLoC, GetX, Riverpod 등. 이 중 Riverpod을 선택한 이유:

  • 컴파일 타임 안전성: Provider와 달리 BuildContext에 의존하지 않아서, 잘못된 참조를 컴파일 시점에 잡을 수 있다.
  • 코드 생성 지원: @riverpod 어노테이션으로 Provider를 선언하면 boilerplate가 대폭 줄어든다.
  • Drift와의 궁합: Drift의 Stream API를 Riverpod Provider로 래핑하면, DB가 변경될 때 UI가 자동으로 리렌더링된다.

디렉토리 구조

lib/
├── core/                  # 공통 유틸
│   └── time/
│       ├── clock.dart         # DateTime.now() 추상화
│       └── fake_clock.dart    # 테스트용 Clock
├── features/
│   ├── notes/
│   │   ├── data/          # Drift DB, Repository 구현체
│   │   ├── domain/        # Note 엔티티, Repository 인터페이스
│   │   └── presentation/  # 화면, 컨트롤러, 에디터
│   ├── settings/          # 테마, 폰트 크기 설정
│   └── sync/              # 동기화 엔진 (추후)
├── app/                   # 앱 진입점, 라우터, 테마
└── main.dart

feature 단위로 디렉토리를 나누고, 각 feature 안에서 data/domain/presentation 레이어를 분리했다. 이렇게 하면 notes 기능을 수정할 때 settings 코드를 건드릴 일이 없고, 각 기능이 독립적으로 발전할 수 있다.




Flutter 프로젝트 생성

설계가 끝났으니 프로젝트를 생성한다.

flutter create --org com.baekhj cross_platform_note

이 명령 하나로 아래 구조가 자동 생성된다:

cross_platform_note/
├── android/       # Android 네이티브 프로젝트 (Gradle)
├── ios/           # iOS 네이티브 프로젝트 (Xcode)
├── macos/         # macOS 네이티브 프로젝트 (Xcode)
├── windows/       # Windows 네이티브 프로젝트 (CMake)
├── lib/           # Dart 소스 코드
├── test/          # 테스트
└── pubspec.yaml   # 의존성 관리

4개 플랫폼의 네이티브 프로젝트가 한 번에 생성된다. 이후 flutter run -d macosflutter run -d chrome 같은 명령으로 원하는 플랫폼에서 실행할 수 있다.




의존성 구성

이 프로젝트에서 사용할 주요 패키지들을 pubspec.yaml에 추가했다.

상태 관리 — Riverpod

flutter_riverpod: ^2.5.1
riverpod_annotation: ^2.3.5

riverpod_annotation은 코드 생성용 어노테이션을 제공한다. @riverpod를 붙이면 Provider 선언 코드를 자동으로 만들어준다.

로컬 DB — Drift (SQLite)

drift: ^2.18.0
sqlite3_flutter_libs: ^0.5.24
path_provider: ^2.1.3

Drift는 Dart용 SQLite ORM이다. 일반적인 sqflite와 비교했을 때:

항목 sqflite Drift
쿼리 방식 문자열 SQL Dart 코드 (타입 안전)
스키마 마이그레이션 수동 자동 (버전 관리)
Stream 지원 X O (데이터 변경 시 자동 알림)
FTS5 수동 SQL 지원

타입 안전한 쿼리와 자동 마이그레이션이 결정적이었다. 나중에 노트 타입을 추가하면서 DB 스키마를 변경할 텐데, Drift가 마이그레이션을 체계적으로 관리해준다.

path_provider는 각 플랫폼에서 적절한 DB 파일 저장 경로를 제공하는 패키지다.

라우팅 — go_router

go_router: ^14.2.0

Flutter의 기본 Navigator도 쓸 수 있지만, go_router는 선언적 라우팅을 지원한다. URL 기반으로 라우트를 정의하고, 파라미터 전달이 깔끔하다. 나중에 딥링크가 필요해지면 그대로 확장할 수 있다.

엔티티 — freezed

freezed_annotation: ^2.4.1
json_annotation: ^4.9.0
uuid: ^4.4.0

freezed는 불변(immutable) 데이터 클래스를 자동 생성해준다.

직접 작성하면 이런 코드가 필요하다:

// freezed 없이 직접 작성
class Note {
  final String id;
  final String title;
  final String content;
  // ... 이하 == 연산자, hashCode, toString, copyWith 전부 수동 구현
}

freezed를 쓰면:

@freezed
class Note with _$Note {
  const factory Note({
    required String id,
    required String title,
    required String content,
  }) = _Note;
}

이것만 작성하면 copyWith, ==, hashCode, toString, JSON 직렬화가 모두 자동 생성된다. 엔티티가 늘어날수록 이 차이는 더 커진다.

uuid는 노트의 고유 ID를 생성할 때 사용한다.




코드 생성 설정

이 프로젝트는 freezed, Drift, Riverpod 세 가지가 모두 코드 생성(build_runner)에 의존한다. 설정을 제대로 잡아놓지 않으면 개발 중 계속 에러를 만나게 된다.

build_runner 설정

build.yaml을 생성하여 코드 생성 옵션을 구성하고, 실행 명령은 다음과 같다:

dart run build_runner build --delete-conflicting-outputs

--delete-conflicting-outputs 옵션은 기존 생성 파일과 충돌이 생기면 덮어쓴다. 이 옵션 없이 돌리면 충돌 에러로 빌드가 실패하는 경우가 있다.

.gitignore 설정

코드 생성 결과물(*.g.dart, *.freezed.dart)은 git에 포함하지 않았다. 이유는:

  • 생성 파일은 소스에서 언제든 재생성 가능
  • 소스 변경 시 생성 파일도 같이 변경되어 diff가 지저분해짐
  • 다른 PC에서 작업할 때 build_runner를 한 번 돌리면 됨

린트 설정

analysis_options.yaml에 Flutter 권장 린트 규칙을 적용했다. 코드 스타일을 일관되게 유지하고, 잠재적 버그를 빌드 전에 잡아준다.




이번 편 정리

항목 내용
개발 동기 맥+윈도우+갤럭시 환경에서 iCloud 공유 불가 → 직접 만들기로 결심
기술 스택 Flutter 3.10+ / Dart 3.11+ (4플랫폼 단일 코드)
아키텍처 Clean Architecture 3레이어 (Presentation → Domain → Data)
상태 관리 Riverpod 2.x (컴파일 타임 안전, 코드 생성)
로컬 DB Drift 2.18 (타입 안전 SQLite ORM, 마이그레이션, Stream)
라우팅 go_router 14.x (선언적 라우팅)
엔티티 freezed (불변 데이터 클래스 자동 생성)
프로젝트 설정 Flutter 프로젝트 생성, 의존성 추가, build_runner 설정, 린트 구성

여기까지 프로젝트의 뼈대를 잡았다. 설계와 의존성이 갖춰졌으니 이제 실제 코드를 작성할 차례다.

다음 편에서는 Drift로 로컬 DB를 설계하고, 노트의 CRUD(생성/조회/수정/삭제)를 구현한다.



다음: #2 로컬 DB 설계와 노트 CRUD