같은 기능의 앱을 여러 아키텍처 스택으로 구현해 비교 하는 Flutter 학습용 레포. Pixabay API 에서 이미지를 받아 로컬 DB/캐시에 저장하고 그리드로 보여주는 "갤러리" 앱 하나를, 상태 관리 / DI / 네트워크 / 로컬 DB 축을 바꿔 가며 s1 ~ s6 으로 구현
한 앱 안에 모든 스택이 공존 하고, --dart-define=STACK=sN 플래그로 실행 시 어느 스택으로 돌릴지 선택
- Pixabay API 로 꽃 이미지 리스트 fetch (
flower, 30 건) - 로컬 DB 에 메타데이터 저장 → 오프라인에서도 조회 가능
- 로컬 파일 캐시 (꽃 썸네일 + 작가 프로필 이미지)
- 2 열 GridView + 당겨서 새로고침 + 오프라인 배너
- 상세 화면 — 스크롤에 따라 이미지가 자연스럽게 축소되는 collapsing parallax hero, 태그/통계/AI 메타 플래그 표시, 사용자 이름 편집 + 저장
- 선택 모드 → 여러 아이템 선택 → 확인 바텀시트 → 일괄 삭제
- 사용하지 않게 된 user 는 자동 prune
| 스택 | State 관리 | DI | API | 로컬 DB |
|---|---|---|---|---|
| s1 | flutter_bloc | self (수동 생성자 주입) | dio (hand-rolled) | realm |
| s2 | flutter_bloc | injectable + get_it | dio (hand-rolled) | realm |
| s3 | flutter_bloc | injectable + get_it | retrofit + dio | realm |
| s4 | riverpod (generator) | injectable + get_it | retrofit + dio | realm |
| s5 | riverpod (generator) | injectable + get_it | retrofit + dio | drift |
| s6 (계획) | riverpod (generator) | injectable + get_it | retrofit + dio | sqflite (raw) |
dio 는 모든 스택 공통 HTTP client. s1
s2 는부터는 retrofit 이dio.get(...)을 직접 호출하는 hand-rolled 버전, s3@GET어노테이션으로 dio 호출 코드를 generator 가 만들어 주는 방식. retry interceptor / dio 설정 / DTO 는 전 스택 동일.
스택을 뒤로 갈수록 한 축씩만 교체 되므로, 이전 스택과 diff 가 학습 포인트가 됩니다 (s1→s2 는 DI, s2→s3 는 API, s3→s4 는 상태관리, s4→s5 는 DB).
- s1 — 모든 걸 수동 배선. 의존성 흐름이 한 파일 (
self_dependencies.dart) 에 그대로 드러남. - s2 — s1 대비 DI 만 교체.
@module/@LazySingleton어노테이션 +injectable_generator - s3 — s2 대비 API 만 교체.
@RestApi/@GET어노테이션으로retrofit_generator가 HTTP 클라이언트 자동 생성. - s4 — s3 대비 상태관리만 교체. 단일 Bloc state → 4 개 작은 provider (
StreamProvider/Notifier/AsyncNotifier) 로 분해,AsyncValue.guard로 try/catch 보일러플레이트 제거. - s5 — s4 대비 로컬 DB 만 교체 (realm → drift/SQLite).
FlowerRepository를 추상 인터페이스로 추출해 realm/drift 두 구현을 env 로 스위칭. - s6 (계획) — s5 대비 ORM 제거. drift 가 자동으로 해 주던 것 (타입 안전 쿼리 / 스트림 재실행 / 마이그레이션) 을 raw sqflite 로 직접 풀어 봄.
lib/
├── main.dart # STACK 분기 + MaterialApp 테마
│
├── core/ # 전 스택 공유 유틸/위젯
│ ├── colors.dart
│ ├── utils.dart
│ └── widgets/ # message_view, offline_banner,
│ # refresh_error_banner, selection_app_bar,
│ # cached_image 등
│
├── shared/ # 스택 간 공유 도메인/데이터 계층
│ ├── domain/
│ │ ├── flower_repository.dart # ★ 추상 인터페이스 (s1~s6 공통)
│ │ └── model/flower.dart # freezed 도메인 모델
│ ├── data/
│ │ ├── api/ # pixabay_api (abstract), flower_dto,
│ │ │ # retry_interceptor
│ │ ├── cache/image_cache_service.dart # 파일 캐시
│ │ └── connectivity/ # ConnectivityRepository
│ └── widgets/ # flower_tile, flower_detail_page,
│ # delete_confirm_sheet 등
│
├── data_impl/ # 구현 레이어 (스택별로 선택됨)
│ ├── retrofit/ # PixabayApi 의 retrofit 구현 (s3~s6)
│ │ ├── pixabay_retrofit_api.dart # PixabayApi 상속 + retrofit client 위임
│ │ └── pixabay_retrofit_client.dart # @RestApi 인터페이스
│ │
│ ├── realm/ # realm 기반 (s1~s4)
│ │ ├── entity/ # @RealmModel (FlowerEntity, UserEntity)
│ │ ├── realm_flower_mappers.dart # DTO ↔ Entity ↔ Domain
│ │ ├── realm_flower_local_data_source.dart
│ │ └── realm_flower_repository.dart # implements FlowerRepository
│ │
│ └── drift/ # drift 기반 (s5)
│ ├── tables/ # Flowers, Users (Table 상속)
│ ├── dao/ # FlowerDao, UserDao (@DriftAccessor)
│ ├── app_database.dart # @DriftDatabase
│ ├── drift_flower_mappers.dart
│ ├── drift_flower_local_data_source.dart
│ └── drift_flower_repository.dart # implements FlowerRepository
│
├── di/ # 의존성 주입
│ ├── self/self_dependencies.dart # s1 전용 수동 배선
│ └── injectable/ # s2~s6 공통
│ ├── injectable_dependencies.dart
│ ├── injectable_dependencies.config.dart # ← codegen
│ └── modules/app_module.dart # env 로 스택별 구현 선택
│
├── state_mgmt/ # UI 상태 관리 계층
│ ├── bloc/ # s1~s3 (Bloc)
│ │ ├── flower_list_bloc.dart
│ │ ├── flower_list_event.dart
│ │ ├── flower_list_state.dart
│ │ └── pages/flower_list_page.dart
│ └── riverpod/ # s4~s6 (Riverpod)
│ ├── dependency_providers.dart # getIt ↔ riverpod 브릿지
│ ├── flowers_provider.dart # StreamProvider<List<Flower>>
│ ├── online_provider.dart # Notifier<bool>
│ ├── selection_state.dart, selection_provider.dart
│ ├── flower_list_controller.dart # AsyncNotifier<void> (액션)
│ └── pages/flower_list_page.dart
│
└── stacks/ # 스택별 진입점 (Bootstrap + Scaffold)
├── s1_bloc_self_realm/bootstrap.dart
├── s2_bloc_injectable_realm/bootstrap.dart
├── s3_bloc_injectable_retrofit_realm/bootstrap.dart
├── s4_riverpod_injectable_retrofit_realm/bootstrap.dart
└── s5_riverpod_injectable_retrofit_drift/bootstrap.dart
핵심 원칙 — shared/ 와 core/ 는 전 스택 공통, data_impl/ 와 di/ 와 state_mgmt/ 와 stacks/ 는 스택별 선택. 공개 표면(도메인 인터페이스) 은 같고 구현만 env 로 스위칭되므로 같은 UI 가 어떤 스택을 고르더라도 그대로 동작.
| 버전 | |
|---|---|
| Flutter | 3.41.7 (stable, 개발 환경 기준) |
| Dart SDK | ^3.11.4 (pubspec) |
주요 의존성 버전 pin 은 pubspec.yaml 참고. 특히 source_gen 이 2.0.0 에 묶여 있어 drift_dev 는 >=2.26.0 <2.28.0 로 pin 되어 있음 (2.28+ 는 source_gen ^3.0.0 요구 → 다른 generator 들과 비호환).
# 의존성 설치
flutter pub get
# 코드 생성 (freezed / retrofit / riverpod / injectable / drift)
dart run build_runner build --delete-conflicting-outputs
# 스택 선택 실행
flutter run --dart-define=STACK=s1 # bloc + self + dio + realm
flutter run --dart-define=STACK=s2 # bloc + injectable + dio + realm
flutter run --dart-define=STACK=s3 # bloc + injectable + retrofit + realm
flutter run --dart-define=STACK=s4 # riverpod + injectable + retrofit + realm
flutter run --dart-define=STACK=s5 # riverpod + injectable + retrofit + drift
# 기본값은 s1
flutter run--dart-define=STACK=sN 은 const _stack = String.fromEnvironment('STACK', defaultValue: 's1') 로 읽혀서 main.dart 의 switch 에서 해당 스택의 Bootstrap.build() 를 호출
Pixabay API key 가 필요. 루트에 .env 파일을 만들고:
PIXABAY_API_KEY=your_pixabay_api_key_here
.env.example 참고.
MIT — LICENSE 파일 참고.