Flutter 앱 개발기 (2/5) — 콰이어트(QuietLeaf) 기술 노트: 텍스트 리더 아키텍처
Riverpod 상태 관리, Hive 로컬 저장소, 1,848개 테스트, 다중 포맷 렌더링 설계
핵심 요약
- DDD 3계층 + Riverpod: Domain(순수 비즈니스 로직) → Data(Hive 10개 박스) → Presentation(UI). StateNotifier로 복합 읽기 상태, FutureProvider로 비동기 파일 로딩.
- 다중 포맷: TXT(인코딩 폴백 체인), EPUB(ZIP + HTML), PDF(페이지 렌더), 이미지(핀치 줌). 포맷별 프로바이더 분리.
- 안정화 결과: 3라운드에 걸쳐 24건의 패턴화된 이슈를 분류·수정. 주요 축은 O(n)→O(1) 조회, 컨트롤러 누수 차단, async mounted 체크, 테마 시스템 일관성.
- 테스트: 82 파일 / 1,848 케이스. AdMob 타이머와
pumpAndSettle충돌은pump(Duration)우회로 해결.
이 글이 다루는 것
Flutter로 로컬 텍스트 리더 앱을 만들 때 반복적으로 부딪히는 설계 지점을 정리한다. 인코딩 감지, 포맷별 렌더링 전략, 읽기 위치 복원, 테마 일관성, Hive 스키마의 비가역성 — 각 주제에서 어떤 선택지가 있고, 어떤 근거로 고르며, 어떤 한계가 남는지에 초점을 둔다. 앱 이름은 QuietLeaf.
1. 기술 스택 선택
| 항목 | 선택 | 근거 |
|---|---|---|
| 프레임워크 | Flutter 3.41 + Dart 3.11 | 크로스 플랫폼, 단일 코드베이스 |
| 상태 관리 | Riverpod 3.3 | StateNotifier로 복합 상태, FutureProvider로 비동기, 테스트 용이 |
| 로컬 저장소 | Hive CE 2.19 | 키-값 스토어, Box 단위 관심사 분리, 진입 장벽 낮음 |
| 라우팅 | go_router 17.1 | 선언적 라우팅, 딥링크 지원 |
| pdfrx 2.2 | 페이지 렌더링 | |
| EPUB | archive 4.0 + flutter_html | ZIP 파싱 + HTML 스타일 렌더링 |
| TTS | flutter_tts 4.2 | 텍스트 음성 변환, 속도/피치 조절 |
| 광고 | google_mobile_ads 7.0 | 보상형 광고 (시청 1회 → 16시간 광고 제거) |
Riverpod vs BLoC — 선택 기준
텍스트 리더의 상태 공간은 스크롤 위치, 현재 페이지, 진행률, 밝기, 폰트 크기, 테마처럼 연속적으로 갱신되는 값이 다수를 차지한다. BLoC의 이벤트-상태 패턴은 이벤트 타입을 매번 선언해야 하므로 이런 형태에서 보일러플레이트가 누적된다. StateNotifier는 메서드 호출 단위로 상태를 변경하므로 동일 기능을 약 절반의 코드로 표현할 수 있다.
FutureProvider는 EPUB/PDF처럼 비동기 파일 로딩이 필요한 지점에서 로딩/완료/에러 3상태를 프레임워크 수준에서 관리해주므로, 로더 UI를 별도로 구성할 필요가 없다.
반대로 이벤트 이력 추적이 중요한 도메인(예: 게임 입력 로그)에서는 BLoC가 더 적합하다. 선택은 상태가 연속값인지 이벤트 스트림인지로 가른다.
Hive vs SQLite — 선택 기준
1인 개발 규모에서 Hive는 스키마 선언 없이 바로 사용 가능하고, 키-값 접근이 빠르며, Box 단위로 엔티티 타입을 분리할 수 있다. 10개 Box 구성:
documents, reading_states, bookmarks, collections,
app_settings, reading_sessions, monetization_state,
search_history, collection_items, highlights
한계: typeId와 HiveField 번호는 한 번 확정되면 재배치가 불가능하다. AppSettings는 필드 45개까지 확장됐으며, 중간 번호를 재사용할 수 없으므로 추후 관계형 질의가 필요한 지점이 나오면 SQLite 마이그레이션 비용이 발생한다. 관계형 조회가 핵심인 도메인이면 처음부터 SQLite를 쓰는 편이 낫다.
2. DDD 3계층 아키텍처
Domain — 순수 비즈니스 로직
// Use Case 예시 — Flutter/Hive import 없음, 순수 Dart
class AddDocumentUseCase {
final DocumentRepository repository;
Future<Document> execute(String path) { ... }
}
Domain 계층에는 Flutter·Hive 임포트가 존재하지 않는다. AddDocumentUseCase, RestoreReadingStateUseCase, SaveReadingStateUseCase 유스케이스가 위치한다. 저장소 구현이 교체되어도 Domain은 수정 대상이 아니다.
Data — 영속화
Hive 서비스와 리포지토리가 위치한다. Box 초기화, 어댑터 등록, CRUD 연산을 담당한다.
모델 정의: - Document (typeId: 0) - ReadingState (typeId: 1) - Bookmark (typeId: 2) - AppSettings (typeId: 3, 필드 45개) - Collection (typeId: 4) - MonetizationState (typeId: 6) - ReadingSession (typeId: 7)
Presentation — UI
5개 메인 화면: - LibraryScreen — 5개 탭 (디렉토리 브라우저, 최근, 북마크, 검색, 하이라이트) - ReaderScreen — TXT/EPUB/PDF/이미지 렌더러 분기 - SettingsScreen — 45개 설정 항목 - StatisticsScreen — 일별/주별 독서 통계 차트 - ThemeBuilderScreen — 커스텀 테마 시드 색상 설정
3. 다중 포맷 렌더링 전략
TXT — 인코딩 폴백 체인
한국어 TXT는 UTF-8, EUC-KR, CP949가 혼재하며, 일본어는 Shift-JIS, 중국어는 Big5가 섞인다. 인코딩 자동 감지는 단일 알고리즘으로 해결되지 않으므로 폴백 체인으로 접근한다.
UTF-8 → EUC-KR → Shift-JIS → Big5 → Latin-1 (fallback)
각 단계에서 디코딩을 시도하고, 유효하지 않은 바이트 시퀀스가 발견되면 다음 인코딩으로 넘어간다. Latin-1은 모든 단일 바이트를 수용하므로 반드시 성공한다. 목표는 최선의 해석 + 크래시 방지다. 표시가 깨질지라도 앱이 중단되지 않는 것이 우선순위다.
대용량 파일(10MB+)은 512KB 청크 스트리밍으로 읽는다. 전체 파일을 한 번에 메모리에 올리면 저사양 기기에서 OOM이 발생한다.
EPUB — ZIP + HTML
EPUB 컨테이너는 ZIP 안에 OPF/NCX/XHTML이 배치된 구조다. archive로 압축을 해제하고, flutter_html로 XHTML을 렌더링한다. 목차 파싱, 챕터 간 네비게이션, 제목·볼드·이탤릭·인용구 스타일을 지원한다.
PDF — 페이지 단위 렌더링
pdfrx 기반. 페이지 단위 네비게이션, 현재 페이지 추적, 진행률 표시가 포함된다.
이미지 — 핀치 줌
JPG, PNG, WEBP, GIF, BMP 지원. 핀치 줌(0.5x~5x)과 패닝. 만화·스캔본 사용 사례에 대응한다.
각 포맷은 전용 프로바이더(epubContentProvider, pdfContentProvider)로 분리된다. 읽기 상태는 공통 인터페이스지만, 콘텐츠 로딩 로직은 포맷별로 달라지므로 프로바이더를 공유하지 않는다.
4. 읽기 경험 설계
위치 복원 — 500ms 디바운스
스크롤이 멈추고 500ms 경과 시 현재 위치를 Hive에 저장한다. 진행률(0.0~1.0)도 함께 기록된다. 디바운스가 없으면 스크롤 프레임마다 디스크 I/O가 발생하므로, 500ms는 사용자 체감 지연과 I/O 비용 사이의 타협점이다.
테마 시스템 — 10개 + 커스텀
Light, Dark, Sepia, Paper, Night, Green, Cream, Mint, Lavender, Ocean. 추가로 Material 3의 동적 색상 시딩을 활용한 커스텀 시드 모드가 있다.
규칙: 하드코딩 색상 금지. 모든 색상은 Theme.of(context).colorScheme에서 가져온다. 이 규칙을 어기면 테마 전환 시 일부 UI가 이전 색을 유지하는 리그레션이 발생한다. 안정화 과정에서 이 규칙 위반 14지점을 식별·수정했다.
하이라이트 · 북마크 · 검색
5가지 색상의 텍스트 하이라이트와 메모 첨부. 북마크는 위치 점프를 지원한다. 문서 내 검색은 미니맵과 결합된다.
TTS
flutter_tts 기반. 현재 읽는 문단을 UI에서 하이라이트하며, 속도·피치 조절을 제공한다.
접근성
Semantics, textScaler, 고대비 모드, 볼륨 키 네비게이션(옵트인), 몰입 모드(상태바·네비게이션 바 숨김).
5. 수익화 — 보상형 광고 설계
Google AdMob 보상형 광고. 시청 1회 → 16시간 광고 제거, 최대 3회(48시간) 누적.
배너 광고는 독서 흐름을 분절하므로 제외한다. 보상형은 사용자 선택 시점에만 노출되고 대가로 광고 없는 세션을 제공하므로, 읽기 경험과 정합적이다. 광고 네트워크 로드 실패 시 광고 없이 진행되도록 폴백 처리한다 — 네트워크 품질에 따라 UX가 좌우되지 않아야 한다.
6. 안정화 라운드에서 드러난 패턴
릴리스 전 3라운드 안정화에서 24건의 이슈가 식별됐다. 개별 버그보다 패턴 단위 분류가 더 가치 있다.
- 조회 복잡도:
box.values순회(O(n)) →box.get(id)직접 접근(O(1)). 컬렉션 크기 증가 시 지연이 선형으로 증가하는 지점을 제거. - 컨트롤러 누수: 다이얼로그 내
TextEditingController미해제 6곳. 다이얼로그는StatefulWidget으로 감싸고dispose()에서 해제한다. - async mounted 체크: 비동기 콜백 완료 후
setState호출 전mounted확인 누락. 전수 검사로 수정. - 테마 일관성: 하드코딩 색상 14곳 →
Theme.colorScheme로 치환. - 백업 복원 순서: 기존 순서는 "클리어 → 파싱"이었고, 파싱 실패 시 데이터 유실이 발생할 수 있었다. "파싱 → 검증 → 클리어"로 재배치하여 롤백 가능성을 확보.
이런 패턴은 tasks/lessons.md에 축적되어 후속 세션에서 동일 실수를 조기에 차단한다.
7. Claude Code 기반 개발 파이프라인
이 프로젝트는 Claude Code와의 페어 프로그래밍으로 개발됐다. 역할 분리는 다음과 같다.
| 에이전트 | 모델 | 역할 |
|---|---|---|
| orchestrator | Opus | 판단, 아키텍처 설계, 단순 구현 |
| executor | Sonnet | 복잡한 코드 작성, 리팩토링 |
| quality | Sonnet | 읽기 전용 리뷰, 코드 품질 검증 |
스킬은 키워드 트리거 기반으로 호출된다: brainstorming, code-review, deep-interview, flutter-hive, flutter-reader, flutter-test, git-commit, project-doctor, self-audit, testing, ux-ui-design, verification, writing-plans.
가장 효과가 컸던 지점은 안정화 라운드다. 패턴화된 이슈(미해제 컨트롤러, O(n) 조회, mounted 체크 누락)는 개별 코드 리뷰보다 규칙 기반 스캔이 더 빠르게 잡아낸다.
8. Play Store 배포 고려사항
진행 상태
- 내부 테스트 트랙: 게시
- 비공개 테스트: 심사 대상 (v2.0.0+22, 63.1MB AAB)
- 14일 비공개 테스트 기간 필요
- 프로덕션 릴리스 대기
MANAGE_EXTERNAL_STORAGE 권한
핵심 기능이 디렉토리 브라우징이므로 SAF(Storage Access Framework) 또는 MediaStore만으로는 TXT 파일 탐색이 충분하지 않다. 권한이 없으면 앱의 핵심 유스케이스가 성립하지 않는 구조이며, 심사 시 정당한 사유로 제출된다.
다국어
한국어, 영어, 일본어, 중국어. ARB 기반으로 267개 이상의 키를 관리한다.
9. 테스트 구성
82 파일 / 1,848 케이스.
검증 범위: - Hive 영속화 (모델별 CRUD) - Riverpod 프로바이더 상태 전이 - 유스케이스 (문서 추가, 읽기 상태 복원, 백업) - 설정 캐스케이드 (45개 필드) - 인코딩 감지 - 파일 브라우징 - TTS 문단 추적
주의 지점: AdMob 보상형 광고의 내부 타이머는 pumpAndSettle의 종료 조건을 충족하지 못한다. 위젯 테스트가 무한 대기 상태에 빠지므로, AdMob 의존 경로는 pump(Duration)으로 명시적 시간 증분을 사용한다.
10. 수치 요약
| 항목 | 값 |
|---|---|
| 앱 버전 | v2.0.0+22 |
| AAB 크기 | 63.1MB |
| 지원 포맷 | TXT, EPUB, PDF, 이미지 (5종) |
| 테마 | 10개 + 커스텀 |
| Hive Box | 10개 |
| 설정 필드 | 45개 |
| 테스트 | 1,848개 (82 파일) |
| 안정화 수정 | 24건 (3라운드) |
| 학습 패턴 | 28개 |
| 다국어 키 | 267개 × 4언어 |
| 스킬 | 14개 |
적용 가능 범위 · 열린 질문
적용 가능 범위 - 로컬 우선(local-first) Flutter 앱에서 Riverpod + Hive 조합은 복합 상태·비관계형 영속화 조합에 적합하다. - 인코딩 폴백 체인은 다국어 텍스트를 다루는 모든 리더·뷰어에 이식 가능하다. - 안정화 라운드의 패턴 분류(컨트롤러 누수, 복잡도, async mounted, 테마 일관성)는 대부분의 Flutter 앱에 공통 적용된다.
열린 질문
- Hive typeId 고정성은 장기 유지보수에서 언제 SQLite 전환 비용을 상회하는가? 관계형 질의 수요가 나타나는 시점을 어떻게 조기에 감지할 것인가?
- 보상형 광고의 가치-시간 환산(시청 1회 ↔ 16시간)은 어떤 지표로 튜닝해야 리텐션과 수익의 균형을 잡을 수 있는가?
- 테마 일관성 규칙(Theme.colorScheme only)은 정적 분석 규칙으로 자동화 가능한가?
시리즈 전체 안내: 시리즈 목차
댓글
댓글 쓰기