
Flutter 앱을 직접 만들어 보며 익힌 Flutter App Architecture를 정리해 보자. 아직 프로젝트 앱에도 더 작업할 게 남아 있긴 하지만, 교육과정을 수료하고 앱도 일단은 양대 스토어에 올라간 시점에 한 번 정리해 보면 좋을 것 같더라. 기본적으로 공식문서를 많이 참고하게 되더라. 처음 읽었을 때는 무슨 소리인지 모르겠는 부분이 많았는데, 앱 개발이 어느 정도 진행된 후에 살펴 보니 그제서야 읽히는 부분이 꽤 있었다.
특히 Case Study에서 Compass 앱의 디렉토리 구조를 보여주는데, 뭐가 이렇게 복잡한가 싶었다. 너무 복잡해 보여서 이대로 하지는 않고 그냥 손 가는 대로 구조를 잡아 코드를 작성하다 보니 결국엔 이 방식대로 리팩토링 하게 되더라. (처음부터 말을 잘 들을 걸 그랬다 싶기도 하고 ㅋㅋ 샘플 앱은 괜히 샘플이 아니다...!)
하여간 가장 위에 있는 UI 계층부터 시작해서 아키텍처를 한 번 정리하고 넘어가기로 했다.
관심사 분리 (Separation of Concerns; SoC)
- UI 위젯은 화면을 그리는 것만 신경쓰며, 네트워크 통신 및 DB 쿼리에 대해 알아서는 안 된다.
- 데이터 레이어는 위젯 트리에 대해 완전히 독립적이어야 한다.
단방향 데이터 흐름 (Unidirectional Data Flow; UDF)
- 데이터는 아래에서 위로 흐른다.
- 이벤트는 위에서 아래로 흐른다.
단일 진실 공급원 (Single Source of Truth; SSOT)
- 데이터의 원천은 UI가 아닌 Repository가 독점 관리한다.
- 로컬 DB와 원격 서버 중 어떤 데이터를 최신으로 볼 것인지는 Repository가 결정한다.
사용자에게 정보를 보여주고 상호작용을 처리하는 레이어
Presentation 레이어라고도 부르며, 화면을 렌더링하고 사용자 입력을 감지하는 View와 UI 상태를 보관하고 Command를 실행하는 ViewModel로 구성된다.
한 화면에 들어가는 모든 요소를 한 파일에서 작성하면 build() 가 기하급수적으로 길어지며 위젯 트리가 깊어지고 가독성이 떨어지기 십상이다. 이를 방지하기 위해 화면을 구성하는 의미 있는 단위로 위젯을 모듈화하여 파일을 나눈다. 그렇게 나눈 파일을 정리하는 디렉토리 구조는 크게 두 가지 방식이 있다. 기능별로 묶어서 관리하는 것과 계층별로 묶어서 관리하는 것이다.
공식 문서에서는 이 두 가지를 혼합해서 사용한다. 전체적으로는 계층별로 묶어서 관리하되, UI 레이어 내에서는 기능별로 하위 디렉토리 구조를 갖는 것이다. ui 디렉토리에는 각 기능을 담당하는 디렉토리들이 들어 있고, 그 아래 해당 기능에 대한 widgets 디렉토리와 view_models 디렉토리에 모듈화된 View와 그 상태를 관리하는 ViewModel이 각각 들어 있다.
역할과 특징
- 순수한 UI 렌더링과 사용자 입력 수신을 담당한다.
- 비즈니스 로직이나 데이터 가공 코드를 포함하지 않는다.
- ViewModel이 제공하는 상태를 구독하여 그대로 화면에 반영한다.
lib/ui/<feature_name>/widgets/한 화면 또는 기능 안에서만 종속되어 사용되는 하위 컴포넌트들이 들어 있는 디렉토리다. 개별 widget 내부에서 ViewModel을 직접 참조하기보다는 가장 큰 뼈대가 되는 View에서만 ViewModel을 참조하고 나머지는 상위 View로부터 필요한 데이터와 이벤트 콜백을 인자로 전달받는 게 좋다. 이로서 위젯 트리 외부 상태에 종속되는 것을 방지할 수 있다.
Flutter는 상태가 바뀌면 해당 widget의 build() 를 호출하여 widget을 다시 그리기 때문에 build() 안에 너무 많은 것이 들어 있으면 다시 그리지 않아도 되는 부분까지 다시 그려지게 된다. 그러니까 모듈화는 가독성뿐만 아니라 성능을 위해서도 중요하다. 화면을 개별 widget으로 분리하면 해당 widget만 다시 그림으로써 자원 낭비를 막을 수 있다.
lib/ui/core/widgets/ & lib/ui/core/themes/어떤 UI 컴포넌트는 특정 화면이나 기능이 아니라 앱 전반에서 사용될 수 있다. 그런 녀석들을 따로 모아두는 곳이 lib/ui/core/widgets 이다. 여기에는 도메인을 전혀 모르는 순수 디자인 시스템 위젯과 여러 화면에서 반복적으로 등장하는 도메인 개념을 시각화하는 앱 전역 공통 도메인 위젯이 포함된다. 이론적으로는 공통 위젯은 도메인 모델을 알면 안 된다는 식으로 이야기하지만, 실무에서는 실용적인 관점에서 앱 전역 공통 도메인 위젯도 허용한다나.
그 옆의 lib/ui/core/themes/ 에는 디자인 시스템 자산이 담겨 있다. 타이포그래피 계층을 담은 TextTheme 와 색상 디자인 토큰을 담은 ColorScheme 를 위한 데이터다. 그 외 커스텀 색상 팔레트(ThemeExtension)도 여기서 정의할 수 있다.
역할과 특징
- 화면에 필요한 상태를 보관하고, UI 이벤트를 비즈니스 호출로 변환한다.
- 전통적인 공식 Compass App 스타일
ChangeNotifier와Command패턴을 사용하여 구현한다.- 버튼 실행 상태와 중복 탭 방지를 캡슐화한다.
- 단순하고 프레임워크 비종속적이며, 버튼 상태 바인딩에 최적화되어 있다.
- 현대적인 Riverpod 스타일
AsyncNotifier및AsyncValue를 사용하여 구현한다.- 비동기 상태(로딩/성공/에러)를 컴파일 타임에 누락 없이 안전하게 다룬다.
- 컴파일 타임 안전성, 선언적 캐싱, 강력한 리액티브 바인딩을 보장한다.
lib/ui/<feature_name>/view_models/ViewModel은 화면 단위로 거의(반드시는 아니고!) 1대1로 매핑된다. 사용자 입력을 받아 하위 레이어를 호출하며, 그 결과에 따라 화면에 상태를 갱신하는 역할을 한다. 하위 레이어로부터 받은 상태는 패턴 매칭을 통해 처리하는 편이 실수로 특정 상태 조건에 대한 걸 누락시키는 것을 방지할 수 있고 가독성 측면에서도 좋다.
lib/ui/core/view_models/화면 단위로 매핑되지 않은 예외적인 ViewModel이 이곳에 존재한다. 앱을 만들다 보면 어느 한 화면에 종속시킬 수 없는 전역 상태가 발생하곤 하는데, 그 녀석들이 이곳에 들어온다. 우리 앱에서는 버전 정책을 확인하여 업데이트 다이얼로그를 띄울지 판단하는 로직, 푸시 알림 수신 상태를 관리하고 딥링크 라우트를 관리하는 로직, 로컬에 저장된 미전송 오프라인 데이터를 원격 서버와 동기화하는 로직이 여기에 들어 있다. lib/ui/home 같은 데에 넣으려면 넣을 수는 있겠지만 단일 책임 원칙 관점에서 좋은 선택이 아니라고 판단했다.
UI 레이어와 Data 레이어 계층 사이에 선택적으로 존재하는 레이어
데이터 저장소의 구체적인 구현이나 화면의 표현 방식에 전혀 의존하지 않는, 순수한 비즈니스 규칙의 집합인 UseCase가 자리 잡고 있으며, 순수 Dart로 작성된 Model 또한 이곳에 존재한다.
역할과 특징
- 앱의 핵심 비즈니스 도메인 개념을 표현하는 순수 Dart class다.
- UI 위젯이나 특정 라이브러리의 저수준 구현에 종속되지 않는다.
- 데이터 무결성과 예측 가능성을 보장하기 위해 불변성을 기본으로 한다.
lib/domain/models/도메인 모델은 모든 필드를 final 로 선언하고 @immutable annotation을 붙여 불변 객체로 만들기 때문에 한 번 생성된 모델은 외부에서 상태를 직접 변경할 수 없다. 이는 어디서 값이 바뀌었는지 추적하기 어렵고 단방향 데이터 흐름의 예측 가능성을 무너뜨리기 쉬운 가변 객체의 특징으로부터 벗어나기 위함이다. 상태 변화가 필요할 땐 절대 값을 직접 바꾸지 않고 바꾸고자 하는 값만 명시하여 새 것을 복제 생성하는 copyWith 패턴을 사용한다.
이때, 특정 nullable 필드를 null 로 초기화하고 싶을 때 그대로 null 을 전달하면 값을 전달하지 않아 기존 값을 유지하려는 것과 구분되지 않는다. 따라서 copyWith 에서 nullable 필드는 value ?? this.value 와 같은 형태로 구현하지 않고, ValueGetter<T?>? type을 사용하여 구현한다. null 로 초기화하고 싶을 땐 해당 필드에 () => null 을 전달하면 된다.
역할과 특징
- 시스템에서 수행할 수 있는 하나의 구체적인 비즈니스 행위를 캡슐화한다.
- 상태를 갖지 않으며 단 하나의 실행 메서드(
execute())만을 외부에 노출한다.
왜 필요한가
- 둘 이상의 Repository를 사용하는 비즈니스 로직을 한 Repository가 다른 Repository를 호출하는 방식으로 구현하면 순환 참조가 발생하고 데이터 계층의 경계가 무너진다.
- Repository 간의 협업은 Repository보다 상위 계층에서 조율해야 한다.
- 그렇다고 상위 계층인 ViewModel에서 구현하면 ViewModel이 너무 많은 책임을 지게 되어 단일 책임 원칙에 위배된다.
- Repository들을 조율하는 비즈니스 트랜잭션 로직을 UseCase로 격리하면 ViewModel은 이 UseCase 하나만 호출하면 된다.
- 이렇게 분리하니 Mock Repository를 넣어 핵심 알고리즘만 단위 테스트를 수행하기도 좋다.
- 동일한 복합 정책이 여러 화면에 쓰일 때도 중복 없이 중앙집중식으로 관리할 수 있다.
공식 문서는 모든 비즈니스 로직에 UseCase를 만드는 것을 지양하며, 단순 CRUD 작업에는 UseCase를 만들지 말고 Repository를 직접 주입받아 사용할 것을 권고한다. 불필요한 보일러플레이트를 작성할 이유가 없다는 이유다. 두 개 이상의 Repository가 얽혀 있거나 비즈니스 규칙이 복잡할 때만 UseCase를 선택적으로 사용해야 한다.
lib/domain/use_cases/<feature_name>/위와 같은 이유로 UseCase를 작성하기로 결정한 비즈니스 로직이 이곳에 작성된다. 각 로직별로 하나의 class로 구현된다. 각 class는 하나의 비즈니스 로직을 처리하기 위해 둘 이상의 Repository를 조합하여 사용한다.
우리 프로젝트에서 사용하는 UseCase 중 하나를 예시로 든다면, 운동 세션을 완료하는 UseCase는 세 개의 Repository를 조합하여 사용한다. 로컬 DB에 실시간 세션 정보를 저장하고 관리하는 SessionRepository 를 통해 현재 진행 중인 세션을 완료 상태로 변경하고 세션 결과를 생성한다. 네트워크 단절에 대비하여 세션 결과를 차곡차곡 쌓아두는 OfflineSessionRepository 를 통해 방금 생성된 세션 결과를 enqueue한다. 세션 결과를 원격 서버에 올리고 관리하는 SessionResultRepository 를 통해 firestore와 storage에 세션 결과 데이터 및 스케치 이미지를 업로드 시도한다. 세션 업로드에 성공하면 오프라인 큐와 로컬 좌표 데이터를 폐기하고, 실패하면 업로드 대기열에 있는 세션 결과의 상태를 syncFailed 로 갱신하여 이후에 동기화를 재시도할 수 있도록 한다. 그 모든 과정이 complete_session_use_case.dart 의 execute() 에서 이루어진다.
앱에 필요한 모든 데이터 입출력을 책임지는 레이어
저수준 입출력을 담당하는 Service와 데이터 추상화를 담당하는 Repository로 구성된다.
역할과 특징
- 외부 세계(REST API, Firestore, Local SQLite, GPS 센서 등)와 직접 통신한다.
- 비즈니스 판단을 하지 않으며 상태가 존재하지 않는다.
- 네트워크 타임아웃, 파싱 에러 등의 원시 예외를 가감없이 던진다.
lib/data/services/Service는 '외부 시스템과 어떻게 대화할 것인가'에 집중하며, 데이터 원천의 특성에 따라 다음과 같은 하위 디렉토리를 구성한다.
lib/data/services/ ├── hardware/ # 기기 센서 및 플랫폼 통신 채널 ├── local/ # SQLite/Drift 로컬 데이터베이스 및 로컬 파일 입출력 └── remote/ # REST API, Firebase Firestore&Storage, 외부 Open API
외부 시스템에서 발생하는 예측 불가능한 예외들을 억지로 덮지 않고 상위 레이어로 전달한다. 그 예외들을 맥락에 맞게 잘 처리하는 역할은 Repository의 몫이다.
역할과 특징
- 서비스들을 조합하여 도메인 모델을 생산하고 상위 계층에 단일 진실 공급원 역할을 수행한다.
- 로컬 DB 캐시를 먼저 보여주고 백그라운드에서 원격 서버 데이터를 동기화할지 결정한다.
- 저수준 DTO나 JSON map을 안전하고 불변인 도메인 객체(Model)로 변환한다.
- 서비스에서 올라온 원시 예외들을 상위 계층이 다루기 쉬운
Result<T>형태로 변환한다.
lib/data/repositories/데이터 원천의 특징에 따라 하위 디렉토리를 나누는 Service와 달리 Repository는 기능별로 하위 디렉토리를 나눈다. 각 디렉토리에는 Repository의 인터페이스와 구현체가 함께 들어있다.
lib/data/repositories/ ├── <feature1> # 원격 저장소만 사용하는 기능 │ ├── <feature1>_repository.dart │ └── <feature1>_repository_remote.dart # 원격 저장소와 연결된 구현체 ├── <feature2> # 로컬 저장소만 사용하는 기능 │ ├── <feature2>_repository.dart │ └── <feature2>_repository_local.dart # 로컬 저장소와 연결된 구현체 └── <feature3> # 로컬 및 원격 저장소를 선택적으로 사용하는 기능 ├── <feature3>_repository.dart ├── <feature3>_repository_local.dart # 로컬 저장소와 연결된 구현체 └── <feature3>_repository_remote.dart # 원격 저장소와 연결된 구현체
나는 객체지향 clean architecture를 접해본 적 없이 Flutter architecture를 만나 별 위화감이 없었는데, 보통은 인터페이스는 Domain 레이어에 두고 구현체만 Data 레이어에 두는 방식이 많이 쓰인다는 모양이다. 그렇지만 Flutter 공식 아키텍처는 실용적인 이유로 그들을 함께 배치하기로 결정했다고.
- 실용적인 응집도 | 단일 Flutter 앱 프로젝트에서 인터페이스와 구현체가 서로 다른 레이어에 멀리 떨어져 있으면 파일을 탐색하고 유지보수하는 비용만 증가한다. 반면 같은 폴더에 두면 해당 Repository의 명세와 구현을 한눈에 파악할 수 있다.
- DIP(의존성 역전)의 본질 충족 | 중요한 것은 '물리적 파일의 위치'가 아니라 '상위 레이어가 구현체가 아닌 인터페이스에 의존하고 있는가'이다. ViewModel이나 UseCase는 구현체가 아닌 인터페이스 타입만을 주입받으므로, 레이어를 이렇게 구성한다고 해서 단위 테스트 시 Mock Repository를 갈아끼우는 데에는 아무 지장이 없다.
- 데이터 소스별 구현체 교체 용이성 | 로컬 DB 전담(
_local), 원격 네트워크 전담(_remote), 목업 전담(_mock) 등 구현체가 늘어나도 한 디렉토리 안에서 명확히 관리할 수 있다. 이를 위해 Flutter의 Repository 구현체는 파일 이름에_impl가 아니라 어떤 구현체인지 구체적으로 명시한다.
Repository는 Service에서 전달된 예외를 안전하게 catch하여 상위 계층이 다루기 좋은 Result.ok(data) 또는 Result.error(exception) 으로 변환하여 올려 보낸다. 상위 레이어에서는 Result 객체에 대한 패턴 매칭으로 모든 경우의 수를 누락 없이 커버할 수 있다. 필요 시 Crashlytics에 치명적인 에러를 로깅하는 책임도 Repository에서 수행한다.
3대 기본 레이어를 든든하게 받쳐주는 인프라 및 유틸리티 파일들
lib/utils/result.dartResult 객체는 성공과 실패를 담는다. 성공에 대해서는 데이터를 Result.ok() 로 감싸서 전달하고 실패에 대해서는 예외를 Result.error() 로 감싸서 전달하도록 하는 sealed class다. Rust의 일반적인 예외처리가 Result 로 이루어지는 것과 달리 Dart 표준 라이브러리에는 공식적인 Result type이 없기 때문에 프로젝트마다 직접 만들어 주어야 한다.
sealed class는 외부 라이브러리나 다른 파일에서 extends 할 수 없는 class다. 그 자체로 인스턴스화할 수는 없고 factory constructor를 사용한다. Result 의 경우 Result.ok() 와 Result.error() 가 factory constructor다. 외부에서 extends 할 수 없기 때문에 컴파일 시점에 이 class의 하위 class를 모두 알 수 있고, 모든 하위 타입이 다 처리되었는지 파악하기 용이하기 때문에 패턴 매칭과 함께 사용하기 좋다.
다음은 공식 데모 앱 Compass App에서 정의한 Result 코드다.
lib/utils/result.dartsealed class Result<T> { const Result(); /// Creates a successful [Result], completed with the specified [value]. const factory Result.ok(T value) = Ok._; /// Creates an error [Result], completed with the specified [error]. const factory Result.error(Exception error) = Error._; } /// Subclass of Result for values final class Ok<T> extends Result<T> { const Ok._(this.value); /// Returned value in result final T value; String toString() => 'Result<$T>.ok($value)'; } /// Subclass of Result for errors final class Error<T> extends Result<T> { const Error._(this.error); /// Returned error in result final Exception error; String toString() => 'Result<$T>.error($error)'; }
Result class를 extends하는 class는 오직 Ok<T> 와 Error<T> 뿐이고, Result 의 factory constructor를 통해 이들을 생성할 수 있다. 그리고 다음과 같은 패턴 매칭으로 모든 경우의 수를 커버할 수 있다.
switch (result) { case Ok(:final value): // 결과값 value를 사용하여 작업을 처리하는 로직 // generic type으로 구현되어 있어 `as` 다운캐스팅 없이 안전하게 언패킹 case Error(:final error): // error로 전달된 예외를 스낵바로 띄워주거나 적절한 조치를 취하는 로직 }
lib/utils/command.dart우리 프로젝트에서는 Riverpod을 쓰느라 이걸 쓰지 않았지만 공식 데모 앱에는 이런 게 존재한다는 걸 ViewModel 다룰 때 잠깐 언급했다. ChangeNotifier 기반 ViewModel에서 비동기 작업을 처리할 때 발생하는 보일러플레이트 코드를 처리하기 위한 녀석이다. 외부 상태 관리 프레임워크를 도입하지 않고 Flutter 내장 기능인 listenable 을 통해 버튼 바인딩을 구현할 수 있다.
이벤트를 받는 버튼을 ListenableBuilder 로 감싸 작업이 실행 상태(running)인지 실행 가능 상태(canExecute)인지를 listenable 을 통해 구독한다. 실행 상태일 때 들어온 이벤트는 무시하고 실행 가능 상태일 때 들어온 이벤트만 처리하도록 한다. 그리고 결과는 안전하게 Result<T> 로 감싸 내보낸다.
공식 데모 앱 Compass App에서는 argument가 없는 작업은 return type T 에 대해 Command0<T> 를 사용하고 argument가 있는 작업은 return type T 와 argument type A 에 대해 Command1<T, A> 를 사용하도록 정의하고 있다.
lib/routing/routes.dart앱 내 모든 화면의 path 문자열과 동적 parameter 빌더를 중앙 관리하는 상수 클래스다. path 문자열이 이곳저곳에 하드코딩되어 있으면 오타가 나거나 path 변경 시 누락되는 부분이 발생하여 런타임 404 에러가 발생할 수 있다. 이를 방지하기 위해 인스턴스화 할 수 없는 abstract final class를 선언하여 모든 계층 구조와 동적 parameter 생성 함수를 모아 놓는다.
일부 path만 뽑아서 보면 대략 다음과 같다.
lib/routing/routes.dartabstract final class Routes { static const auth = '/auth'; static const loginRelative = 'login'; static const login = '$auth/$loginRelative'; static const feed = '/feed'; static String feedPost(String sketchId) => '$feed/post/$sketchId'; static const history = '/history'; static String historyDetails(String sessionId) => '$history/details/$sessionId'; }
lib/routing/router.dart앞서 routes.dart 에 작성한 path 상수들을 실제 화면과 매핑한다. go_router 패키지를 기반으로 선언적 라우팅을 한다. redirect를 사용하여 로그인하지 않은 사용자의 접근을 제한하고 로그인 화면으로 튕겨내는 인증 가드를 구현할 수 있다. StatefulShellRoute 를 사용하면 탭 전환 시 각각의 탭의 화면 상태를 보존하는 중첩 내비게이션을 구현할 수 있다.
lib/config/dependencies.dartService → Repository → UseCase로 이어지는 의존성 그래프를 조립하는 곳이다. 먼저 Service 인스턴스를 등록하고, 이를 주입받아 Repository 인스턴스를 인터페이스 타입으로 제공하며, 다중 Repository를 주입받아 UseCase 인스턴스를 선언한다. ViewModel에서는 이제 이 파일을 import하고 적절한 Repository 인스턴스 및 UseCase 인스턴스를 구독하면 된다.
lib/config/assets.dartroutes.dart 와 같은 맥락에서 정적 리소스 path 또한 이곳저곳에서 하드코딩하기보다는 한 곳에 정적 상수로 정의해 놓고 관리하자는 차원에서 만들어진 파일이다. 정적 리소스 path가 변경될 경우 그것을 사용한 코드를 일일이 찾아다닐 필요 없이 이 파일에서만 경로를 수정해 주면 되어 404 오류를 방지할 수 있다.
[Gemini가 작성한 흐름도]
교육과정 한 달째쯤 되었을 때 Flutter App Architecture에 대해 살펴보고 MVVM 패턴에 대해 살펴보면서는 내가 공식문서를 읽고는 있는데 이해했다고 보기는 어려운 상태로 넘어갔다. 직접 앱을 구현해 보며 삽질의 과정을 거치고 나니 그래도 흐름이 보이기 시작하는 것 같다. 지금 앱을 다시 만들라고 하면 좀 더 체계적으로 잘 만들 수 있을 것 같기도 하고 ㅋㅋ;; 아무래도 처음 해보는 작업이다 보니 헤맬 수밖에 없긴 했다. 그래도 그 시간이 있었기에 직접 부딪혀 보며 더 뚜렷하게 배우고 익혀 나갈 수 있었던 거 아닐까.
글 잘 읽었습니다!
저는 MVVM + Clean Architecture를 채택해서 진행했었는데, 이 글 보면서 제 코드 보니 오버 엔지니어링인 것 같은 느낌이 드네요.
불변 객체 모델 만들때 쓰는 freezed 패키지 있는데 이 패키지 쓰면 copyWith, toString등 자동 생성 해줘서 편합니다!
Result 객체도 result_dart라는 패키지를 쓰면 직접 생성하는 코드 줄어들고 성공 실패 분기처리도 간단하게 할 수 있어요 나중에 기회되시면 한 번 써보세요~