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 선언적 라우팅, 딥링크 지원
PDF 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

한계: typeIdHiveField 번호는 한 번 확정되면 재배치가 불가능하다. 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)은 정적 분석 규칙으로 자동화 가능한가?

시리즈 전체 안내: 시리즈 목차

댓글

이 블로그의 인기 게시물

"LLM 핵심 학습 (1/6) — 기본: 토큰화·임베딩·어텐션·위치 인코딩"

"LLM 핵심 학습 (2/6) — 파인튜닝: LoRA·QLoRA·증류·Adapter"

"ML 기초 학습 (1/9) — 머신러닝과 sklearn: 학습의 좌표계"