크로스플랫폼 노트 앱 개발기 시리즈 #1 프로젝트 설계 | #2 로컬 DB와 CRUD | #3 UI와 4플랫폼 빌드 | #4 3종 에디터 | #5 검색 기능 | #6 테마/설정
#2 — 로컬 DB 설계와 노트 CRUD
1편에서 프로젝트 뼈대를 잡았으니, 이번 편에서는 노트를 실제로 저장하고 불러오는 로직을 만든다. Clock 추상화부터 시작해서 Note 엔티티 정의, Drift DB 스키마, 그리고 Repository 패턴을 적용한 CRUD 구현까지 다룬다.
Clock 추상화 — DateTime.now()를 직접 쓰지 않는 이유
코드 어딘가에서 DateTime.now()를 직접 호출하면, 그 코드는 실행 시점의 시간에 의존하게 된다. 문제는 테스트할 때 드러난다.
예를 들어 “노트 수정 시 updatedAt이 갱신되는가?”를 테스트한다고 해보자. DateTime.now()를 직접 쓰면 테스트 실행 속도에 따라 createdAt과 updatedAt이 같은 밀리초로 찍힐 수도 있고, 1~2ms 차이가 날 수도 있다. 결과가 비결정적이 되는 것이다.
이 문제를 해결하는 가장 깔끔한 방법은 시간을 추상화하는 것이다.
abstract class Clock {
DateTime now();
}
class SystemClock implements Clock {
const SystemClock();
@override
DateTime now() => DateTime.now().toUtc();
}
프로덕션 코드에서는 SystemClock을 주입하고, 테스트에서는 FakeClock을 주입한다.
class FakeClock implements Clock {
FakeClock(this._current);
DateTime _current;
@override
DateTime now() => _current;
void advance(Duration d) {
_current = _current.add(d);
}
void setTo(DateTime t) {
_current = t;
}
}
FakeClock은 시간을 완전히 제어할 수 있다. advance()로 원하는 만큼 시간을 흘려보내고, setTo()로 특정 시점으로 고정할 수 있다. 이렇게 하면 테스트에서 “5분 후에 수정했을 때”같은 시나리오를 정확하게 재현할 수 있다.
// 테스트 예시
final clock = FakeClock(DateTime.utc(2026, 1, 1));
final note = createNote(clock: clock); // createdAt = 2026-01-01
clock.advance(Duration(minutes: 5));
final updated = updateNote(note, clock: clock); // updatedAt = 2026-01-01 00:05
참고로 SystemClock.now()에서 .toUtc()를 호출하는 것도 의도적인 설계다. 노트가 여러 기기에서 동기화될 때 시간대가 다를 수 있는데, UTC로 통일하면 이 문제를 원천 차단할 수 있다. 로컬 시간은 UI에서 표시할 때만 변환하면 된다.
이 패턴은 Flutter에 국한되지 않고, 시간에 의존하는 모든 코드에 적용할 수 있는 일반적인 테스트 기법이다. 한 번 적용해두면 시간 관련 테스트가 극적으로 편해진다.
Note 엔티티 — freezed로 불변 데이터 클래스 만들기
1편에서 freezed를 선택한 이유를 설명했다. 이제 실제로 Note 엔티티를 정의한다.
import 'package:freezed_annotation/freezed_annotation.dart';
part 'note.freezed.dart';
part 'note.g.dart';
@freezed
class Note with _$Note {
const factory Note({
required String id,
required String title,
required String content,
required DateTime createdAt,
required DateTime updatedAt,
}) = _Note;
factory Note.fromJson(Map<String, dynamic> json) => _$NoteFromJson(json);
}
이것만 작성하면 build_runner가 note.freezed.dart와 note.g.dart를 자동 생성한다. 생성되는 코드에는 다음이 포함된다:
copyWith: 특정 필드만 바꾼 새 객체를 생성한다.note.copyWith(title: '새 제목')처럼 사용한다.==/hashCode: 두 Note 객체의 모든 필드가 같으면 동일하다고 판단한다. 리스트나 Map에서 비교할 때 유용하다.toString: 디버깅할 때 모든 필드 값을 읽기 좋게 출력해준다.fromJson/toJson: JSON 직렬화/역직렬화. 나중에 동기화할 때 필요하다.
각 필드의 의미
| 필드 | 타입 | 설명 |
|---|---|---|
id |
String | UUID v4로 생성. 디바이스 간 충돌 없는 고유 식별자 |
title |
String | 노트 제목. 빈 문자열 허용 |
content |
String | 노트 본문. 빈 문자열 허용 |
createdAt |
DateTime | 최초 생성 시각 (UTC) |
updatedAt |
DateTime | 마지막 수정 시각 (UTC). 정렬과 동기화 충돌 해결에 사용 |
왜 id가 int 자동 증분이 아닌 UUID인가?
단일 기기만 쓴다면 SQLite의 autoincrement로 충분하다. 하지만 이후 여러 기기 간 동기화를 계획하고 있다. 기기 A에서 id=1, 기기 B에서도 id=1이 생성되면 충돌이 발생한다. UUID는 전 세계적으로 고유하기 때문에 이런 문제가 원천적으로 없다.
왜 createdAt과 updatedAt 둘 다 필요한가?
노트 목록에서는 “최근 수정한 노트”가 위로 올라와야 한다(updatedAt 정렬). 하지만 “이 노트를 처음 만든 게 언제였지?”라는 정보도 유용하다. 나중에 동기화 시 충돌 해결에서도 두 타임스탬프가 각각 역할을 한다.
NoteRepository 인터페이스 — Domain과 Data의 경계
Clean Architecture에서 가장 중요한 것 중 하나가 Domain 레이어가 Data 레이어에 의존하지 않는 것이다. 이를 위해 Repository를 인터페이스(추상 클래스)로 정의한다.
abstract class NoteRepository {
/// 모든 노트를 updatedAt 내림차순으로 스트림 반환.
/// 노트가 변경되면 새 리스트를 자동으로 emit한다.
Stream<List<Note>> watchAll();
/// ID로 노트 조회. 없으면 null.
Future<Note?> findById(String id);
/// 노트 저장 (upsert — 없으면 insert, 있으면 update).
Future<void> save(Note note);
/// 노트 영구 삭제.
Future<void> delete(String id);
}
이 인터페이스의 핵심적인 설계 포인트 몇 가지를 짚어보겠다.
watchAll()이 Future가 아닌 Stream인 이유
Future<List<Note>>로 해도 동작은 한다. 하지만 그렇게 하면 노트가 추가/수정/삭제될 때마다 UI에서 수동으로 다시 불러와야 한다. Stream<List<Note>>로 하면 DB에 변경이 생길 때마다 자동으로 새 리스트가 내려온다. Riverpod의 StreamProvider로 감싸면 UI가 자동으로 리렌더링된다.
[DB 변경] → [Stream emit] → [Riverpod 감지] → [UI 리빌드]
이 흐름이 자동이기 때문에 별도의 “새로고침” 로직이 필요 없다.
save()가 insert/update를 분리하지 않는 이유
별도의 create()와 update() 메서드를 만들 수도 있다. 하지만 upsert(insert + update) 하나로 통일하면:
- UI 쪽 코드가 단순해진다. “이 노트가 새 노트인지 기존 노트인지” 판단할 필요 없이 그냥
save()만 호출하면 된다. - SQLite의
INSERT OR REPLACE(Drift에서는insertOnConflictUpdate)가 이 패턴을 네이티브로 지원한다.
delete()가 하드 삭제인 이유
지금은 단순하게 하드 삭제를 사용한다. 나중에 동기화를 구현할 때 “이 노트가 삭제되었다”는 정보를 다른 기기에 전달해야 하므로, 그때 tombstone(소프트 삭제) 방식으로 마이그레이션할 계획이다. 처음부터 소프트 삭제를 넣으면 삭제된 노트 필터링 로직이 모든 쿼리에 들어가야 해서 오히려 복잡도만 올라간다.
Drift로 로컬 DB 스키마 정의
이제 인터페이스의 구현체를 만들 차례다. 먼저 Drift의 테이블 스키마를 정의한다.
Notes 테이블
@DataClassName('NoteRow')
class Notes extends Table {
TextColumn get id => text()();
TextColumn get title => text().withDefault(const Constant(''))();
TextColumn get content => text().withDefault(const Constant(''))();
IntColumn get createdAt => integer()();
IntColumn get updatedAt => integer()();
@override
Set<Column> get primaryKey => {id};
}
Drift에서는 Dart 클래스로 테이블을 정의한다. @DataClassName('NoteRow')은 Drift가 자동 생성하는 데이터 클래스의 이름을 지정한다. 이렇게 하면 Domain의 Note와 Data의 NoteRow가 명확하게 분리된다.
몇 가지 설계 포인트:
DateTime을 integer로 저장하는 이유
SQLite에는 날짜 타입이 없다. 문자열("2026-05-04T20:30:00Z")로 저장하는 방법도 있지만, 정수(millisecondsSinceEpoch)로 저장하면:
- 정렬이 빠르다 — 문자열 비교보다 정수 비교가 훨씬 빠르다
- 저장 공간이 작다 — 8바이트 정수 vs 24바이트 문자열
- 시간대 문제가 없다 — epoch는 항상 UTC 기준
기본값(withDefault)을 지정하는 이유
Drift에서는 나중에 컬럼을 추가할 때 기존 행에 대한 기본값이 필요하다. 처음부터 기본값을 설정해두면 스키마 마이그레이션이 수월해진다.
LocalDb 클래스
@DriftDatabase(tables: [Notes])
class LocalDb extends _$LocalDb {
LocalDb() : super(_openConnection());
/// 테스트용 인메모리 DB 생성자
LocalDb.forTesting(super.e) : super.new();
@override
int get schemaVersion => 1;
}
@DriftDatabase 어노테이션으로 이 DB가 관리하는 테이블 목록을 선언한다. build_runner를 돌리면 _$LocalDb가 자동 생성되어, 테이블에 대한 타입 안전한 쿼리 메서드를 제공한다.
forTesting 생성자는 중요하다. 프로덕션에서는 파일 기반 SQLite를 사용하지만, 테스트에서는 인메모리 DB를 주입한다. 이렇게 하면 테스트가 파일 시스템에 의존하지 않고, 테스트 간 격리도 보장된다.
DB 파일 경로
LazyDatabase _openConnection() {
return LazyDatabase(() async {
final dir = await getApplicationDocumentsDirectory();
final file = File(p.join(dir.path, 'cross_platform_note.sqlite'));
return NativeDatabase.createInBackground(file);
});
}
LazyDatabase는 DB 연결을 처음 사용할 때까지 지연한다. path_provider의 getApplicationDocumentsDirectory()는 각 플랫폼에 맞는 적절한 경로를 반환한다:
| 플랫폼 | 경로 예시 |
|---|---|
| macOS | ~/Library/Application Support/cross_platform_note/ |
| Windows | C:\Users\{user}\AppData\Roaming\cross_platform_note\ |
| Android | /data/data/com.baekhj.cross_platform_note/files/ |
| iOS | {sandbox}/Documents/ |
NativeDatabase.createInBackground(file)은 DB를 별도 isolate에서 열어서 UI 스레드를 블로킹하지 않는다. 노트 수가 많아지면 DB 작업이 UI를 버벅이게 만들 수 있는데, 이걸로 예방한다.
DriftNoteRepository — CRUD 구현
DB 스키마가 준비되었으니, NoteRepository 인터페이스를 Drift로 구현한다.
class DriftNoteRepository implements NoteRepository {
DriftNoteRepository(this._db);
final LocalDb _db;
생성자에서 LocalDb를 주입받는다. Repository는 DB 인스턴스를 직접 생성하지 않는다 — 의존성 주입(DI) 원칙에 따라 외부에서 받는다.
전체 노트 목록 스트림 — watchAll()
@override
Stream<List<Note>> watchAll() {
final query = _db.select(_db.notes)
..orderBy([(t) => OrderingTerm.desc(t.updatedAt)]);
return query.watch().map(
(rows) => rows.map(_fromRow).toList(growable: false),
);
}
Drift의 .watch()는 해당 테이블에 변경이 생길 때마다 새 결과를 emit하는 Stream을 반환한다. orderBy로 updatedAt 내림차순 정렬을 걸어서, 최근 수정된 노트가 항상 위에 온다.
_fromRow는 Drift의 NoteRow를 Domain의 Note로 변환하는 매퍼다:
Note _fromRow(NoteRow row) {
return Note(
id: row.id,
title: row.title,
content: row.content,
createdAt: DateTime.fromMillisecondsSinceEpoch(row.createdAt, isUtc: true),
updatedAt: DateTime.fromMillisecondsSinceEpoch(row.updatedAt, isUtc: true),
);
}
정수로 저장된 timestamp를 다시 DateTime으로 변환한다. isUtc: true를 반드시 넘겨야 한다 — 그렇지 않으면 로컬 시간대로 해석되어 시간이 틀어질 수 있다.
단건 조회 — findById()
@override
Future<Note?> findById(String id) async {
final row = await (_db.select(_db.notes)
..where((t) => t.id.equals(id)))
.getSingleOrNull();
return row == null ? null : _fromRow(row);
}
getSingleOrNull()은 결과가 0개면 null, 1개면 해당 행을 반환한다. 결과가 2개 이상이면 예외를 던진다(id가 PK이니 발생할 수 없지만 안전장치).
저장(upsert) — save()
@override
Future<void> save(Note note) async {
await _db.into(_db.notes).insertOnConflictUpdate(
NotesCompanion(
id: Value(note.id),
title: Value(note.title),
content: Value(note.content),
createdAt: Value(note.createdAt.toUtc().millisecondsSinceEpoch),
updatedAt: Value(note.updatedAt.toUtc().millisecondsSinceEpoch),
),
);
}
insertOnConflictUpdate는 SQLite의 INSERT ... ON CONFLICT DO UPDATE를 실행한다. id가 같은 행이 없으면 INSERT, 있으면 UPDATE. 이게 upsert의 핵심이다.
NotesCompanion은 Drift가 자동 생성하는 클래스로, Value() 래퍼를 통해 “이 필드는 값이 있다 / 없다”를 타입 안전하게 표현한다. 기본값이 있는 컬럼은 Value.absent()로 생략할 수도 있다.
DateTime을 millisecondsSinceEpoch로 변환하는 부분에서 .toUtc()를 먼저 호출하는 것에 주목하자. 로컬 시간대의 DateTime이 들어오더라도 UTC epoch로 저장된다.
삭제 — delete()
@override
Future<void> delete(String id) async {
await (_db.delete(_db.notes)
..where((t) => t.id.equals(id)))
.go();
}
가장 단순한 메서드다. id로 해당 행을 삭제한다. .go()는 실제 삭제를 실행하고 삭제된 행 수를 반환하지만, 여기서는 반환값을 쓰지 않는다.
레이어 간 데이터 흐름
지금까지 만든 구조에서 노트 하나를 저장하고 조회하는 전체 흐름을 정리해보자.
[UI] save 버튼 클릭
↓
[Controller] Note 객체 생성 (clock.now()로 시간 설정)
↓
[NoteRepository.save(note)] ← 인터페이스 호출
↓
[DriftNoteRepository.save()] ← 실제 구현체
↓ Note → NotesCompanion 변환 (DateTime → epoch 정수)
↓ insertOnConflictUpdate 실행
[SQLite 파일에 저장]
↓
[Drift Stream이 변경 감지]
↓
[watchAll() 새 리스트 emit] ← NoteRow → Note 변환
↓
[Riverpod Provider 갱신]
↓
[UI 자동 리렌더링] — 새로 추가된 노트가 목록 최상단에 표시
중요한 점은 UI가 직접 DB를 알지 못한다는 것이다. UI는 NoteRepository 인터페이스만 참조하고, 실제로 Drift가 SQLite에 저장하는 건 Data 레이어 내부의 구현 세부사항이다. 나중에 저장소를 원격 API로 바꾸더라도 RemoteNoteRepository implements NoteRepository만 만들면 UI 코드는 한 줄도 수정할 필요가 없다.
이번 편 정리
| 항목 | 내용 |
|---|---|
| Clock 추상화 | DateTime.now() 대신 Clock 인터페이스 주입. 테스트에서 시간 제어 가능 |
| Note 엔티티 | freezed로 불변 데이터 클래스 정의. copyWith, ==, JSON 직렬화 자동 생성 |
| NoteRepository | Domain 레이어의 인터페이스. watchAll(Stream), findById, save(upsert), delete |
| Notes 테이블 | Drift 테이블 정의. DateTime은 epoch 정수로 저장, UUID PK |
| LocalDb | Drift DB 클래스. 파일 DB + 테스트용 인메모리 DB 생성자 |
| DriftNoteRepository | NoteRepository의 Drift 구현체. insertOnConflictUpdate로 upsert 처리 |
| 레이어 분리 | UI → Domain(인터페이스) → Data(Drift 구현체). 각 레이어가 독립적 |
여기까지 노트의 저장과 조회가 동작하는 백엔드가 완성되었다. Domain과 Data 레이어가 깔끔하게 분리되어 있어서, 다음 단계에서 UI를 붙일 때 Data 쪽 코드를 건드릴 일이 없다.
다음 편에서는 Riverpod 컨트롤러와 Flutter UI를 구현하고, 4개 플랫폼에서 빌드가 되는지 확인한다.
← 이전: #1 프로젝트 설계와 Flutter 초기 설정 → 다음: #3 UI 구현과 4플랫폼 빌드