[{"content":"Ch05에서 상태관리를 끝냈다. ValueNotifier부터 Riverpod까지 같은 Todo 앱을 5번 갈아엎으면서 \u0026ldquo;뷰와 상태를 어떻게 분리하는가\u0026quot;에 집중했다면, Ch06은 **\u0026ldquo;그 상태를 서버에서 어떻게 가져오는가\u0026rdquo;**에 집중한다.\n지금까지의 Todo 앱은 로컬 데이터였다. 하지만 실제 앱은 서버에서 데이터를 받아오고, 유저 인증도 하고, 에러도 처리해야 한다. 이번 챕터에서 다루는 전체 파이프라인을 먼저 보면:\n1 2 뷰 → ref.watch → Provider → Retrofit → Dio → 인터셉터 → 서버 서버 → JSON → fromJson → Provider 상태 → .when() → 뷰 이걸 하나씩 쌓아 올린다.\n1. API 기본 개념 API란? 앱과 서버의 대화 방법이다. 식당으로 치면 메뉴판 — \u0026ldquo;이 URL로 이런 데이터를 이런 형식으로 보내면, 이런 응답을 줄게\u0026quot;라는 약속.\niOS에서 URLSession으로 직접 요청을 날려본 경험이 있는데, Flutter도 본질은 같다. URL + HTTP 메서드 + 바디 → 서버 → 응답.\nREST API REpresentational State Transfer. URL로 자원을 표현하고, HTTP 메서드로 행위를 구분하는 규칙이다.\n1 2 3 4 5 GET /todos → 전체 조회 (Read) GET /todos/1 → 1번 조회 (Read) POST /todos → 생성 (Create) PUT /todos/1 → 수정 (Update) DELETE /todos/1 → 삭제 (Delete) \u0026ldquo;RESTful하다\u0026quot;는 건 이 규칙을 잘 지킨다는 뜻이다. URL에 동사가 들어가거나(/getTodos), 전부 POST로 처리하면 RESTful하지 않다.\nHTTP 메서드 = CRUD HTTP 메서드 CRUD 설명 GET Read 데이터 조회 (바디 없음) POST Create 데이터 생성 PUT Update 전체 수정 PATCH Update 부분 수정 DELETE Delete 삭제 iOS의 URLRequest.httpMethod로 설정하던 것과 동일한 개념.\nHTTP 상태 코드 서버의 응답 결과를 세 자리 숫자로 알려준다.\n2xx 성공 — 200 OK, 201 Created, 204 No Content 4xx 내 잘못 — 400 Bad Request, 401 Unauthorized, 403 Forbidden, 404 Not Found 5xx 서버 잘못 — 500 Internal Server Error, 503 Service Unavailable 핵심은 401과 403의 차이. 401은 \u0026ldquo;너 누구야\u0026rdquo; (인증 실패), 403은 \u0026ldquo;너인 건 알겠는데 권한 없어\u0026rdquo; (인가 실패).\nJSON 서버와 주고받는 데이터 형식. XML도 있지만 요즘은 거의 JSON이다.\n1 2 3 4 5 { \u0026#34;id\u0026#34;: 1, \u0026#34;title\u0026#34;: \u0026#34;Flutter 공부\u0026#34;, \u0026#34;completed\u0026#34;: false } Dart에서는 Map\u0026lt;String, dynamic\u0026gt;으로 파싱된다. 이걸 직접 다루면 타입 안전성이 없으니, 뒤에서 Freezed로 모델 클래스를 자동 생성한다.\nHTTP vs HTTPS HTTP에 SSL/TLS 암호화를 얹은 게 HTTPS. 요즘은 HTTPS가 기본이고, iOS의 ATS(App Transport Security)처럼 Flutter도 실무에선 HTTPS만 쓴다.\n2. 인증 — 토큰 기반 인증 토큰이란? 로그인 성공 시 서버가 발급하는 증명서. 이후 모든 API 요청에 이 토큰을 함께 보내서 \u0026ldquo;나 로그인한 사람이야\u0026quot;를 증명한다.\nJWT (JSON Web Token) 구조 1 2 xxxxx.yyyyy.zzzzz 헤더 .내용 .서명 헤더: 알고리즘, 토큰 타입 내용 (Payload): 유저 ID, 만료 시간 등 (Base64 디코딩하면 읽을 수 있음 → 민감 정보 넣으면 안 됨) 서명: 서버만 검증 가능한 서명 (위조 방지) Access Token vs Refresh Token Access Token Refresh Token 수명 짧음 (15분~1시간) 김 (2주~1개월) 용도 API 요청 시 첨부 Access Token 재발급 탈취 시 피해 제한적 (곧 만료) 위험 (새 Access 발급 가능) 저장 메모리 or SharedPreferences SharedPreferences (암호화) 자동 갱신 흐름 1 2 3 4 1. API 요청 → 401 Unauthorized (Access Token 만료) 2. Refresh Token으로 /auth/refresh 요청 3. 새 Access Token 발급 4. 원래 요청 재시도 이 흐름을 매번 수동으로 하면 미친다. 뒤에서 Dio 인터셉터로 자동화한다.\n토큰 저장 1 2 3 4 5 6 // 저장 final prefs = await SharedPreferences.getInstance(); await prefs.setString(\u0026#39;accessToken\u0026#39;, token); // 읽기 final token = prefs.getString(\u0026#39;accessToken\u0026#39;); SharedPreferences는 iOS의 UserDefaults와 같다. 간단한 키-값 저장. 실무에선 flutter_secure_storage로 암호화 저장을 쓰기도 한다.\nFirebase Auth와의 차이 Firebase Auth JWT 직접 구현 토큰 관리 SDK가 자동 직접 관리 인터셉터 불필요 필요 유연성 제한적 자유로움 서버 Firebase 종속 자체 서버 Firebase Auth는 토큰 갱신을 SDK가 알아서 해준다. 편하지만 서버 로직을 직접 통제할 수 없다. 자체 서버가 있으면 JWT 직접 관리가 일반적.\n3. Dio — HTTP 클라이언트 http vs Dio Flutter에는 기본 http 패키지가 있다. 하지만 실무에선 거의 Dio를 쓴다.\nhttp (기본) Dio iOS 비유 URLSession Alamofire 인터셉터 없음 있음 토큰 자동 첨부 직접 구현 인터셉터로 자동 에러 핸들링 기본 풍부 파일 업로드 번거로움 FormData 지원 BaseUrl 설정 1 2 3 4 5 6 7 final dio = Dio( BaseOptions( baseUrl: \u0026#39;https://api.example.com\u0026#39;, connectTimeout: Duration(seconds: 5), receiveTimeout: Duration(seconds: 3), ), ); 한 번 설정해두면 이후 요청에서 dio.get('/todos')처럼 경로만 쓰면 된다.\n인터셉터 — 핵심 기능 인터셉터는 모든 요청/응답을 가로채서 가공하는 미들웨어다.\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 class AuthInterceptor extends Interceptor { @override void onRequest(RequestOptions options, RequestInterceptorHandler handler) { // 모든 요청에 토큰 자동 첨부 final token = TokenStorage.accessToken; if (token != null) { options.headers[\u0026#39;Authorization\u0026#39;] = \u0026#39;Bearer $token\u0026#39;; } handler.next(options); } @override void onError(DioException err, ErrorInterceptorHandler handler) async { if (err.response?.statusCode == 401) { // Access Token 만료 → Refresh Token으로 갱신 final newToken = await refreshToken(); if (newToken != null) { // 토큰 갱신 성공 → 원래 요청 재시도 err.requestOptions.headers[\u0026#39;Authorization\u0026#39;] = \u0026#39;Bearer $newToken\u0026#39;; final response = await dio.fetch(err.requestOptions); return handler.resolve(response); } } handler.next(err); } } 이걸 Dio에 등록하면:\n1 dio.interceptors.add(AuthInterceptor()); 이제 모든 API 요청에 토큰이 자동으로 붙고, 401이 오면 자동으로 갱신해서 재시도한다. 인터셉터 없이 이걸 하려면 모든 API 호출마다 토큰 로직을 넣어야 한다.\n앱 시작 시 1회 초기화 Dio 인스턴스는 앱 전체에서 하나만 만들어서 공유한다. 보통 DI(의존성 주입) 또는 Provider로 관리.\n4. Retrofit — API 코드 자동 생성 Retrofit이란? Dio 위에 얹는 코드 자동 생성 도구. API 인터페이스만 정의하면 구현체를 build_runner가 만들어준다.\n없을 때 vs 있을 때 Dio만 쓸 때 (수동):\n1 2 3 4 5 6 7 8 9 10 11 Future\u0026lt;List\u0026lt;Todo\u0026gt;\u0026gt; getTodos() async { final response = await dio.get(\u0026#39;/todos\u0026#39;); return (response.data as List) .map((json) =\u0026gt; Todo.fromJson(json)) .toList(); } Future\u0026lt;Todo\u0026gt; createTodo(Todo todo) async { final response = await dio.post(\u0026#39;/todos\u0026#39;, data: todo.toJson()); return Todo.fromJson(response.data); } API가 20개면 이런 코드가 20개. 실수도 잦고 지루하다.\nRetrofit 쓸 때 (자동):\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 @RestApi(baseUrl: \u0026#39;https://api.example.com\u0026#39;) abstract class TodoApi { factory TodoApi(Dio dio) = _TodoApi; @GET(\u0026#39;/todos\u0026#39;) Future\u0026lt;List\u0026lt;Todo\u0026gt;\u0026gt; getTodos(); @POST(\u0026#39;/todos\u0026#39;) Future\u0026lt;Todo\u0026gt; createTodo(@Body() Todo todo); @PUT(\u0026#39;/todos/{id}\u0026#39;) Future\u0026lt;Todo\u0026gt; updateTodo(@Path(\u0026#39;id\u0026#39;) int id, @Body() Todo todo); @DELETE(\u0026#39;/todos/{id}\u0026#39;) Future\u0026lt;void\u0026gt; deleteTodo(@Path(\u0026#39;id\u0026#39;) int id); } 선언만 하면 _TodoApi 구현체가 자동 생성된다. HTTP 메서드와 어노테이션이 직관적이라 API 문서를 보면서 그대로 옮기면 된다.\n어노테이션 정리 어노테이션 역할 예시 @RestApi 기본 URL 설정 @RestApi(baseUrl: '...') @GET GET 요청 @GET('/todos') @POST POST 요청 @POST('/todos') @PUT PUT 요청 @PUT('/todos/{id}') @DELETE DELETE 요청 @DELETE('/todos/{id}') @Path URL 경로 변수 @Path('id') int id @Body 요청 바디 @Body() Todo todo @Query 쿼리 파라미터 @Query('page') int page @Header 헤더 @Header('Authorization') String token build_runner 실행 1 dart run build_runner build .g.dart 파일이 생성된다. 코드가 바뀌면 다시 돌려야 하고, watch 모드로 자동 감지도 가능:\n1 dart run build_runner watch 5. Freezed + json_serializable — 모델 클래스 왜 필요한가 서버에서 오는 JSON을 Map\u0026lt;String, dynamic\u0026gt;으로 쓰면:\n1 2 final title = json[\u0026#39;title\u0026#39;] as String; // 타입 캐스팅 필수 final id = json[\u0026#39;id\u0026#39;] as int; // 키 오타나면 런타임 에러 타입 안전성이 없고, 필드가 10개면 지옥이다. Freezed + json_serializable은 이 문제를 코드 자동 생성으로 해결한다.\nFreezed 모델 정의 1 2 3 4 5 6 7 8 9 10 11 @freezed class Todo with _$Todo { const factory Todo({ required int id, required String title, @Default(false) bool completed, DateTime? dueDate, }) = _Todo; factory Todo.fromJson(Map\u0026lt;String, dynamic\u0026gt; json) =\u0026gt; _$TodoFromJson(json); } build_runner를 돌리면 자동으로 생성되는 것들:\nfromJson / toJson — JSON ↔ 객체 변환 copyWith — 불변 객체의 일부 필드만 변경한 새 객체 생성 == / hashCode — 값 비교 (동일 값이면 같은 객체로 판단) toString — 디버깅용 출력 @freezed vs @unfreezed @freezed @unfreezed 불변성 불변 (immutable) 가변 (mutable) 수정 방법 copyWith (새 객체) 직접 필드 수정 용도 상태관리 모델, API 응답 폼 입력 등 자주 바뀌는 임시 데이터 실무에선 거의 @freezed만 쓴다. 상태관리에서 불변 객체가 변경 감지에 유리하기 때문.\nSwift Codable과 비교 Flutter (Freezed) Swift (Codable) 정의 @freezed class Todo struct Todo: Codable JSON 변환 fromJson / toJson JSONDecoder / JSONEncoder 불변성 const factory let 프로퍼티 코드 생성 build_runner 필요 컴파일러가 자동 부분 수정 copyWith 직접 구현 필요 Swift의 Codable이 컴파일러 레벨에서 해주는 걸, Flutter는 build_runner로 해결한다. 좀 번거롭지만 결과물은 비슷하고, copyWith까지 공짜로 주니 오히려 편한 면도 있다.\ncopyWith 예시 1 2 3 4 5 6 7 8 final todo = Todo(id: 1, title: \u0026#39;Flutter 공부\u0026#39;, completed: false); // 완료 상태만 변경한 새 객체 final done = todo.copyWith(completed: true); // 원본은 변경되지 않음 print(todo.completed); // false print(done.completed); // true 6. Riverpod 연동 — Provider로 서버 데이터 관리 Ch05에서 Riverpod의 기본을 다뤘으니, 여기서는 서버 데이터와 어떻게 연동하는가에 집중한다.\n함수형 Provider = 읽기 (GET) 1 2 3 4 5 @riverpod Future\u0026lt;List\u0026lt;Todo\u0026gt;\u0026gt; todoList(Ref ref) async { final api = ref.watch(todoApiProvider); return api.getTodos(); } 함수형으로 선언하면 읽기 전용. GET 요청으로 데이터를 가져오는 용도. 자동으로 AsyncValue가 되어 로딩/에러/성공 상태를 .when()으로 처리할 수 있다.\n클래스형 Provider = CRUD 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 @riverpod class TodoListNotifier extends _$TodoListNotifier { @override Future\u0026lt;List\u0026lt;Todo\u0026gt;\u0026gt; build() async { final api = ref.watch(todoApiProvider); return api.getTodos(); } Future\u0026lt;void\u0026gt; addTodo(Todo todo) async { final api = ref.read(todoApiProvider); await api.createTodo(todo); ref.invalidateSelf(); // 목록 새로고침 } Future\u0026lt;void\u0026gt; deleteTodo(int id) async { final api = ref.read(todoApiProvider); await api.deleteTodo(id); ref.invalidateSelf(); } } 클래스형으로 선언하면 메서드를 통해 쓰기(C/U/D) 가능. ref.invalidateSelf()를 호출하면 build()가 다시 실행되어 서버에서 최신 데이터를 가져온다.\nref.invalidate — 데이터 새로고침 1 2 3 4 5 // Provider 외부에서 새로고침 ref.invalidate(todoListNotifierProvider); // Provider 내부에서 자기 자신 새로고침 ref.invalidateSelf(); \u0026ldquo;캐시를 버리고 다시 가져와\u0026quot;라는 의미. Pull-to-refresh 같은 기능에 딱이다.\nProvider끼리 참조 1 2 3 4 5 6 7 8 9 10 11 @riverpod TodoApi todoApi(Ref ref) { final dio = ref.watch(dioProvider); return TodoApi(dio); } @riverpod Future\u0026lt;List\u0026lt;Todo\u0026gt;\u0026gt; todoList(Ref ref) async { final api = ref.watch(todoApiProvider); // 다른 Provider 참조 return api.getTodos(); } ref.watch로 다른 Provider를 참조하면, 의존하는 Provider가 바뀔 때 자동으로 다시 실행된다. Provider끼리의 의존성 그래프를 Riverpod이 알아서 관리해준다.\n함수형 vs 클래스형 정리 함수형 Provider 클래스형 Provider 선언 @riverpod Future\u0026lt;T\u0026gt; func(Ref ref) @riverpod class Notifier extends _$Notifier 용도 읽기 전용 (GET) CRUD 전체 상태 변경 불가 (외부에서 invalidate만) 메서드로 직접 변경 복잡도 단순 약간 복잡 규칙: 읽기만 하면 함수형, 쓰기도 하면 클래스형.\n7. 전체 데이터 흐름 정리 이제 전체 파이프라인을 한 번에 보자.\n요청 (뷰 → 서버) 1 2 3 4 5 6 1. 뷰에서 ref.watch(todoListNotifierProvider) 2. Provider의 build() 실행 3. Retrofit의 getTodos() 호출 4. Dio가 HTTP 요청 생성 5. AuthInterceptor가 토큰 자동 첨부 6. 서버로 GET /todos 전송 응답 (서버 → 뷰) 1 2 3 4 5 6 1. 서버가 JSON 응답 2. Dio가 응답 수신 3. Retrofit이 fromJson으로 List\u0026lt;Todo\u0026gt; 변환 4. Provider 상태에 저장 (AsyncData) 5. ref.watch가 변경 감지 6. 뷰의 .when()이 data 분기로 렌더링 에러 흐름 (토큰 만료) 1 2 3 4 5 6 7 1. 서버가 401 응답 2. Dio의 AuthInterceptor가 가로챔 3. Refresh Token으로 새 Access Token 발급 4. 원래 요청 재시도 5. 성공하면 정상 응답 흐름으로 6. 실패하면 Provider가 AsyncError 상태 7. 뷰의 .when()이 error 분기로 렌더링 각 레이어의 역할 1 2 3 4 5 6 7 8 9 10 11 12 13 ┌─────────────┐ │ 뷰 │ ref.watch + .when()으로 상태별 UI ├─────────────┤ │ Provider │ 상태 관리 + 캐싱 + 새로고침 ├─────────────┤ │ Retrofit │ API 인터페이스 → 구현 자동 생성 ├─────────────┤ │ Dio │ HTTP 통신 + 인터셉터 (토큰, 로깅) ├─────────────┤ │ Freezed │ 불변 모델 + JSON 변환 자동 생성 ├─────────────┤ │ 서버 │ REST API 제공 └─────────────┘ 각 레이어가 자기 역할만 하고, 위아래로만 통신한다. 뷰는 Dio를 몰라도 되고, Retrofit은 인터셉터를 몰라도 된다. 관심사의 분리가 자연스럽게 이루어진다.\n느낀 점 Ch05에서 상태관리의 \u0026ldquo;왜\u0026quot;를 이해했다면, Ch06은 \u0026ldquo;무엇을\u0026rdquo; 관리하는가에 대한 답이다. 로컬 리스트가 아니라 서버에서 오는 실제 데이터를 다루니까 비로소 앱다운 앱이 된다.\n처음에 Dio, Retrofit, Freezed, json_serializable, build_runner\u0026hellip; 패키지가 너무 많아서 \u0026ldquo;이게 다 필요해?\u0026ldquo;라는 생각이 들었다. 하지만 하나씩 쌓아보니 각각이 한 가지 문제를 확실히 해결하고 있었다:\nDio → HTTP 통신 + 인터셉터 Retrofit → API 보일러플레이트 제거 Freezed → 타입 안전한 불변 모델 Riverpod → 서버 데이터의 상태 관리 iOS에서 Alamofire + Moya + Codable 조합을 쓰던 것과 구조적으로 비슷하다. 프레임워크는 달라도 **\u0026ldquo;네트워크 레이어를 추상화하고, 모델을 자동 생성하고, 상태로 관리한다\u0026rdquo;**는 패턴은 동일하다.\n다음 챕터에서는 이걸 실제 앱에 적용해본다.\n","date":"2026-05-05T00:00:00+09:00","permalink":"/p/ch06-flutter-networking/","title":"Ch06. Flutter 네트워킹 - API부터 Riverpod 연동까지 한방 정리"},{"content":"Ch05 시리즈를 통해 같은 Todo 앱을 ValueNotifier → GetX → BLoC → Riverpod → Hooks까지 5가지 방식으로 다뤄봤다. 강의 마지막에 \u0026ldquo;그래서 뭘 써야 하나\u0026quot;에 대한 총정리가 나왔는데, 여기에 내 생각도 섞어서 정리한다.\n왜 아키텍처를 고민하는가 많은 개발자들이 앱의 아키텍처를 궁금해하고, 어떻게 해야 좋은지 고뇌한다. 내 생각은 결국 쉬운 구조를 위해서가 아닐까 싶다.\n그 \u0026ldquo;쉬운 구조\u0026quot;를 판단하는 기준은 크게 5가지로 나눌 수 있다.\n1. 쉬운 개발 가장 직관적인 기준이다.\n구조 파악이 쉬운가 — 코드를 처음 보는 사람도 흐름을 빠르게 이해할 수 있는가 작성하는 코드의 양이 적은가 — 보일러플레이트가 적을수록 생산성이 올라간다 가독성이 좋은가 — 6개월 뒤의 내가 봐도 바로 이해되는가 Ch05에서 직접 체감한 걸 돌아보면:\n방식 보일러플레이트 진입 장벽 구조 파악 ValueNotifier 중간 낮음 쉬움 GetX 적음 낮음 쉬움 BLoC 많음 높음 Event 따라가면 명확 Riverpod 적음 중간 Provider 단위로 명확 Hooks + Riverpod 적음 중간 역할 분리 깔끔 BLoC은 Event/State/Bloc 클래스를 다 만들어야 하니까 코드량이 확실히 많다. 근데 그 대신 \u0026ldquo;이 이벤트가 이 상태를 바꾼다\u0026quot;가 명확해서 대규모 팀에서는 오히려 파악이 쉬울 수 있다. 결국 \u0026ldquo;쉬움\u0026quot;의 기준도 상황마다 다르다.\n2. 안정성 여기서 말하는 안정성은 \u0026ldquo;앱이 잘 돌아가느냐\u0026quot;가 아니다.\n일반적으로 코드를 짜면 코딩한 대로 동작하기 마련이다. 어떤 아키텍처를 쓰든, 어떤 상태관리를 쓰든, 출시 전에 개발자와 테스터가 테스트를 마치고 앱이 나가기 때문에 작동하지 않는 경우는 없다. 이런 관점에서는 모두 안정적이고 차이가 거의 없다.\n여기서 말하는 안정성은 Test Code를 작성할 수 있는가다.\n스펙이 변경되고 그에 따른 수정을 할 때, 예상하지 못한 버그가 일어나는 걸 최소화하려면 테스트 코드가 필요하다. 새 기능을 추가했는데 기존 기능이 깨지는 걸 사전에 잡아내는 것. 이게 진짜 안정성이다.\n방식 테스트 용이성 이유 GetX (Static) 어려움 전역 상태라 격리가 힘듦 BLoC (Scoped) 좋음 Event 입력 → State 출력 검증이 깔끔 Riverpod (Scoped) 좋음 ProviderScope로 격리, override로 mock 주입 ValueNotifier (Scoped) 보통 가능하지만 편의 기능이 적음 3. 성능 사실 성능은 일반적인 모바일 앱에서는 거의 동일하다.\n약 500만 번 정도의 연산이 일어나야 120Hz 렌더링을 방해할 수 있다 (애니메이션 예제 기준) 코드를 잘못 짜서 상호참조나 무한루프를 만드는 게 아닌 이상, 성능 차이를 체감하기는 어렵다 Flutter 자체가 **C 레벨의 네이티브(Skia/Impeller)**로 렌더링하기 때문에 성능이 워낙 좋다 물론 위젯 트리 구조대로 필요한 곳만 빌드하게 해서 성능을 최적화할 수는 있다. const 위젯이나 Consumer/BlocBuilder 같은 걸로 리빌드 범위를 줄이는 식으로.\n하지만 배터리 타임이나 CPU 타임에 영향을 줄 정도로 아키텍처나 상태관리 패키지가 차이를 만들지는 않는다. 아키텍처 선택이 성능을 좌우한다는 건 사실상 미신이다.\n4. 확장성 스펙이 바뀌거나 기능이 추가될 때, 기존 아키텍처가 유지되는 게 베스트다.\n새 화면 추가할 때 기존 구조를 건드리지 않아도 되는가 상태 하나 추가할 때 수정 범위가 해당 모듈 안에서 끝나는가 팀원이 늘어나도 각자 독립적으로 작업할 수 있는가 Scoped 방식(BLoC, Riverpod)은 Provider/Bloc 단위로 관심사가 나뉘어 있어서 확장할 때 기존 코드를 건드릴 일이 적다. GetX도 Controller 단위로 나누면 되지만, 전역 접근이 가능하다는 특성상 의도치 않은 의존성이 생길 수 있다.\n5. 제공되는 추가 기능들 각 패키지마다 별도로 제공하는 기능들이 있고, 이것도 선택에 영향을 준다.\n패키지 특화 기능 GetX 라우팅, 다이얼로그, 스낵바, 다국어 — 올인원 BLoC BlocObserver (Event 로깅), bloc_test Riverpod .when (비동기 분기), family (키 기반 상태), @riverpod 코드 생성 Hooks Controller 자동 dispose, Custom Hook 재사용 GetX는 상태관리 외에도 네비게이션, UI 유틸리티까지 한 패키지에 들어있다. BLoC은 이벤트 추적에 강하다. Riverpod은 비동기 처리가 압도적이다. 프로젝트에서 어떤 기능이 중요한지에 따라 선택이 달라진다.\n정답은 없다, 차이만 있다 아키텍처와 상태관리는 맞고 틀리고의 정답이 없다. \u0026lsquo;차이점\u0026rsquo;만 존재한다.\n5가지 기준을 한눈에 정리하면:\n기준 GetX BLoC Riverpod 쉬운 개발 쉬움 보일러플레이트 많음 적당함 안정성 (테스트) 격리 어려움 Event/State 검증 용이 override로 mock 주입 성능 동일 동일 동일 확장성 전역이라 의존성 주의 Bloc 단위 분리 Provider 단위 분리 추가 기능 올인원 Event 로깅 .when, family Scoped vs Static — 접근성 다시 보기 Ch04에서 Scoped Model과 Static Model을 다뤘었다. 실제로 써보고 나니 \u0026ldquo;접근\u0026quot;에 대한 생각이 좀 바뀌었다.\n모델 상태 접근 방식 대표 Scoped context.read() (BLoC), ref.read() (Riverpod) BLoC, Riverpod Static Get.find() — 어디서나 접근 GetX Static이 언뜻 보면 쉬워 보인다. context니 ref니 하는 참조 엘리먼트를 안 써도 되니까. 하지만 실무적으로 살짝 불편할 뿐, 구조적으로는 접근에 문제가 없다.\n애초에 앱을 실행시키는 main 함수에서 앱 객체를 만들 때부터 코드에서는 context와 ref를 참조할 수 있다. initState나 afterFirstLayout에서 context 접근이 가능하고, Riverpod은 ConsumerWidget만 쓰면 ref로 필요한 접근이 다 된다.\n그렇기 때문에 \u0026ldquo;접근\u0026rdquo; 자체는 정말 사소하게 불편할 뿐, 참조를 연결만 한다면 구조적으로 언제나 접근이 된다고 봐도 무방하다.\nWidget Tree 기반 설계의 차이 진짜 차이가 나는 건 Widget Tree에 기반한 설계다.\nScoped 방식(BLoC, Riverpod)은 위젯 트리 위치에 따라 상태의 범위가 자연스럽게 정해진다. 화면이 닫히면 해당 스코프의 상태도 자동으로 정리된다.\nGetX로 같은 걸 하려면 직접 구조를 구현해야 한다. 이전 블로그에서 다뤘듯이 임시 ID(tag)를 부여해서 관리해야 하는데, 해당 트리에 해당되는 위젯 생성자에 ID를 매번 전달하거나, InheritedWidget을 직접 구현해야 한다. 메모리 관리도 따로 해줘야 해서 오히려 더 불편해진다.\n1 2 3 4 5 6 7 8 9 10 11 // Riverpod: 스코프가 자연스럽게 관리됨 ProviderScope( overrides: [commentProvider.overrideWith(() =\u0026gt; CommentNotifier(videoId: id))], child: CommentSection(), ) // 화면 닫히면 자동 정리 // GetX: tag로 직접 관리해야 함 Get.put(CommentController(videoId: id), tag: id); // ... Get.delete\u0026lt;CommentController\u0026gt;(tag: id); // 직접 정리 4가지 방식 최종 비교 Ch05 전체를 통해 같은 Todo 앱을 돌려본 결과:\nValueNotifier GetX BLoC Riverpod 모델 Scoped (내장) Static Scoped Scoped 상태 변경 notifyListeners() .obs 자동 emit() state = 재할당 UI 구독 ValueListenableBuilder Obx BlocBuilder ref.watch 비동기 분기 직접 처리 직접 처리 직접 처리 .when 자동 보일러플레이트 중간 적음 많음 적음 추적성 낮음 낮음 높음 중간 메모리 관리 자동 수동 자동 자동 테스트 보통 어려움 좋음 좋음 접근 방식 context Get.find() context ref 여기에 Hooks를 더하면 로컬 UI 상태(useState, useTextEditingController)는 Hooks가, 공유 비즈니스 상태는 Riverpod이 맡아서 역할 분리가 깔끔해진다.\n공부법에 대한 생각 Flutter를 공부해보니 몇 개월, 몇 년 이상 공부한 사람들은 패키지, 아키텍처, 상태관리 관련해서 블로그나 유튜브 등을 찾아보면서 시간을 꽤 많이 쓰지 않았을까 싶다. 물론 좋은 방향이지만, 너무 알아보고 너무 살펴보는 데 많은 시간을 쏟는다면 조금은 낭비가 아닐까 조심스레 생각해본다.\n필자의 성격은 새로운 하나를 접하면(개발 외 모든 분야) A에서 Z까지 파보는 성격이다. 처음 개발을 접할 때도 그렇게 공부했다가 몇 달 만에 번아웃이 와버렸다.\n이번에 Flutter를 새로 공부하면서 그 습관은 버리고, 직접 프로젝트에 적용해 가며 안 되는 것들을 찾아가고, 이를 해결하기 위해 직접 코드를 구현하거나 다른 패키지들을 도입해보면서 해결할 수 있는 능력을 길렀다. 해보지도 않고 이론에만 시간 쏟는 것보다 와닿는 게 훨씬 빨랐다.\n물론 매우 주관적인 의견일 수 있다. 하지만 적어도 나한테는 이 방법이 맞았다:\n일단 만들어본다 — Todo 앱 하나를 4가지 방식으로 전환해본 게 블로그 10개 읽는 것보다 나았다 안 되는 걸 찾아본다 — 필요할 때 찾아보는 게 기억에 남는다 비교는 직접 해본 뒤에 — 써보지도 않고 \u0026ldquo;BLoC vs Riverpod\u0026rdquo; 비교 글만 읽으면 감이 안 온다 마무리 앞으로 많은 프로젝트를 할 예정이고, 현재 운영 중인 iOS 네이티브 앱을 Flutter로 전환하는 계획도 있다. 상태관리, 아키텍처, 각종 패키지 등 프로젝트 상황에 맞게 잘 적용해보자.\nCh05 시리즈를 한 문장으로 정리하면:\n아키텍처와 상태관리는 정답이 없다. 차이점만 있을 뿐이다. 직접 써보고 프로젝트에 맞는 걸 고르자.\n","date":"2026-05-03T02:00:00+09:00","permalink":"/p/ch05-6-architecture-wrap-up/","title":"Ch05-6. 아키텍처와 상태관리 총정리 - 정답은 없고 차이만 있다"},{"content":"강의에서 Hooks 부분은 눈으로만 봤는데, SwiftUI의 @State랑 너무 비슷해서 정리하지 않으면 아까울 것 같았다. StatefulWidget의 보일러플레이트가 싫었던 사람이라면 Hooks를 보는 순간 \u0026ldquo;이걸 왜 이제 알았지\u0026rdquo; 싶을 거다.\nStatefulWidget, 뭐가 불편한가 간단한 카운터 하나 만드는데 이만큼 써야 한다:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 class CounterPage extends StatefulWidget { const CounterPage({super.key}); @override State\u0026lt;CounterPage\u0026gt; createState() =\u0026gt; _CounterPageState(); } class _CounterPageState extends State\u0026lt;CounterPage\u0026gt; { int _count = 0; @override Widget build(BuildContext context) { return Scaffold( body: Center(child: Text(\u0026#39;$_count\u0026#39;)), floatingActionButton: FloatingActionButton( onPressed: () =\u0026gt; setState(() =\u0026gt; _count++), child: const Icon(Icons.add), ), ); } } StatefulWidget 클래스 + State 클래스, 2개를 만들어야 한다. createState() 보일러플레이트도 매번 써야 하고. 상태 변수가 하나인데도 이 정도다. TextEditingController나 AnimationController가 들어오면 initState에서 초기화하고 dispose에서 해제하는 코드까지 추가된다.\nReact에서 이 문제를 Hooks로 해결했고, Riverpod을 만든 Remi Rousselet이 Flutter 버전으로 flutter_hooks를 만들었다.\nHookWidget — 2개 클래스가 1개로 같은 카운터를 Hooks로 바꾸면:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 class CounterPage extends HookWidget { const CounterPage({super.key}); @override Widget build(BuildContext context) { final count = useState(0); return Scaffold( body: Center(child: Text(\u0026#39;${count.value}\u0026#39;)), floatingActionButton: FloatingActionButton( onPressed: () =\u0026gt; count.value++, child: const Icon(Icons.add), ), ); } } StatefulWidget + State 2개 클래스 → HookWidget 1개. createState() 보일러플레이트 사라짐. 상태 선언이 build 메서드 안에서 한 줄로 끝난다.\nSwiftUI 개발자라면 바로 느낌 올 거다:\n1 2 3 4 5 6 7 8 9 // SwiftUI struct CounterPage: View { @State private var count = 0 var body: some View { Text(\u0026#34;\\(count)\u0026#34;) Button(\u0026#34;+\u0026#34;) { count += 1 } } } @State가 useState고, .value로 접근하는 것만 다르다.\n핵심 Hooks useState — 로컬 상태 관리 1 2 3 final count = useState(0); // int final name = useState(\u0026#39;\u0026#39;); // String final isLoading = useState(false); // bool useState\u0026lt;T\u0026gt;(initialValue)는 ValueNotifier\u0026lt;T\u0026gt;를 내부적으로 만들고, .value가 바뀌면 위젯을 자동으로 리빌드한다. setState(() { ... })를 직접 호출할 필요가 없다.\nFlutter Hooks SwiftUI 설명 useState(0) @State var count = 0 로컬 상태 선언 count.value count 값 읽기 count.value++ count += 1 값 변경 → 자동 리빌드 useEffect — 생명주기 처리 initState + dispose를 하나로 합친 것이다:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 @override Widget build(BuildContext context) { // 마운트 시 실행, 리턴 함수는 dispose 시 실행 useEffect(() { final subscription = stream.listen(print); return subscription.cancel; // cleanup = dispose }, []); // [] = 마운트 시 1회만 // 특정 값이 바뀔 때마다 실행 final userId = useState(1); useEffect(() { fetchUser(userId.value); return null; // cleanup 불필요 }, [userId.value]); // userId가 바뀔 때마다 return ...; } 두 번째 인자(keys)가 핵심이다:\nkeys 동작 SwiftUI 대응 [] 마운트 시 1회 .onAppear [value] value 변경 시 .onChange(of: value) 생략 매 빌드마다 - return 함수 위젯 제거 시 실행 .onDisappear useMemoized — 비싼 연산 캐싱 1 2 // 매 빌드마다 재계산되는 걸 방지 final expensive = useMemoized(() =\u0026gt; heavyComputation(data), [data]); keys가 바뀔 때만 재계산한다. SwiftUI의 캐싱은 뷰 자체가 struct라서 자동으로 되는 부분이 있는데, Flutter는 build가 매번 호출되니까 직접 메모이제이션해야 한다.\nController 계열 — 자동 dispose가 핵심 StatefulWidget에서 Controller를 쓰면 항상 이 패턴이 반복된다:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 // StatefulWidget 방식: 초기화 + 해제를 직접 관리 class _MyState extends State\u0026lt;MyWidget\u0026gt; { late TextEditingController _controller; late AnimationController _animController; @override void initState() { super.initState(); _controller = TextEditingController(); _animController = AnimationController( vsync: this, duration: const Duration(milliseconds: 300), ); } @override void dispose() { _controller.dispose(); _animController.dispose(); super.dispose(); } } Hooks로 바꾸면:\n1 2 3 4 5 6 7 8 9 10 11 12 13 // Hooks: 한 줄이면 끝, dispose 자동 @override Widget build(BuildContext context) { final controller = useTextEditingController(); final animController = useAnimationController( duration: const Duration(milliseconds: 300), ); final tabController = useTabController(initialLength: 3); final focusNode = useFocusNode(); final scrollController = useScrollController(); return ...; } Hook 대체하는 것 자동 dispose useTextEditingController() TextEditingController + dispose O useAnimationController() AnimationController + TickerProvider + dispose O useTabController() TabController + dispose O useFocusNode() FocusNode + dispose O useScrollController() ScrollController + dispose O initState에서 만들고 dispose에서 해제하는 그 반복 패턴이 완전히 사라진다. 특히 useAnimationController는 TickerProviderStateMixin도 자동으로 처리해주니까 with SingleTickerProviderStateMixin 같은 mixin도 필요 없다.\nuseRef — 리빌드 없이 값 유지 1 2 final renderCount = useRef(0); renderCount.value++; // 이걸 바꿔도 리빌드 안 됨 useState와 달리 값이 바뀌어도 리빌드를 트리거하지 않는다. 렌더링 횟수 추적, 이전 값 기억 같은 용도로 쓴다.\nHook 규칙 3가지 React Hooks와 같은 규칙이다. 어기면 버그가 난다:\n1. 조건문 안에서 호출 금지 1 2 3 4 5 6 7 8 9 10 // 나쁜 예 if (isLoggedIn) { final name = useState(\u0026#39;\u0026#39;); // 조건에 따라 Hook 호출 순서가 바뀜 } // 좋은 예 final name = useState(\u0026#39;\u0026#39;); if (isLoggedIn) { // name.value 사용 } 2. 항상 같은 순서로 호출 1 2 3 4 // 항상 이 순서대로 호출돼야 함 final count = useState(0); final name = useState(\u0026#39;\u0026#39;); final isLoading = useState(false); Hooks는 내부적으로 호출 순서로 상태를 추적한다. 순서가 바뀌면 엉뚱한 값이 매칭된다.\n3. use 접두어 커스텀 Hook을 만들 때 use로 시작해야 한다. 컨벤션이자 Hooks임을 표시하는 약속이다.\nCustom Hook 만들기 반복되는 Hook 조합을 함수로 추출하면 된다:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 // 디바운스된 값을 반환하는 커스텀 Hook ValueNotifier\u0026lt;T\u0026gt; useDebounced\u0026lt;T\u0026gt;(T value, {Duration delay = const Duration(milliseconds: 500)}) { final debounced = useState(value); useEffect(() { final timer = Timer(delay, () =\u0026gt; debounced.value = value); return timer.cancel; // 이전 타이머 정리 }, [value, delay]); return debounced; } // 사용 @override Widget build(BuildContext context) { final searchText = useState(\u0026#39;\u0026#39;); final debouncedText = useDebounced(searchText.value); useEffect(() { searchApi(debouncedText.value); return null; }, [debouncedText.value]); return TextField(onChanged: (v) =\u0026gt; searchText.value = v); } 꿀팁 패턴들 useMemoized + useFuture — API 캐싱 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 @override Widget build(BuildContext context) { // useMemoized로 Future를 캐싱 → 리빌드해도 API 재호출 안 함 final future = useMemoized(() =\u0026gt; fetchUserData(), []); final snapshot = useFuture(future); if (snapshot.connectionState == ConnectionState.waiting) { return const CircularProgressIndicator(); } if (snapshot.hasError) { return Text(\u0026#39;에러: ${snapshot.error}\u0026#39;); } return Text(\u0026#39;${snapshot.data?.name}\u0026#39;); } useMemoized 없이 useFuture(fetchUserData())를 쓰면 매 빌드마다 새 Future가 만들어져서 API가 무한 호출된다. useMemoized로 Future 자체를 캐싱하는 게 핵심이다.\nuseDebounced — 검색 디바운싱 위의 Custom Hook 예제가 바로 이 패턴이다. 검색창에서 타이핑할 때마다 API를 호출하지 않고, 사용자가 타이핑을 멈춘 뒤 500ms 후에 한 번만 호출한다. 실무에서 검색 자동완성에 거의 필수다.\nRiverpod + Hooks 조합 여기서 진짜 빛난다. hooks_riverpod 패키지의 HookConsumerWidget을 쓰면 Hooks(로컬 상태) + **Riverpod(공유 상태)**를 한 위젯에서 쓸 수 있다.\n역할 분리 역할 도구 예시 로컬 UI 상태 Hooks (useState, useAnimationController) 텍스트 입력, 애니메이션, 토글 공유 비즈니스 상태 Riverpod (ref.watch, ref.read) 유저 데이터, Todo 목록, API 코드 예제 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 // HookConsumerWidget = HookWidget + ConsumerWidget class TodoPage extends HookConsumerWidget { const TodoPage({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { // Hooks: 로컬 상태 final controller = useTextEditingController(); final focusNode = useFocusNode(); final isExpanded = useState(false); // Riverpod: 공유 상태 final todoList = ref.watch(todoDataProvider); return Scaffold( appBar: AppBar(title: const Text(\u0026#39;Todo\u0026#39;)), body: Column( children: [ // 로컬 상태: 입력 필드 (이 화면에서만 필요) TextField( controller: controller, focusNode: focusNode, decoration: const InputDecoration(hintText: \u0026#39;할 일 입력\u0026#39;), ), ElevatedButton( onPressed: () { // Riverpod: 공유 상태 변경 ref.read(todoDataProvider.notifier).addTodo( Todo(title: controller.text), ); controller.clear(); focusNode.requestFocus(); }, child: const Text(\u0026#39;추가\u0026#39;), ), // Riverpod: 공유 상태 구독 Expanded( child: ListView.builder( itemCount: todoList.length, itemBuilder: (context, index) =\u0026gt; ListTile( title: Text(todoList[index].title), ), ), ), ], ), ); } } ConsumerStatefulWidget에서는 TextEditingController 초기화 + dispose를 직접 했어야 했다. HookConsumerWidget에서는 useTextEditingController() 한 줄이면 끝이다. Riverpod의 ref.watch/ref.read도 그대로 쓸 수 있다.\n위젯 타입 정리 위젯 상속 대상 Hooks Riverpod StatelessWidget - X X HookWidget StatelessWidget O X ConsumerWidget StatelessWidget X O HookConsumerWidget StatelessWidget O O HookConsumerWidget 하나면 로컬 상태도 공유 상태도 다 된다. 실무에서 가장 많이 쓰는 조합이다.\n생산성 라이브러리 패키지 역할 비고 flutter_hooks 핵심 Hooks (useState, useEffect 등) Remi Rousselet 제작 hooks_riverpod Hooks + Riverpod 조합 (HookConsumerWidget) Riverpod 공식 flutter_use 추가 Hooks 모음 (useDebounce, usePrevious 등) 커뮤니티 flutter_hooks + hooks_riverpod만 있으면 거의 모든 상황을 커버한다. flutter_use는 자주 쓰는 패턴을 미리 만들어둔 편의 패키지다.\nSwiftUI와 최종 비교 개념 Flutter Hooks SwiftUI 로컬 상태 useState @State 값 접근 .value 직접 접근 생명주기 진입 useEffect(() {}, []) .onAppear 값 변경 감지 useEffect(() {}, [value]) .onChange(of: value) 정리/해제 useEffect의 return .onDisappear 컨트롤러 관리 useTextEditingController() 등 (자동 dispose) @StateObject (자동 관리) 비싼 연산 캐싱 useMemoized 뷰가 struct라 자동 최적화 리빌드 없는 값 useRef 일반 let 변수 공유 상태 Riverpod (ref.watch) @EnvironmentObject, @Observable SwiftUI는 프로퍼티 래퍼(@State, @Binding, @StateObject)로 선언형 상태를 관리하고, Flutter Hooks는 use 함수로 같은 역할을 한다. 접근 방식은 다르지만 \u0026ldquo;선언적으로 상태를 관리하고, 변경 시 UI가 자동으로 반영된다\u0026quot;는 핵심은 동일하다.\n내가 느낀 점 SwiftUI에서 @State 쓰던 경험이 있어서 useState가 바로 이해됐다. \u0026ldquo;아, 이거 @State랑 같은 거잖아.\u0026rdquo; 그리고 useTextEditingController처럼 Controller를 자동으로 dispose해주는 게 진짜 편하다. StatefulWidget에서 initState + dispose 짝 맞추는 거 매번 귀찮았는데, 이게 한 줄로 끝나니까.\nRiverpod과 조합하면 역할 분리가 깔끔해진다:\n로컬 UI 상태 (텍스트 입력, 애니메이션, 토글) → Hooks 공유 비즈니스 상태 (Todo 목록, 유저 데이터, API) → Riverpod ConsumerStatefulWidget에서 하던 걸 HookConsumerWidget으로 바꾸면 코드가 절반으로 줄어든다. StatefulWidget의 보일러플레이트에서 해방된 느낌이다.\n","date":"2026-05-03T01:00:00+09:00","permalink":"/p/ch05-5-flutter-hooks/","title":"Ch05-5. Flutter Hooks + Riverpod - SwiftUI @State가 여기 있었네"},{"content":"Ch05 시리즈 마지막이다. BLoC에서 Riverpod으로 전환한다. 솔직히 BLoC의 Event 기반 구조가 MVI 느낌이라 꽤 마음에 들었는데, Riverpod 써보니까 생산성이 말이 안 된다. 뷰에서 상태별 분기가 .when 하나로 되고, BLoC에서 필요했던 Event 클래스, State 래퍼, Emitter 전달 같은 보일러플레이트가 싹 사라진다.\n이 글의 코드는 flutter_riverpod 2.6.1 기준이다. 최신 Riverpod 3.0과의 차이점은 글 하단에 정리했다.\nBLoC → Riverpod, 뭐가 바뀌나 영역 BLoC Riverpod 상태 클래스 Bloc\u0026lt;Event, State\u0026gt; 상속 StateNotifier\u0026lt;T\u0026gt; 상속 이벤트 sealed class TodoEvent 별도 정의 없음. 메서드 직접 호출 State 래퍼 @freezed TodoBlocState 직접 List\u0026lt;Todo\u0026gt; 등록 BlocProvider(create: ...) StateNotifierProvider(...) 전역 선언 UI 위젯 StatelessWidget + BlocBuilder ConsumerWidget 접근 context.read\u0026lt;TodoBloc\u0026gt;() ref.read(provider.notifier) 구독 BlocBuilder\u0026lt;TodoBloc, TodoBlocState\u0026gt; ref.watch(provider) 상태 발행 emit(state.copyWith(...)) state = newValue 요약하면 BLoC에서 Event 클래스 + State 래퍼 + Emitter 패턴이 전부 사라지고, 직접 메서드 호출 + state 재할당으로 단순해진다.\nStateNotifierProvider — 핵심 구조 Provider 선언 (전역) 1 2 3 final todoDataProvider = StateNotifierProvider\u0026lt;TodoDataHolder, List\u0026lt;Todo\u0026gt;\u0026gt;( (ref) =\u0026gt; TodoDataHolder()); 이게 끝이다. BLoC에서는 BlocProvider로 위젯 트리에 감싸야 했는데, Riverpod은 전역 변수처럼 선언한다. \u0026ldquo;provider가 전역으로 선언되었다고 데이터도 전역으로 같이 쓰는 게 아니다\u0026rdquo; — 이건 뒤에서 Scope로 설명한다.\nStateNotifier 구현 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 class TodoDataHolder extends StateNotifier\u0026lt;List\u0026lt;Todo\u0026gt;\u0026gt; { TodoDataHolder() : super(\u0026lt;Todo\u0026gt;[]); void addTodo() async { final result = await WriteTodoDialog().show(); if (result != null) { state.add( Todo( id: DateTime.now().microsecondsSinceEpoch, title: result.text, dueDate: result.dateTime, ), ); state = List.of(state); // 새 리스트로 재할당 → 리빌드 트리거 } } void changeTodoStatus(Todo todo) async { switch (todo.status) { case TodoStatus.inComplete: todo.status = TodoStatus.onGoing; case TodoStatus.onGoing: todo.status = TodoStatus.complete; case TodoStatus.complete: final result = await ConfirmDialog(\u0026#39;정말로 처음 상태로 변경하시겠어요?\u0026#39;).show(); result?.runIfSuccess((data) { todo.status = TodoStatus.inComplete; }); } state = List.of(state); } void editTodo(Todo todo) async { final result = await WriteTodoDialog(todoForEdit: todo).show(); if (result != null) { todo.title = result.text; todo.dueDate = result.dateTime; state = List.of(state); } } void removeTodo(Todo todo) { state.remove(todo); state = List.of(state); } } BLoC에서는 emit(state.copyWith(todoList: oldTodoList))로 새 State를 발행했는데, Riverpod에서는 state = List.of(state)로 직접 재할당한다. state에 새 값을 넣으면 구독 중인 위젯이 자동으로 리빌드된다.\nBLoC과 비교하면:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 // BLoC: Event를 정의하고, 핸들러를 등록하고, Emitter로 발행 sealed class TodoEvent {} class TodoAddEvent extends TodoEvent {} class TodoBloc extends Bloc\u0026lt;TodoEvent, TodoBlocState\u0026gt; { TodoBloc() : super(...) { on\u0026lt;TodoAddEvent\u0026gt;(_addTodo); // 핸들러 등록 } void _addTodo(TodoAddEvent event, Emitter\u0026lt;TodoBlocState\u0026gt; emit) { emit(state.copyWith(todoList: newList)); // Emitter로 발행 } } // Riverpod: 그냥 메서드 쓰면 됨 class TodoDataHolder extends StateNotifier\u0026lt;List\u0026lt;Todo\u0026gt;\u0026gt; { void addTodo() { state = List.of(state); // state 재할당이 곧 발행 } } Event 클래스 정의 → 핸들러 등록 → Emitter 전달 → emit 호출. 이 4단계가 state 재할당 한 줄로 줄었다.\nExtension으로 접근 축약 1 2 3 extension TodoListHolderProvider on WidgetRef { TodoDataHolder get readTodoHolder =\u0026gt; read(todoDataProvider.notifier); } ref.read(todoDataProvider.notifier).addTodo() 대신 ref.readTodoHolder.addTodo()로 쓸 수 있다. BLoC에서 context.readTodoBloc으로 축약했던 것과 같은 패턴이다.\nConsumerWidget — UI에서 사용 ProviderScope 설정 1 2 3 4 5 6 7 8 9 // s_main.dart class MainScreenWrapper extends StatelessWidget { const MainScreenWrapper({super.key}); @override Widget build(BuildContext context) { return const ProviderScope(child: MainScreen()); } } ProviderScope은 Riverpod의 DI 컨테이너다. 이 안에 있는 위젯들이 Provider에 접근할 수 있다. BLoC의 BlocProvider와 같은 역할이지만, 여러 Provider를 개별적으로 감쌀 필요 없이 하나의 ProviderScope만 있으면 된다.\nConsumerWidget으로 구독 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 // w_todo_list.dart class TodoList extends ConsumerWidget { const TodoList({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { final todoList = ref.watch(todoDataProvider); // 구독 return todoList.isEmpty ? const Expanded( child: Center( child: Text(\u0026#39;할 일을 작성해 보세요.\u0026#39;, style: TextStyle(fontSize: 32)), ), ) : Column( children: todoList.map((e) =\u0026gt; TodoItem(e)).toList(), ); } } BLoC에서는 BlocBuilder\u0026lt;TodoBloc, TodoBlocState\u0026gt; 위젯으로 감싸야 했는데, Riverpod은 ConsumerWidget을 상속하면 build 메서드에 WidgetRef ref가 들어온다. ref.watch(provider)로 구독하면 state가 바뀔 때마다 자동 리빌드된다.\nref.watch vs ref.read 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 // w_todo_item.dart class TodoItem extends ConsumerWidget { final Todo todo; const TodoItem(this.todo, {super.key}); @override Widget build(BuildContext context, WidgetRef ref) { return Dismissible( key: ValueKey(todo.id), onDismissed: (direction) { ref.readTodoHolder.removeTodo(todo); // ref.read: 한 번 읽기 }, child: RoundedContainer( child: Row( children: [ TodoStatusWidget(todo), Text(todo.title), const Spacer(), IconButton( onPressed: () { ref.readTodoHolder.editTodo(todo); // ref.read: 이벤트용 }, icon: const Icon(EvaIcons.editOutline), ) ], ), ), ); } } 메서드 용도 리빌드 ref.watch(provider) 상태 구독 (UI 그릴 때) O ref.read(provider.notifier) 메서드 호출 (이벤트용) X BLoC의 context.watch / context.read와 정확히 같은 개념이다. 차이라면 BLoC은 context에서 꺼내고, Riverpod은 ref에서 꺼낸다.\nConsumerStatefulWidget Rive 애니메이션처럼 lifecycle 메서드가 필요하면 ConsumerStatefulWidget을 쓴다:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 class TodoStatusWidget extends ConsumerStatefulWidget { final Todo todo; const TodoStatusWidget(this.todo, {super.key}); @override ConsumerState\u0026lt;TodoStatusWidget\u0026gt; createState() =\u0026gt; _TodoStatusWidgetState(); } class _TodoStatusWidgetState extends ConsumerState\u0026lt;TodoStatusWidget\u0026gt; { // initState, dispose 등 lifecycle 사용 가능 // ref도 사용 가능 @override Widget build(BuildContext context) { return Tap( onTap: () { ref.readTodoHolder.changeTodoStatus(widget.todo); }, child: SizedBox( width: 50, height: 50, child: switch (widget.todo.status) { TodoStatus.complete =\u0026gt; Checkbox(value: true, onChanged: null), TodoStatus.inComplete =\u0026gt; const Checkbox(value: false, onChanged: null), TodoStatus.onGoing =\u0026gt; _riveReady ? RiveWidget(controller: _controller!, fit: Fit.cover) : const SizedBox(), }, ), ); } } 위젯 타입 BLoC 대응 언제 쓰나 ConsumerWidget StatelessWidget + BlocBuilder 단순 UI ConsumerStatefulWidget StatefulWidget + BlocBuilder lifecycle 필요 ProviderScope — 스코프 개념 Provider가 전역으로 선언되었다고 데이터도 전역으로 같이 쓰는 게 아니다. ProviderScope으로 나눠주면 별도의 데이터를 가지게 된다.\n1 2 3 4 5 6 7 8 9 10 // 같은 Provider인데 Scope가 다르면 다른 데이터 ProviderScope( overrides: [todoDataProvider.overrideWith(() =\u0026gt; TodoDataHolder())], child: ScreenA(), // 이 안에서는 독립된 TodoDataHolder ) ProviderScope( overrides: [todoDataProvider.overrideWith(() =\u0026gt; TodoDataHolder())], child: ScreenB(), // 여기도 독립된 TodoDataHolder ) Ch04에서 다뤘던 Scoped Model의 진짜 실현이다. BLoC에서는 BlocProvider를 위젯 트리 특정 위치에 넣어서 스코프를 만들었는데, Riverpod은 ProviderScope의 overrides로 더 유연하게 스코프를 지정할 수 있다.\n유튜브 앱에서 영상마다 다른 댓글 리스트를 보여줘야 한다면:\n1 2 3 4 5 6 7 8 9 10 11 // BLoC: 각 화면에 BlocProvider를 별도로 감싸야 함 BlocProvider( create: (_) =\u0026gt; CommentBloc(videoId: \u0026#39;abc123\u0026#39;), child: CommentSection(), ) // Riverpod: ProviderScope로 override ProviderScope( overrides: [commentProvider.overrideWith(() =\u0026gt; CommentNotifier(videoId: \u0026#39;abc123\u0026#39;))], child: CommentSection(), ) 사실 결과적으로 비슷하지만, Riverpod은 Provider 선언이 전역이니까 어떤 Provider들이 있는지 한눈에 파악이 된다. BLoC은 각 화면의 BlocProvider를 찾아다녀야 한다.\nFutureProvider — 비동기 상태의 끝판왕 Todo 앱에서는 안 썼지만, 실제 앱을 만들면 API 호출이 필수다. 여기서 Riverpod의 진가가 나온다.\n기본 사용법 1 2 3 4 final userProvider = FutureProvider\u0026lt;User\u0026gt;((ref) async { final response = await http.get(Uri.parse(\u0026#39;https://api.com/user\u0026#39;)); return User.fromJson(jsonDecode(response.body)); }); 이걸 뷰에서 쓰면:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 class UserPage extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final userAsync = ref.watch(userProvider); return Scaffold( body: Center( child: userAsync.when( loading: () =\u0026gt; CircularProgressIndicator(), error: (e, _) =\u0026gt; Text(\u0026#39;에러: $e\u0026#39;), data: (user) =\u0026gt; Text(user.name), ), ), ); } } .when 하나로 로딩/에러/데이터 분기가 끝난다. 세 가지 상태를 빠뜨릴 수가 없어서 안전하기도 하다. Swift에서 비슷하게 하려면:\n1 2 3 4 5 6 7 8 9 10 // Swift - 직접 분기 처리 var body: some View { if isLoading { ProgressView() } else if let error { Text(\u0026#34;에러: \\(error)\u0026#34;) } else if let user { Text(user.name) } } 이걸 .when 하나로 끝내는 거다.\n화면 일부만 분기하기 .when이 화면 전체를 교체하는 것만은 아니다. 부분에도 쓸 수 있다:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 Scaffold( appBar: AppBar(title: Text(\u0026#39;마이페이지\u0026#39;)), // 고정 body: Column( children: [ Text(\u0026#39;항상 보이는 영역\u0026#39;), // 고정 // 이 부분만 로딩/에러/데이터 처리 ref.watch(userProvider).when( loading: () =\u0026gt; CircularProgressIndicator(), error: (e, _) =\u0026gt; Text(\u0026#39;로드 실패\u0026#39;), data: (user) =\u0026gt; UserCard(user: user), ), Text(\u0026#39;여기도 항상 보임\u0026#39;), // 고정 ], ), ) 프로필 페이지, 상품 목록, 검색 결과 같은 데서 엄청 자주 쓰는 패턴이다. loading이나 error 분기에도 커스텀 위젯을 자유롭게 넣을 수 있다:\n1 2 3 4 5 6 7 8 ref.watch(userProvider).when( loading: () =\u0026gt; ShimmerSkeleton(), // 스켈레톤 UI error: (e, _) =\u0026gt; ErrorRetryWidget( message: \u0026#39;불러오기 실패\u0026#39;, onRetry: () =\u0026gt; ref.invalidate(userProvider), // 재시도 ), data: (user) =\u0026gt; UserProfileCard(user: user), ) ref.listen — 오버레이 로딩 .when은 화면의 특정 영역을 교체하는 방식이다. 기존 화면 위에 오버레이로 로딩을 띄우고 싶다면 ref.listen을 쓴다:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 class LoginPage extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { // 상태 변화를 \u0026#39;듣고\u0026#39; 오버레이 처리 ref.listen(loginProvider, (prev, next) { if (next.isLoading) { showDialog( context: context, builder: (_) =\u0026gt; Center(child: CircularProgressIndicator()), ); } else { Navigator.of(context).pop(); // 로딩 닫기 next.whenOrNull( error: (e, _) =\u0026gt; ScaffoldMessenger.of(context) .showSnackBar(SnackBar(content: Text(\u0026#39;실패: $e\u0026#39;))), data: (user) =\u0026gt; Navigator.pushReplacement(...), ); } }); // 메인 UI는 그대로 return Scaffold( body: Column( children: [ TextField(...), ElevatedButton( onPressed: () =\u0026gt; ref.read(loginProvider.notifier).login(), child: Text(\u0026#39;로그인\u0026#39;), ), ], ), ); } } 상황 방식 화면 영역이 로딩/에러/데이터로 교체 ref.watch + .when 기존 화면 위에 오버레이 로딩 ref.listen + showDialog 데이터만 필요하고 로딩 무시 .valueOrNull when은 화면 교체용, listen은 이벤트 반응용이다.\n@riverpod — 코드 제너레이션 지금까지 StateNotifierProvider\u0026lt;TodoDataHolder, List\u0026lt;Todo\u0026gt;\u0026gt;((ref) =\u0026gt; ...) 같은 긴 타입을 직접 썼는데, @riverpod을 쓰면 이 보일러플레이트도 자동 생성된다:\n1 2 3 4 5 6 7 8 9 // @riverpod 방식: 함수 쓰듯이 로직만 짜면 끝 @riverpod Future\u0026lt;User\u0026gt; user(ref) async { final response = await http.get(Uri.parse(\u0026#39;https://api.com/user\u0026#39;)); return User.fromJson(jsonDecode(response.body)); } // build_runner 돌리면 userProvider가 자동 생성됨 // 뷰에서 ref.watch(userProvider).when(...) 으로 사용 알아야 하는 건 3개뿐이다:\n@riverpod 붙이고 로직 짜기 build_runner 돌려서 코드 생성 뷰에서 ref.watch / ref.read로 사용 StateNotifierProvider\u0026lt;TodoNotifier, List\u0026lt;Todo\u0026gt;\u0026gt;((ref) =\u0026gt; ...) 이런 보일러플레이트를 직접 안 짜도 된다. 요즘 새 프로젝트는 대부분 @riverpod 방식을 쓴다.\nBLoC vs Riverpod 최종 비교 코드 비교 항목 BLoC Riverpod 방식 Event → Bloc → State 직접 메서드 호출 이벤트 정의 sealed class TodoEvent + 하위 클래스 4개 없음 State 래퍼 @freezed TodoBlocState (BlocStatus 포함) 직접 List\u0026lt;Todo\u0026gt; 등록 BlocProvider (위젯 트리) StateNotifierProvider (전역 선언) UI 위젯 BlocBuilder 래퍼 ConsumerWidget 상속 상태 발행 emit(state.copyWith(...)) state = newValue 비동기 분기 직접 처리 .when(loading, error, data) Event 추적 BlocObserver로 가능 직접 로깅 필요 보일러플레이트 많음 적음 스코프 BlocProvider 위치 ProviderScope overrides 같은 \u0026ldquo;Todo 삭제\u0026rdquo; 동작 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 // BLoC: Event 정의 → 핸들러 등록 → Event dispatch // 1. Event 정의 class TodoRemoveEvent extends TodoEvent { final Todo removedTodo; TodoRemoveEvent(this.removedTodo); } // 2. 핸들러 등록 on\u0026lt;TodoRemoveEvent\u0026gt;(_removeTodo); // 3. 핸들러 구현 void _removeTodo(TodoRemoveEvent event, Emitter\u0026lt;TodoBlocState\u0026gt; emit) { final oldTodoList = List\u0026lt;Todo\u0026gt;.from(state.todoList); oldTodoList.removeWhere((e) =\u0026gt; e.id == event.removedTodo.id); emit(state.copyWith(todoList: oldTodoList)); } // 4. UI에서 dispatch context.readTodoBloc.add(TodoRemoveEvent(todo)); // Riverpod: 메서드 하나 // 1. 메서드 구현 void removeTodo(Todo todo) { state.remove(todo); state = List.of(state); } // 2. UI에서 호출 ref.readTodoHolder.removeTodo(todo); BLoC은 4단계, Riverpod은 2단계. 추적성 vs 생산성의 트레이드오프다.\n언제 뭘 쓸까 상황 추천 대규모 팀 (10명+) BLoC — Event 히스토리 추적, 팀 컨벤션 강제 소규모 팀 / 개인 Riverpod — 생산성, 적은 보일러플레이트 API 호출 많은 앱 Riverpod — .when 패턴이 압도적 Event 로깅 필수 BLoC — BlocObserver 프로토타입 Riverpod (또는 GetX) 2026년 기준으로 새 프로젝트는 대부분 Riverpod이 기본 선택이고, BLoC은 대규모 엔터프라이즈에서 쓰이는 추세다.\n4가지 방식 총 비교 Ch05 전체를 통해 같은 Todo 앱을 4가지 방식으로 만들어봤다:\nValueNotifier GetX BLoC Riverpod 방식 Scoped (내장) Static Scoped Scoped 상태 변경 notifyListeners() .obs 자동 emit() state = 재할당 UI 구독 ValueListenableBuilder Obx BlocBuilder ref.watch 비동기 분기 직접 처리 직접 처리 직접 처리 .when 자동 보일러플레이트 중간 적음 많음 적음 추적성 낮음 낮음 높음 중간 메모리 관리 자동 수동 자동 자동 내가 느낀 점 BLoC의 Event 기반 구조가 사실상 Scope 역할을 하는 거라고 느꼈다. 어떤 이벤트가 어떤 상태를 바꾸는지 명확히 분리되니까. 근데 Riverpod은 Provider 단위로 관심사를 나누면서도 코드량이 훨씬 적다.\n1 2 3 4 // 관심사 분리: Provider 단위로 나누면 됨 final todoDataProvider = StateNotifierProvider\u0026lt;TodoDataHolder, List\u0026lt;Todo\u0026gt;\u0026gt;(...); final userProvider = FutureProvider\u0026lt;User\u0026gt;(...); final settingsProvider = StateNotifierProvider\u0026lt;SettingsNotifier, Settings\u0026gt;(...); 젤 좋은 건 뷰에서 상태별 화면 분기가 .when 하나로 되는 점이다. 로딩/에러/데이터를 빠뜨릴 수 없게 강제하니까 안전하고, 각 분기에 아무 위젯이나 넣을 수 있으니까 커스텀도 자유롭다.\n앱이 커져서 상태가 많아지면 Provider를 나누면 되고, Scope가 필요하면 ProviderScope의 overrides로 해결된다. 개인 프로젝트에서 쓴다면 Riverpod이 정답인 것 같다.\nRiverpod 3.0 — 2025년 9월 기준 변경점 이 글에서 쓴 코드는 Riverpod 2.x 기반이다. 2025년 9월에 Riverpod 3.0이 나왔는데, 핵심 개념(ref.watch, ref.read, .when, ProviderScope)은 그대로고 보일러플레이트가 더 줄었다.\nRiverpod 2.x (이 글) Riverpod 3.0 Provider 선언 StateNotifierProvider\u0026lt;T, S\u0026gt;((ref) =\u0026gt; ...) 직접 작성 @riverpod 붙이면 자동 생성 상태 클래스 StateNotifier\u0026lt;T\u0026gt; Notifier\u0026lt;T\u0026gt; (StateNotifier deprecated) 비동기 FutureProvider 별도 선언 @riverpod Future\u0026lt;T\u0026gt; 함수로 통일 Mutation 없음 @mutation으로 폼 제출/버튼 액션의 로딩/에러 자동 관리 (experimental) AsyncValue 일반 클래스 sealed class — 패턴 매칭 시 빠뜨리면 컴파일 에러 3.0에서 가장 큰 변화는 StateNotifier가 Notifier로 대체된 것과, @riverpod 코드 제너레이션이 기본이 된 것이다:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 // 2.x: 직접 선언 final todoDataProvider = StateNotifierProvider\u0026lt;TodoDataHolder, List\u0026lt;Todo\u0026gt;\u0026gt;( (ref) =\u0026gt; TodoDataHolder()); class TodoDataHolder extends StateNotifier\u0026lt;List\u0026lt;Todo\u0026gt;\u0026gt; { TodoDataHolder() : super([]); // ... } // 3.0: @riverpod + Notifier @riverpod class TodoDataHolder extends _$TodoDataHolder { @override List\u0026lt;Todo\u0026gt; build() =\u0026gt; []; // 초기 상태 void addTodo(Todo todo) { state = [...state, todo]; } } // build_runner가 todoDataHolderProvider 자동 생성 Mutation API는 아직 experimental이라 API가 바뀔 수 있지만, 폼 제출 같은 액션의 로딩/성공/에러 상태를 자동으로 관리해주는 기능이다. 정식 출시되면 ref.listen으로 수동 처리하던 부분이 더 간결해질 예정이다.\n","date":"2026-05-02T00:00:00+09:00","permalink":"/p/ch05-4-riverpod/","title":"Ch05-4. Todo 앱 Riverpod 전환 - 이게 진짜 사기다"},{"content":"Ch05 시리즈 세 번째다. GetX에서 BLoC으로 전환한다. 핵심 변화는 2가지다: (1) 상태 변경을 Event로 제한하고, (2) Todo 모델을 freezed로 immutable하게 바꾸는 것. 코드량은 늘어나지만 \u0026ldquo;상태가 어디서 왜 바뀌었는지\u0026rdquo; 추적이 확실해진다.\nBLoC이란 Business Logic Component. UI에서 Event를 보내면, Bloc이 처리해서 새로운 State를 내보내는 단방향 흐름이다.\n1 2 3 [UI] --Event--\u0026gt; [Bloc] --State--\u0026gt; [UI] | 비즈니스 로직 GetX에서는 todoData.addTodo(todo) 처럼 직접 메서드를 호출했다. BLoC에서는 bloc.add(TodoAddEvent()) 처럼 이벤트를 보낸다. 뷰에서는 호출만 하고, Bloc 내부에서 이벤트를 받아 해당하는 로직을 수행하고, 상태를 변경하면, BlocBuilder가 돌아간다.\n차이가 뭐냐: 모든 상태 변경이 Event로 기록된다. 어떤 이벤트가 언제 발생했는지 로그로 추적할 수 있고, 디버깅이 쉬워진다. GetX는 \u0026ldquo;야 이거 바꿔\u0026quot;라고 직접 수정하는 거고, BLoC은 \u0026ldquo;이거 바꿔달라\u0026quot;는 요청서를 제출하는 거다.\nSwift에서 TCA(The Composable Architecture)를 써봤다면 Action → Reducer → State 흐름과 거의 같은 패턴이다.\nfreezed로 불변 모델 만들기 mutable의 문제 (Ch05-2 복습) GetX에서 Todo의 상태를 바꿀 때 이런 식이었다:\n1 2 3 // GetX 시절: mutable 객체를 직접 수정 todo.status = TodoStatus.complete; todoList.refresh(); // 수동 refresh 필요 문제점:\n어디서 todo.status를 바꿨는지 추적 불가 refresh() 까먹으면 UI 안 바뀜 같은 Todo 인스턴스를 여러 곳에서 참조하면 의도치 않은 사이드 이펙트 freezed 적용 1 2 3 4 5 6 7 8 9 10 11 @freezed class Todo with _$Todo { const factory Todo({ required int id, required String title, required DateTime createdTime, DateTime? modifyTime, required DateTime dueDate, required TodoStatus status, }) = _Todo; } const factory로 선언하면 모든 필드가 final이 된다. 직접 수정이 불가능하다. 대신 copyWith로 새 인스턴스를 만들어야 한다.\n1 2 3 4 5 // Before (mutable class) todo.status = TodoStatus.complete; // 직접 수정 // After (freezed) final newTodo = todo.copyWith(status: TodoStatus.complete); // 새 인스턴스 생성 Swift의 struct(value type)와 거의 같다. Swift에서는 struct가 기본 제공하는 ==, hashCode, copyWith(없지만 멤버와이즈 이니셜라이저가 비슷한 역할)를 Dart에서는 freezed가 코드 생성으로 만들어준다.\n처음에는 좀 번거로웠다. 기존처럼 switch문으로 상태를 변경하고 할당시켜도 안 됐다. 내부에서 상태를 수정할 수 없으니까 copyWith로 별도 수정해줘야 한다. 근데 이 불편함이 안전함의 대가다.\nBlocState도 freezed로 1 2 3 4 5 6 7 8 9 enum BlocStatus { initial, loading, success, error } @freezed class TodoBlocState with _$TodoBlocState { const factory TodoBlocState({ required BlocStatus status, required List\u0026lt;Todo\u0026gt; todoList, }) = _TodoBlocState; } State에 BlocStatus를 포함시켜서 로딩/에러 상태도 관리할 수 있다. todoList와 status를 하나의 immutable State 객체로 묶었다.\nbuild_runner로 코드 생성 1 flutter pub run build_runner build 이 명령어로 vo_todo.freezed.dart, todo_bloc_state.freezed.dart가 생성된다. copyWith, ==, hashCode, toString이 전부 자동으로 만들어진다.\nEvent 설계 — sealed class 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 sealed class TodoEvent {} class TodoAddEvent extends TodoEvent {} class TodoStatusUpdateEvent extends TodoEvent { final Todo updatedTodo; TodoStatusUpdateEvent(this.updatedTodo); } class TodoContentUpdateEvent extends TodoEvent { final Todo updatedTodo; TodoContentUpdateEvent(this.updatedTodo); } class TodoRemoveEvent extends TodoEvent { final Todo removedTodo; TodoRemoveEvent(this.removedTodo); } 처음에는 abstract class로 했다가 sealed class로 바꿨다. sealed로 바꾸면 switch문에서 모든 이벤트를 빠짐없이 처리했는지 컴파일 타임에 체크해준다. 이벤트를 하나 추가했는데 핸들러를 안 만들면 컴파일 에러가 나니까 실수를 방지할 수 있다.\nSwift의 enum + associated values와 거의 같은 역할이다:\n1 2 3 4 5 6 7 // Swift equivalent enum TodoAction { case add case updateStatus(Todo) case updateContent(Todo) case remove(Todo) } 이벤트를 sealed class로 만드는 이유는 각 이벤트가 서로 다른 데이터를 가져야 할 때 유연하기 때문이다. TodoAddEvent는 데이터가 필요 없고(다이얼로그에서 받으니까), TodoRemoveEvent는 어떤 Todo를 삭제할지 알아야 하고, 이런 차이를 타입으로 표현할 수 있다.\nTodoBloc — 핵심 로직 Bloc 클래스 구조 1 2 3 4 5 6 7 8 9 10 11 class TodoBloc extends Bloc\u0026lt;TodoEvent, TodoBlocState\u0026gt; { TodoBloc() : super(const TodoBlocState( status: BlocStatus.initial, todoList: \u0026lt;Todo\u0026gt;[], )) { on\u0026lt;TodoAddEvent\u0026gt;(_addTodo); on\u0026lt;TodoStatusUpdateEvent\u0026gt;(_changeTodoStatus); on\u0026lt;TodoContentUpdateEvent\u0026gt;(_editTodo); on\u0026lt;TodoRemoveEvent\u0026gt;(_removeTodo); } } super(initialState)로 초기 상태를 설정하고, on\u0026lt;EventType\u0026gt;(handler)로 각 이벤트별 핸들러를 등록한다. 이벤트가 들어오면 해당 핸들러가 실행되는 구조다.\n추가 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 void _addTodo(TodoAddEvent event, Emitter\u0026lt;TodoBlocState\u0026gt; emit) async { final result = await WriteTodoDialog().show(); if (result != null) { final oldTodoList = List.of(state.todoList); oldTodoList.add( Todo( id: DateTime.now().microsecondsSinceEpoch, title: result.text, dueDate: result.dateTime, createdTime: DateTime.now(), status: TodoStatus.inComplete, ), ); emitNewList(oldTodoList, emit); } } 핵심 패턴: List.of(state.todoList)로 기존 리스트를 복사 → 수정 → emit으로 새 State를 발행한다. BLoC에서는 add를 사용할 수 없기 때문에(state.todoList가 immutable) 새 List로 감싸서 조작해야 한다.\n1 2 3 void emitNewList(List\u0026lt;Todo\u0026gt; oldTodoList, Emitter\u0026lt;TodoBlocState\u0026gt; emit) { emit(state.copyWith(todoList: oldTodoList)); } emit을 호출하면 BLoC이 새 State를 내보내고, 구독 중인 BlocBuilder가 리빌드된다.\n상태 전환 (가장 복잡한 부분) 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 void _changeTodoStatus(TodoStatusUpdateEvent event, Emitter\u0026lt;TodoBlocState\u0026gt; emit) async { final oldTodoList = List.of(state.todoList); final todo = event.updatedTodo; final todoIndex = oldTodoList.indexWhere((e) =\u0026gt; e.id == todo.id); TodoStatus status = todo.status; switch (todo.status) { case TodoStatus.inComplete: status = TodoStatus.onGoing; case TodoStatus.onGoing: status = TodoStatus.complete; case TodoStatus.complete: final result = await ConfirmDialog(\u0026#39;정말로 처음 상태로 변경하시겠어요?\u0026#39;).show(); result?.runIfSuccess((data) { status = TodoStatus.inComplete; }); } oldTodoList[todoIndex] = todo.copyWith(status: status); emitNewList(oldTodoList, emit); } GetX에서는 todo.status = status로 직접 수정했는데, BLoC에서는 todo.copyWith(status: status)로 새 인스턴스를 만들고, indexWhere로 찾아서 교체한다. immutable이라 기존 객체를 수정할 수 없으니까 새 객체로 대체하는 거다.\nGetX에서 todoList.refresh()를 수동으로 호출해야 했던 문제가 여기서 사라진다. 새 리스트를 emit하면 BLoC이 자동으로 상태 변경을 감지한다.\n삭제 1 2 3 4 5 6 void _removeTodo(TodoRemoveEvent event, Emitter\u0026lt;TodoBlocState\u0026gt; emit) { final oldTodoList = List\u0026lt;Todo\u0026gt;.from(state.todoList); final todo = event.removedTodo; oldTodoList.removeWhere((e) =\u0026gt; e.id == todo.id); emitNewList(oldTodoList, emit); } 수정 1 2 3 4 5 6 7 8 9 10 11 12 13 void _editTodo(TodoContentUpdateEvent event, Emitter\u0026lt;TodoBlocState\u0026gt; emit) async { final todo = event.updatedTodo; final result = await WriteTodoDialog(todoForEdit: todo).show(); if (result != null) { final oldTodoList = List\u0026lt;Todo\u0026gt;.from(state.todoList); oldTodoList[oldTodoList.indexOf(todo)] = todo.copyWith( title: result.text, dueDate: result.dateTime, modifyTime: DateTime.now(), ); emitNewList(oldTodoList, emit); } } UI에서 BLoC 사용 BlocProvider로 등록 1 2 3 4 5 6 7 // app.dart BlocProvider( create: (BuildContext context) =\u0026gt; TodoBloc(), child: MaterialApp( home: const MainScreen(), ), ) InheritedWidget을 직접 만들 필요 없이 BlocProvider가 대신해준다. 내부적으로 InheritedWidget 기반이지만 보일러플레이트가 없다. GetX의 Get.put()과 비교하면, BlocProvider는 위젯이 dispose될 때 자동으로 Bloc도 close해준다. 메모리 관리를 신경 쓸 필요가 없다.\nBlocBuilder로 구독 1 2 3 4 5 6 7 8 9 10 // w_todo_list.dart BlocBuilder\u0026lt;TodoBloc, TodoBlocState\u0026gt;( builder: (context, state) { return state.todoList.isEmpty ? const Center(child: Text(\u0026#39;할 일을 작성해 보세요.\u0026#39;)) : Column( children: state.todoList.map((e) =\u0026gt; TodoItem(e)).toList(), ); }, ) state로 현재 상태 전체를 받는다. GetX의 Obx처럼 .obs 변수를 직접 접근하는 게 아니라, Bloc이 emit한 State 객체를 통해 데이터에 접근한다.\ncontext.read vs context.watch 1 2 3 4 5 // context_extension.dart extension ContextExtension on BuildContext { TodoBloc get readTodoBloc =\u0026gt; read(); // 이벤트 보낼 때 (리빌드 안 함) TodoBloc get watchTodoBloc =\u0026gt; watch(); // 상태 구독할 때 (리빌드 함) } read: 한 번 읽고 끝. 이벤트를 보낼 때 사용한다. 리빌드를 트리거하지 않는다. watch: 상태 변경을 구독한다. State가 바뀌면 해당 위젯이 리빌드된다. 1 2 3 4 5 6 // 이벤트 보내기: read (리빌드 불필요) context.readTodoBloc.add(TodoAddEvent()); context.readTodoBloc.add(TodoRemoveEvent(todo)); context.readTodoBloc.add(TodoStatusUpdateEvent(todo)); // 상태 구독: BlocBuilder 안에서 자동으로 처리 주로 이벤트를 보낼 때는 read, UI를 그릴 때는 BlocBuilder 안에서 state를 받아 쓴다.\n실제 위젯에서 사용 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 // w_todo_item.dart - 스와이프 삭제 Dismissible( key: ValueKey(todo.id), onDismissed: (direction) { context.readTodoBloc.add(TodoRemoveEvent(todo)); }, child: RoundedContainer( child: Row( children: [ TodoStatusWidget(todo), Text(todo.title), const Spacer(), IconButton( onPressed: () { context.readTodoBloc.add(TodoContentUpdateEvent(todo)); }, icon: const Icon(EvaIcons.editOutline), ) ], ), ), ) 1 2 3 4 5 6 7 // s_main.dart - FAB으로 추가 FloatingActionButton( onPressed: () { context.readTodoBloc.add(TodoAddEvent()); }, child: const Icon(EvaIcons.plus), ) UI에서는 add(Event)로 이벤트를 보내기만 한다. 로직은 전부 Bloc 내부에 있다. 이게 관심사 분리다.\nRive 애니메이션 (보너스) 상태관리와 직접 관련은 없지만, Todo 상태가 onGoing일 때 Rive 애니메이션이 재생되는 게 꽤 재밌었다.\n1 2 3 4 5 6 7 child: switch (widget.todo.status) { TodoStatus.complete =\u0026gt; Checkbox(value: true, onChanged: null), TodoStatus.inComplete =\u0026gt; const Checkbox(value: false, onChanged: null), TodoStatus.onGoing =\u0026gt; _riveReady ? RiveWidget(controller: _controller!, fit: Fit.cover) : const SizedBox(), } Dart 3의 switch expression으로 상태별 위젯을 깔끔하게 분기한다. _cachedFile을 static으로 캐싱해서 .riv 파일을 한 번만 로드하는 것도 포인트다.\n3가지 방식 최종 비교 코드 비교 항목 ValueNotifier GetX BLoC 방식 Scoped (내장) Static Scoped (패키지) 상태 객체 mutable mutable immutable (freezed) 변경 알림 notifyListeners() 수동 .obs 자동 emit() UI 구독 ValueListenableBuilder Obx(() =\u0026gt;) BlocBuilder 등록 InheritedWidget 직접 구현 Get.put() BlocProvider 접근 of(context) Get.find() context.read/watch 상태 변경 메서드 직접 호출 메서드 직접 호출 Event dispatch 추적성 낮음 낮음 높음 (Event 로깅) 보일러플레이트 중간 적음 많음 메모리 관리 위젯 트리 자동 개발자 직접 위젯 트리 자동 같은 \u0026ldquo;Todo 추가\u0026rdquo; 동작 1 2 3 4 5 6 7 8 // ValueNotifier context.todoHolder.addTodo(todo); // GetX Get.find\u0026lt;TodoDataHolder\u0026gt;().addTodo(todo); // BLoC context.readTodoBloc.add(TodoAddEvent()); Swift/iOS 대응표 Flutter Swift/iOS ValueNotifier ObservableObject + @Published InheritedWidget @EnvironmentObject GetX (.obs + Obx) Combine + @Published (싱글톤) BLoC (Event → State) TCA (Action → Reducer → State) freezed struct (value type) sealed class enum + associated value BlocProvider DI Container (Scoped) 어떤 걸 써야 하나 프로토타입/작은 앱 → GetX가 빠르다 중간 규모 → Provider나 Riverpod이 적당하다 대규모/팀 프로젝트 → BLoC이 Event 추적, 테스트 용이성에서 유리하다 Flutter 원리 학습 → ValueNotifier + InheritedWidget부터 이해하자 개인 프로젝트에서 정답은 없다. 트레이드오프를 이해하고 고르면 된다.\n정리 Ch05 시리즈를 통해 같은 Todo 앱을 3가지 방식으로 만들어봤다. 어떤 방식이든 하는 일은 같다. 상태를 만들고, 변경하고, UI에 반영하는 것. 차이는 \u0026ldquo;변경을 얼마나 통제할 것인가\u0026rdquo;, \u0026ldquo;추적성을 얼마나 확보할 것인가\u0026quot;에 있다.\niOS 개발할 때도 RxSwift → Combine → TCA 순서로 더 구조화된 방향으로 흘러왔는데, Flutter에서도 비슷한 흐름을 체감했다. Ch04에서 이론을 정리하고 Ch05에서 실전을 해보니까 왜 상태관리가 중요한지 확실히 느꼈다.\n","date":"2026-05-01T00:00:00+09:00","permalink":"/p/ch05-3-bloc-freezed/","title":"Ch05-3. Todo 앱 BLoC + freezed 전환 - 이벤트 기반 상태관리"},{"content":"Ch05-1에서 ValueNotifier + InheritedWidget으로 만든 Todo 앱을 GetX로 전환한다. Ch04에서 다뤘던 Static Model의 실전 적용 버전이다. 결론부터 말하면, 일일이 업데이트 걸어줄 필요 없이 심플하게 상태관리가 가능해진다. 근데 그 대가가 있다.\n뭐가 바뀌나 바꾸는 곳은 정해져 있다. 상태 클래스, 등록, 구독, 접근. 이 4가지만 바꾸면 된다.\n영역 Before (ValueNotifier) After (GetX) 상태 클래스 ValueNotifier 상속 GetxController 상속 상태 변수 List\u0026lt;Todo\u0026gt; + notifyListeners() RxList\u0026lt;Todo\u0026gt; (.obs) UI 구독 ValueListenableBuilder Obx(() =\u0026gt;) 등록 InheritedWidget 직접 구현 Get.put() 접근 context.dependOn... Get.find() .obs — 자동 변경 감지 변환 핵심 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 // Before: ValueNotifier class TodoDataNotifier extends ValueNotifier\u0026lt;List\u0026lt;Todo\u0026gt;\u0026gt; { TodoDataNotifier() : super([]); void addTodo(Todo todo) { value.add(todo); notifyListeners(); // 수동 호출 필수! } } // After: GetX class TodoDataHolder extends GetxController { final RxList\u0026lt;Todo\u0026gt; todoList = \u0026lt;Todo\u0026gt;[].obs; void addTodo(Todo todo) { todoList.add(todo); // notifyListeners 불필요! .obs가 변경을 자동 감지 } } 핵심 차이는 .obs다. .obs를 붙이면 Rx\u0026lt;T\u0026gt; 타입으로 래핑되면서, add, remove, []= 같은 List 조작을 전부 intercept한다. 변경이 생기면 자동으로 구독자에게 알림을 보내기 때문에 ValueNotifier에서 notifyListeners() 까먹어서 UI가 안 바뀌는 버그가 사라진다.\n다양한 타입에 .obs 1 2 3 4 final count = 0.obs; // RxInt final name = \u0026#39;\u0026#39;.obs; // RxString final items = \u0026lt;Todo\u0026gt;[].obs; // RxList\u0026lt;Todo\u0026gt; final user = User().obs; // Rx\u0026lt;User\u0026gt; 기본 타입은 .value로 접근하고, List는 직접 메서드를 호출할 수 있다. Swift에서 @Published가 프로퍼티 단위로 감시하는 것과 비슷한데, .obs는 변수 단위로 감시한다.\n같은 값이면 리빌드 안 함 1 2 3 final count = 0.obs; count.value = 0; // 같은 값 → Obx 리빌드 안 함 count.value = 1; // 다른 값 → Obx 리빌드 ValueNotifier의 notifyListeners()는 값이 같아도 무조건 알림을 보내는데, GetX는 내부적으로 이전 값과 비교해서 불필요한 리빌드를 막아준다.\nObx — 반응형 UI 빌더 1 2 3 4 5 6 7 8 9 10 11 12 13 14 // Before: ValueListenableBuilder ValueListenableBuilder\u0026lt;List\u0026lt;Todo\u0026gt;\u0026gt;( valueListenable: todoNotifier, builder: (context, todoList, child) { return Column( children: todoList.map((e) =\u0026gt; TodoItem(e)).toList(), ); }, ) // After: Obx Obx(() =\u0026gt; Column( children: todoDataHolder.todoList.map((e) =\u0026gt; TodoItem(e)).toList(), )) Obx가 내부에서 어떤 .obs 변수를 읽었는지 자동으로 추적한다. 해당 변수가 바뀌면 Obx 블록만 리빌드된다. SwiftUI에서 @Published 프로퍼티가 바뀌면 body가 리빌드되는 것과 같은 원리인데, Obx 단위로 범위를 좁힐 수 있어서 더 세밀한 제어가 가능하다.\n코드량으로만 보면 ValueListenableBuilder 대비 거의 반으로 줄었다. builder 패턴의 보일러플레이트가 사라진 게 크다.\nInheritedWidget 제거 — Get.put / Get.find 등록 1 2 3 4 5 6 7 8 9 10 11 12 13 14 // Before: InheritedWidget으로 위젯 트리 감싸기 TodoDataHolder( notifier: TodoDataNotifier(), child: MaterialApp( home: const MainScreen(), ), ) // After: Get.put 한 줄 @override void initState() { super.initState(); Get.put(TodoDataHolder()); } InheritedWidget 클래스를 만들고, 위젯 트리에 감싸고, of(context) 패턴을 구현하는 보일러플레이트가 전부 사라진다. Get.put() 한 줄이면 끝이다.\n접근 1 2 3 4 5 // Before: context 필요 context.todoHolder.addTodo(todo); // After: context 불필요 Get.find\u0026lt;TodoDataHolder\u0026gt;().addTodo(todo); Get.find()로 어디서든 접근할 수 있다. context가 필요 없으니까 StatelessWidget이든, 일반 클래스든, 어디서든 상태에 접근 가능하다.\nmixin으로 더 축약할 수도 있다:\n1 2 3 4 5 6 7 8 9 10 11 mixin TodoDataProvider { TodoDataHolder get todoData =\u0026gt; Get.find\u0026lt;TodoDataHolder\u0026gt;(); } // 사용 class SomeWidget extends StatelessWidget with TodoDataProvider { @override Widget build(BuildContext context) { todoData.addTodo(todo); // 깔끔 } } 상태 변경 로직 — mutable의 함정 추가, 삭제 1 2 3 4 5 6 7 8 9 void addTodo(Todo todo) { todoList.add(todo); // 끝. RxList가 자동으로 감지한다. } void removeTodo(Todo todo) { todoList.remove(todo); // 역시 자동. } List의 add, remove는 RxList가 감지해서 자동으로 UI가 갱신된다.\n상태 전환 — refresh()가 필요한 순간 1 2 3 4 5 6 7 8 9 10 11 12 13 14 void changeTodoStatus(Todo todo) async { switch (todo.status) { case TodoStatus.inComplete: todo.status = TodoStatus.onGoing; case TodoStatus.onGoing: todo.status = TodoStatus.complete; case TodoStatus.complete: final result = await ConfirmDialog(\u0026#39;정말로 처음 상태로 변경하시겠어요?\u0026#39;).show(); result?.runIfSuccess((data) { todo.status = TodoStatus.inComplete; }); } todoList.refresh(); // 이거 없으면 UI 안 바뀜! } 여기서 함정이 하나 있다. todo.status를 직접 바꾸면 Todo 객체의 프로퍼티만 변경되고, List 자체는 변경이 없다. RxList 입장에서는 \u0026ldquo;List에 아무 변화 없는데?\u0026ldquo;라고 판단하기 때문에 refresh()를 수동으로 호출해야 한다.\nmutable 객체를 직접 수정할 때 생기는 문제다. ValueNotifier에서 notifyListeners()를 수동으로 호출해야 했던 것과 본질적으로 같은 문제가 형태만 바뀌어서 다시 등장한 거다.\n수정도 마찬가지 1 2 3 4 5 6 void editTodo(Todo todo, WriteTodoResult result) { todo.title = result.text; todo.dueDate = result.dateTime; todo.modifyTime = DateTime.now(); todoList.refresh(); // 수동 refresh } GetX의 편리함과 위험성 코드량 비교 항목 ValueNotifier + InheritedWidget GetX 상태 클래스 ValueNotifier 상속 + InheritedWidget 별도 클래스 GetxController 하나 등록 InheritedWidget 래퍼로 위젯 트리 감싸기 Get.put() 한 줄 구독 ValueListenableBuilder (builder 패턴) Obx(() =\u0026gt;) 변경 알림 notifyListeners() 수동 .obs 자동 접근 context.dependOnInheritedWidgetOfExactType Get.find() 확실히 코드량은 GetX가 압도적으로 적다.\n근데 왜 GetX를 안 쓰는 프로젝트가 있을까? Ch04에서 다뤘던 Static Model의 위험성이 그대로 적용된다:\nGet.find()로 어디서든 접근/수정 가능 → 상태 변경 추적이 어려움 메모리 관리를 개발자가 직접 → Get.delete() 안 하면 메모리 누수 mutable 상태 + 전역 접근 → 디버깅 지옥이 될 수 있음 iOS 개발할 때 싱글톤 패턴의 교훈과 정확히 같다. AppState.shared.user = newUser를 아무 데서나 부르면 어디서 바뀌었는지 추적이 안 되는 것처럼, GetX의 Get.find()도 어디서든 상태를 수정할 수 있기 때문에 같은 문제가 생긴다.\nSwift 커뮤니티가 싱글톤에서 DI(의존성 주입)로 이동한 것처럼, Flutter 커뮤니티에서도 GetX에서 Scoped 방식(Provider, BLoC, Riverpod)으로 이동하는 추세가 있다. 물론 작은 앱이나 프로토타이핑에는 GetX가 여전히 최고의 선택이다.\n정리 개념 ValueNotifier GetX 방식 Scoped (내장) Static (패키지) 변경 알림 notifyListeners() 수동 .obs 자동 UI 구독 ValueListenableBuilder Obx(() =\u0026gt;) 등록/접근 InheritedWidget / of(context) Get.put() / Get.find() context 필요 필요 불필요 메모리 관리 위젯 트리가 자동 해제 개발자가 직접 Get.delete() GetX는 확실히 편하다. notifyListeners() 안 불러도 되고, InheritedWidget 만들 필요도 없다. 근데 mutable 객체의 refresh() 함정이 있고, 앱이 커지면 상태 추적이 힘들어진다. 다음 Ch05-3에서는 BLoC으로 전환한다. Event-driven 구조가 이 추적 문제를 어떻게 해결하는지, 그리고 mutable Todo를 freezed로 immutable하게 만들면 refresh() 같은 함정이 어떻게 사라지는지 비교해보자.\n","date":"2026-04-30T00:00:00+09:00","permalink":"/p/ch05-2-getx-state-management/","title":"Ch05-2. Todo 앱 GetX로 전환 - .obs와 Obx의 마법"},{"content":"Ch04에서 상태관리의 역사와 Scoped vs Static을 다뤘으니, 이제 실전이다. Todo 앱 하나를 만들고, 같은 앱을 3가지 상태관리 방식으로 리팩토링하는 시리즈다. 첫 번째는 Flutter 내장 도구만 쓴다. 외부 패키지 없이 원리부터 이해하는 게 목적이다.\nTodo 앱 구조 먼저 전체 프로젝트 구조를 보자.\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 lib/ ├── app.dart // 앱 진입점 ├── data/ │ └── memory/ │ ├── vo_todo.dart // Todo 모델 │ ├── todo_status.dart // 상태 enum │ ├── todo_data_notifier.dart // ValueNotifier │ └── todo_data_holder.dart // InheritedWidget ├── screen/ │ └── main/ │ ├── s_main.dart // 메인 화면 (탭 네비게이션) │ ├── todo/ │ │ ├── f_todo.dart // Todo 탭 전체 │ │ ├── w_todo_list.dart // 리스트 위젯 │ │ ├── w_todo_item.dart // 개별 아이템 │ │ └── w_todo_status.dart // 상태 표시 (Rive 애니메이션) │ └── write/ │ ├── d_write_todo.dart // 추가/수정 다이얼로그 │ └── vo_write_result.dart 파일 이름에 접두어가 붙는다. s_는 screen, f_는 fragment, w_는 widget, d_는 dialog, vo_는 value object. iOS에서 ViewController, ViewModel 같은 네이밍 컨벤션과 비슷한 느낌이다.\nTodo 모델 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 class Todo { int id; String title; final DateTime createdTime; DateTime? modifyTime; DateTime dueDate; TodoStatus status; Todo({ required this.id, required this.title, required this.dueDate, this.status = TodoStatus.inComplete, }) : createdTime = DateTime.now(); } 그냥 평범한 mutable class다. id, title, dueDate, status를 가지고 있고, 직접 프로퍼티를 수정할 수 있다. Swift로 치면 class(reference type)에 var 프로퍼티들을 쓴 거다. 나중에 Ch05-3에서 이걸 freezed로 immutable하게 바꾸면 뭐가 달라지는지 비교할 예정이다.\n1 2 3 4 5 enum TodoStatus { inComplete, onGoing, complete, } 상태 흐름은 inComplete → onGoing → complete 순서다. complete에서 한 번 더 누르면 확인 다이얼로그가 뜨고, 승인하면 다시 inComplete로 돌아간다.\nUI 위젯들 UI는 간단하게 넘어가자. 핵심만 보면:\nMainScreen: 하단 탭 네비게이션 + FloatingActionButton으로 할 일 추가 TodoItem: Dismissible 위젯으로 스와이프 삭제 지원. SwiftUI의 .swipeActions와 같은 역할인데, Flutter는 위젯 자체가 스와이프를 지원해서 편하다 TodoStatusWidget: 상태에 따라 체크박스 표시. onGoing일 때는 Rive 애니메이션이 재생된다 WriteTodoDialog: 할 일 추가/수정 다이얼로그. todoForEdit이 있으면 수정 모드, 없으면 추가 모드 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 // TodoItem에서 스와이프 삭제 Dismissible( key: ValueKey(todo.id), onDismissed: (direction) { // 여기서 삭제 로직 호출 }, child: RoundedContainer( child: Column( children: [ Text(todo.dueDate.toString()), Row( children: [ TodoStatusWidget(todo), Text(todo.title), const Spacer(), IconButton( onPressed: () { // 수정 로직 호출 }, icon: const Icon(EvaIcons.editOutline), ) ], ), ], ), ), ) ValueNotifier — setState의 진화 setState의 한계 setState는 해당 위젯 내부에서만 작동한다. 부모에서 만든 todoList를 여러 자식 위젯이 공유해야 할 때, 콜백으로 내려보내거나 prop drilling을 해야 한다. SwiftUI에서 @Binding을 n단계 내려보내는 고통과 같다.\nValueNotifier란 1 2 3 4 5 6 7 8 9 10 11 12 class TodoDataNotifier extends ValueNotifier\u0026lt;List\u0026lt;Todo\u0026gt;\u0026gt; { TodoDataNotifier() : super([]); // 최초 빈 리스트로 시작 void addTodo(Todo todo) { value.add(todo); notifyListeners(); } void notify() { notifyListeners(); } } ValueNotifier\u0026lt;T\u0026gt;는 Flutter 내장 클래스다. value가 바뀌면 notifyListeners()를 호출해서 구독 중인 위젯을 리빌드시킨다.\nSwift랑 비교하면:\nFlutter SwiftUI ValueNotifier\u0026lt;T\u0026gt; ObservableObject + @Published notifyListeners() objectWillChange.send() (수동) value @Published var value: T 핵심 차이는, Swift의 @Published는 값이 바뀌면 자동으로 알림을 보내는데, Flutter의 ValueNotifier에서는 List 내부를 수정하면(value.add()) 참조 자체는 안 바뀌니까 수동으로 notifyListeners()를 호출해야 한다. 이걸 까먹으면 UI가 안 바뀌는 버그가 생긴다.\nValueListenableBuilder로 UI 연결 1 2 3 4 5 6 7 8 ValueListenableBuilder\u0026lt;List\u0026lt;Todo\u0026gt;\u0026gt;( valueListenable: todoNotifier, builder: (context, todoList, child) { return Column( children: todoList.map((e) =\u0026gt; TodoItem(e)).toList(), ); }, ) SwiftUI에서 @ObservedObject를 쓰면 body가 자동 리빌드되는 것과 같은 역할이다. builder 안에서 todoList가 바뀔 때마다 UI가 다시 그려진다. child 파라미터는 리빌드할 필요 없는 자식을 캐싱하는 용도인데, 이건 성능 최적화 포인트다.\nInheritedWidget — 위젯 트리로 데이터 전파 왜 필요한가 ValueNotifier만으로는 \u0026ldquo;누가 이 Notifier를 들고 있느냐\u0026quot;가 문제다. 생성자로 내려보내면 결국 prop drilling이 다시 발생한다. InheritedWidget은 위젯 트리에 데이터를 심어두고, 하위 어디서든 꺼내 쓸 수 있게 해주는 메커니즘이다.\n사실 이 프로젝트에서 이미 다크모드 테마 시스템으로 InheritedWidget을 쓰고 있었다:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 // 이미 있던 테마용 InheritedWidget class CustomThemeHolder extends InheritedWidget { final AbstractThemeColors appColors; final CustomTheme theme; final Function(CustomTheme) changeTheme; static CustomThemeHolder of(BuildContext context) { return context.dependOnInheritedWidgetOfExactType\u0026lt;CustomThemeHolder\u0026gt;()!; } @override bool updateShouldNotify(CustomThemeHolder old) { return theme != old.theme; } } 같은 원리를 상태관리에 적용하는 거다.\nTodoDataHolder 구현 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 class TodoDataHolder extends InheritedWidget { final TodoDataNotifier notifier; const TodoDataHolder({ required this.notifier, required Widget child, Key? key, }) : super(key: key, child: child); static TodoDataNotifier of(BuildContext context) { return context .dependOnInheritedWidgetOfExactType\u0026lt;TodoDataHolder\u0026gt;()! .notifier; } @override bool updateShouldNotify(TodoDataHolder old) { return notifier != old.notifier; } } of(context) 패턴은 Flutter에서 아주 관용적인 접근법이다. Theme.of(context), MediaQuery.of(context) 등 전부 이 패턴이다. 내부적으로 context.dependOnInheritedWidgetOfExactType은 Element의 해시 테이블을 조회해서 O(1)로 찾는다.\nSwiftUI 비교:\nFlutter SwiftUI InheritedWidget + of(context) @EnvironmentObject 직접 InheritedWidget 클래스 작성 Property Wrapper가 자동 처리 context.dependOn... @EnvironmentObject var vm: ViewModel SwiftUI가 확실히 간편하다. Flutter에서는 InheritedWidget 클래스를 직접 만들어야 하는데, SwiftUI는 @EnvironmentObject만 붙이면 끝이다. 근데 원리는 같다.\ncontext extension으로 축약 1 2 3 4 5 6 extension ContextExtension on BuildContext { TodoDataHolder get todoHolder =\u0026gt; TodoDataHolder.of(this); } // 사용 context.todoHolder.addTodo(todo); 매번 TodoDataHolder.of(context).notifier.addTodo()라고 쓰면 너무 기니까 extension으로 축약한다. 테마에서도 context.appColors로 이미 같은 패턴을 쓰고 있었다.\nApp에서 Provide 1 2 3 4 5 6 7 8 9 // app.dart CustomThemeApp( child: TodoDataHolder( notifier: TodoDataNotifier(), child: MaterialApp( home: const MainScreen(), ), ), ) MaterialApp 위에 InheritedWidget을 감싸서 앱 전체에서 접근 가능하게 한다. SwiftUI에서 .environmentObject(todoVM)을 최상위에 넣는 것과 같은 구조다.\nmounted — 비동기에서 context 안전하게 쓰기 상태관리와 별개로 중요한 포인트가 하나 있었다. 비동기 작업 중에 위젯이 dispose되면 context를 쓸 수 없는 문제다.\n1 2 3 4 5 6 7 8 9 void _loadRive() async { _cachedFile = await File.asset(\u0026#39;assets/rive/fire_button.riv\u0026#39;); // 비동기 작업이 끝났는데 위젯이 이미 사라졌다면? if (_cachedFile != null \u0026amp;\u0026amp; mounted) { // mounted로 체크! _controller = RiveWidgetController(_cachedFile!); setState(() =\u0026gt; _riveReady = true); } } await 후에 setState를 부르는데, 그 사이에 사용자가 화면을 나갔다면 위젯이 dispose된 상태다. 이때 setState를 호출하면 에러가 난다. mounted 프로퍼티로 위젯이 아직 살아있는지 체크하는 게 안전하다.\niOS에서 [weak self]로 self가 nil인지 체크하는 것과 비슷한 맥락이다:\n1 2 3 4 5 6 // Swift Task { [weak self] in let data = await fetchData() guard let self else { return } // self가 살아있는지 체크 self.updateUI(data) } 1 2 3 4 5 6 7 // Flutter void someAsyncWork() async { final data = await fetchData(); if (mounted) { // 위젯이 살아있는지 체크 setState(() =\u0026gt; _data = data); } } 상태 변경 흐름 정리 추가 1 2 3 4 5 6 7 8 9 10 11 12 void _addTodo() async { final result = await WriteTodoDialog().show(); if (result != null) { context.todoHolder.addTodo( Todo( id: DateTime.now().microsecondsSinceEpoch, title: result.text, dueDate: result.dateTime, ), ); } } 다이얼로그에서 결과를 받아와서 Notifier에 추가한다. WriteTodoDialog는 제네릭 타입으로 WriteTodoResult를 반환하는 구조다.\n상태 전환 1 2 3 4 5 6 7 8 9 10 11 12 13 14 void _changeTodoStatus(Todo todo) async { switch (todo.status) { case TodoStatus.inComplete: todo.status = TodoStatus.onGoing; case TodoStatus.onGoing: todo.status = TodoStatus.complete; case TodoStatus.complete: final result = await ConfirmDialog(\u0026#39;정말로 처음 상태로 변경하시겠어요?\u0026#39;).show(); result?.runIfSuccess((data) { todo.status = TodoStatus.inComplete; }); } context.todoHolder.notify(); // 수동으로 알림 } mutable이니까 todo.status를 직접 바꾸고, notify()로 UI를 갱신한다. ConfirmDialog는 SimpleResult 패턴으로 결과를 체이닝한다. Swift의 Result\u0026lt;Success, Failure\u0026gt; 패턴과 비슷하다.\n삭제 1 2 3 // Dismissible의 onDismissed에서 context.todoHolder.value.remove(todo); context.todoHolder.notify(); 스와이프로 삭제하면 리스트에서 제거하고 알림을 보낸다.\n이 접근법의 장단점 장점:\n외부 패키지 0개. Flutter 기본 API만으로 구현 InheritedWidget 동작 원리를 이해하면 Provider, BLoC 등 모든 Scoped 방식의 기반을 이해한 거다 간단한 앱에는 충분 한계:\nnotifyListeners() 수동 호출을 까먹기 쉬움 mutable 객체를 직접 수정하니까 어디서 바뀌었는지 추적이 어려움 비즈니스 로직이 Notifier, UI 여기저기에 흩어질 수 있음 InheritedWidget을 직접 만드는 보일러플레이트가 꽤 있음 정리 개념 Flutter (ValueNotifier) SwiftUI 상태 홀더 ValueNotifier ObservableObject 변경 알림 notifyListeners() 수동 @Published 자동 UI 구독 ValueListenableBuilder @ObservedObject / body 자동 트리 전파 InheritedWidget + of(context) @EnvironmentObject 비동기 안전 mounted 체크 [weak self] / guard let self Flutter 내장만으로 상태관리를 해봤다. 동작은 하는데 notifyListeners() 수동 호출이 좀 거슬린다. 다음 Ch05-2에서는 GetX로 전환하면서 이 수동 호출이 .obs로 바뀌면 얼마나 코드가 줄어드는지 비교해보자.\n","date":"2026-04-29T00:00:00+09:00","permalink":"/p/ch05-1-valuenotifier-inheritedwidget/","title":"Ch05-1. Todo 앱으로 상태관리 입문 - ValueNotifier + InheritedWidget"},{"content":"Ch04부터는 상태관리다. 왜 상태관리를 해야 하는지, 어떤 방식들이 있는지 정리한다. 개인적으로 이 부분이 꽤 재밌었다.\n상태관리를 왜 해야 하나 결론부터 말하면 앱을 더 쉽게 개발하기 위해서다.\n처음 개발할 때 빠르게 만들기 위해 스펙이 바뀌었을 때 수정할 곳을 빠르게 찾기 위해 작은 앱에서는 setState만으로도 충분한데, 화면이 10개 넘어가고 여러 화면에서 같은 데이터를 공유해야 하면 어디서 상태를 관리하고 어떻게 전달할지가 문제가 된다. 이걸 체계적으로 정리한 게 상태관리 패턴이다.\nMVC에서 선언형 UI까지 — 왜 이렇게 바뀌었나 MVC (Model-View-Controller) 1970년대에 나온 패턴이다. 근데 많이들 오해하는 게 있다:\n원래 작은 컴포넌트 단위의 설계였다. 앱 전체 아키텍처용이 아니었다. MVVM에서 말하는 데이터 Observing이 이미 포함돼 있던 개념이다. Controller는 원래 키보드/마우스 입력을 처리하는 역할이었다. 문제는 모바일에서 Controller의 의미가 변질됐다는 거다. Android의 Activity, iOS의 UIViewController가 이미 Controller이면서 동시에 View였다. 화면 자체가 Controller 역할을 하는 짬뽕이 된 거다. 결과적으로 Controller가 뚱뚱해지면서(Massive View Controller라고 부른다) 유지보수가 힘들어졌다.\nSwift에서 UIKit 개발해본 사람이면 ViewController에 네트워크 호출, 테이블뷰 delegate, 데이터 가공 로직까지 다 때려넣어본 경험이 있을 거다. 그게 바로 MVC의 한계다.\nMVP (Model-View-Presenter) MVC의 문제를 해결하려고 나온 게 MVP다.\nAndroid는 View가 XML로 분리돼 있었고 iOS는 View가 ViewController + Storyboard로 분리돼 있었다 이렇게 코드와 분리된 View를 제어할 Presenter가 필요했다. Presenter가 로직을 담당하고, View는 그리기만 한다.\n근데 문제가 있었다. Presenter가 View에게 일일이 명령을 내려야 화면이 갱신됐다. \u0026ldquo;이 라벨 텍스트 바꿔\u0026rdquo;, \u0026ldquo;이 버튼 숨겨\u0026rdquo;, \u0026ldquo;이 리스트 리로드해\u0026rdquo;\u0026hellip; 하나하나 다 지시해야 했다. 코드가 장황해지고 빠뜨리면 UI 버그가 났다.\nMVVM (Model-View-ViewModel) MVP의 \u0026ldquo;일일이 명령\u0026rdquo; 문제를 해결한 게 MVVM이다.\n1 ViewModel의 상태를 바꾸면 → View가 알아서 갱신된다 핵심은 데이터 바인딩이다:\nAndroid에서는 DataBinding 라이브러리가 나왔고 iOS에서는 RxSwift 같은 Rx 라이브러리로 Observable 패턴을 구현했다 값만 세팅하면 알아서 뷰가 갱신되니까 UI 버그가 확 줄었다. 근데 완벽하진 않았다. 뷰가 내부적으로 코드와 분리돼 있었기 때문에(XML, Storyboard) ViewModel과 View를 연결하는 바인딩 코드가 노가다였다.\niOS에서 RxSwift 쓸 때를 떠올리면:\n1 2 3 4 5 6 7 8 9 10 11 12 // iOS + RxSwift: 바인딩 노가다 viewModel.userName .bind(to: nameLabel.rx.text) .disposed(by: disposeBag) viewModel.isLoading .bind(to: activityIndicator.rx.isAnimating) .disposed(by: disposeBag) viewModel.items .bind(to: tableView.rx.items(cellIdentifier: \u0026#34;Cell\u0026#34;)) { ... } .disposed(by: disposeBag) 프로퍼티 하나하나 다 바인딩해줘야 했다.\n선언형 UI — 최종 보스 그리고 선언형 UI가 등장했다.\nAndroid → Jetpack Compose iOS → SwiftUI 크로스플랫폼 → Flutter, React Flutter 공식 문서에서 설명하는 핵심 차이는 이거다:\n1 2 3 4 5 6 7 8 9 10 11 12 // 명령형 (Imperative) — 어떻게 바꿀지 지시 button.setColor(red); button.setText(\u0026#39;완료\u0026#39;); container.removeChild(oldChild); container.addChild(newChild); // 선언형 (Declarative) — 어떤 상태인지 선언 return ElevatedButton( style: ButtonStyle(backgroundColor: MaterialStateProperty.all(Colors.red)), onPressed: onTap, child: Text(\u0026#39;완료\u0026#39;), ); 명령형은 \u0026ldquo;빨간색으로 바꿔, 텍스트 바꿔, 자식 교체해\u0026rdquo; 하고 일일이 지시하는 거고, 선언형은 \u0026ldquo;이 상태일 때 UI는 이렇게 생겼다\u0026quot;고 선언만 하면 프레임워크가 알아서 그려준다.\nSwiftUI에서 @State가 바뀌면 body가 다시 그려지는 것과 완전히 같은 원리다:\n1 2 3 4 5 6 7 // SwiftUI struct CounterView: View { @State var count = 0 var body: some View { Button(\u0026#34;\\(count)\u0026#34;) { count += 1 } } } 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 // Flutter class CounterWidget extends StatefulWidget { @override State\u0026lt;CounterWidget\u0026gt; createState() =\u0026gt; _CounterWidgetState(); } class _CounterWidgetState extends State\u0026lt;CounterWidget\u0026gt; { int count = 0; @override Widget build(BuildContext context) { return ElevatedButton( onPressed: () =\u0026gt; setState(() =\u0026gt; count++), child: Text(\u0026#39;$count\u0026#39;), ); } } 선언형 UI가 나오면서 View의 패턴 구조를 논의할 필요가 없어졌다. MVC냐 MVP냐 MVVM이냐가 아니라, 상태와 데이터를 어떻게 관리할지가 더 중요해진 거다. 그게 바로 State Management다.\n흐름 정리 1 2 3 4 5 6 7 MVC: 원래 작은 컴포넌트용이었는데 모바일에서 Controller가 비대해짐 ↓ MVP: View와 로직을 Presenter로 분리. 근데 일일이 명령해야 함 ↓ MVVM: 데이터 바인딩으로 자동 갱신. 근데 바인딩 코드가 노가다 ↓ 선언형 UI: 상태만 바꾸면 끝. 이제 \u0026#34;상태를 어떻게 관리할까\u0026#34;가 핵심 Scoped Model vs Static Model Flutter 상태관리 구조는 크게 두 가지로 나뉜다.\nScoped Model 상태의 범위를 제한하는 방식이다. 특정 화면이나 위젯 트리 안에서만 상태가 유효하다.\n1 2 3 4 5 // Provider 예시: 이 위젯 하위에서만 CartModel에 접근 가능 ChangeNotifierProvider( create: (_) =\u0026gt; CartModel(), child: ShoppingPage(), // 이 안에서만 CartModel 사용 가능 ) 특징:\n해당 화면이 사라지면 자동으로 메모리 해제된다 자식 위젯에서 id 없이도 상위 데이터를 참조할 수 있다 내부적으로 Flutter의 InheritedWidget을 활용한다 대표적인 Scoped 방식: Provider, BLoC, Riverpod\nStatic Model 상태가 전역으로 떠 있는 방식이다. 앱 어디서든 접근할 수 있다.\n1 2 3 4 5 6 7 8 // GetX 예시: 어디서든 접근 가능 Get.put(UserController()); // A 화면에서 Get.find\u0026lt;UserController\u0026gt;().user.value; // B 화면에서도 Get.find\u0026lt;UserController\u0026gt;().user.value; // 같은 인스턴스 특징:\n메모리 관리를 개발자가 직접 해야 한다 자식에서 데이터 참조할 때 id(tag)가 항상 필요하다 Scoped보다 구현이 훨씬 쉽다 어디서든 접근/수정 가능하니까 상태가 꼬일 위험이 있다 대표적인 Static 방식: GetX\n뭐가 다른지 예시로 보면 유튜브 앱이라고 치자. 영상 재생 화면은 코드상으로는 같은 VideoScreen인데, 어떤 영상이냐에 따라 동영상 URL, 댓글, 좋아요 수가 전부 다르다.\nScoped 방식에서는:\n1 2 3 4 5 // 각 영상 화면의 상위에 해당 영상의 상태를 넣어줌 ChangeNotifierProvider( create: (_) =\u0026gt; VideoState(videoId: \u0026#39;abc123\u0026#39;), child: VideoScreen(), // 이 안에서는 abc123 영상의 상태만 보임 ) Riverpod에서는 family라는 기능으로 더 깔끔하게 처리한다:\n1 2 3 4 5 6 7 8 // Riverpod: videoId별로 자동으로 다른 상태 인스턴스 생성 final videoProvider = FutureProvider.family\u0026lt;Video, String\u0026gt;((ref, videoId) { return fetchVideo(videoId); }); // 사용: 같은 provider인데 id가 다르면 다른 상태 ref.watch(videoProvider(\u0026#39;abc123\u0026#39;)); // 영상 1의 상태 ref.watch(videoProvider(\u0026#39;xyz789\u0026#39;)); // 영상 2의 상태 (별도) Static 방식(GetX)에서는:\n1 2 3 4 5 6 // tag로 구분해서 따로 관리 Get.put(VideoController(), tag: \u0026#39;abc123\u0026#39;); Get.put(VideoController(), tag: \u0026#39;xyz789\u0026#39;); // 사용할 때 tag로 찾아와야 함 Get.find\u0026lt;VideoController\u0026gt;(tag: \u0026#39;abc123\u0026#39;); 둘 다 가능하다. 근데 Scoped는 화면 닫으면 알아서 정리되고, Static은 개발자가 직접 Get.delete(tag: 'abc123') 해줘야 한다.\n또 다른 예시 — ID가 없는 경우 데스크탑 앱에서 \u0026ldquo;새 문서\u0026rdquo; 버튼을 3번 눌렀다고 치자. 각 문서는 아직 저장 전이라 고유 ID가 없다. 이때는 Scoped가 자연스럽다:\n1 2 3 4 5 6 7 // Scoped: 각 문서 화면이 자기 스코프 안에 상태를 가짐 Navigator.push(context, MaterialPageRoute( builder: (_) =\u0026gt; ChangeNotifierProvider( create: (_) =\u0026gt; DocumentState(), // 각각 독립된 상태 child: DocumentScreen(), ), )); Static으로도 가능은 하다 — 임시 ID를 생성해서 tag로 관리하면 된다. 근데 굳이 그럴 필요 없이 Scoped가 더 깔끔한 케이스다.\n그래서 뭘 써야 하나 정답은 스펙에 따라 다르다. 하지만 일반적인 기준은 있다:\nScoped Model Static Model 메모리 관리 자동 해제 직접 관리 데이터 접근 위젯 트리 통해서 어디서든 직접 구현 난이도 상대적으로 복잡 쉬움 상태 안정성 범위가 제한돼서 안전 어디서든 수정 가능해서 꼬일 수 있음 테스트 스코프 단위로 격리 가능 전역 상태라 격리 어려움 대표 패키지 Provider, BLoC, Riverpod GetX Swift 개발자 입장에서 비유하면:\nScoped = SwiftUI에서 @StateObject를 뷰 계층에 맞게 넣는 것 Static = 싱글톤으로 전역 접근하는 것 (AppState.shared) iOS 개발할 때도 싱글톤 남발하면 테스트 힘들고 상태 꼬이는 걸 경험해봤을 텐데, Flutter에서도 똑같다. GetX가 쉬운 건 맞지만 앱이 커지면 Scoped 방식이 관리하기 편하다.\n상태관리 패키지 현황 Flutter 공식 문서에서도 여러 접근법을 소개하고 있다. 현재 주요 패키지들의 포지션을 정리하면:\n패키지 방식 특징 Provider Scoped Flutter 팀 추천 입문용. InheritedWidget 래퍼 BLoC Scoped Event → State 단방향 흐름. 엔터프라이즈 앱에 적합 Riverpod Scoped Provider의 진화형. 컴파일 타임 안전성. family로 키 기반 관리 GetX Static 전역 접근, 쉬운 구현. tag로 인스턴스 구분 Flutter 공식 입장은 \u0026ldquo;setState로 시작하고, 복잡해지면 패키지를 도입하라\u0026quot;다. 어떤 패키지가 절대적으로 좋다기보다는 앱 규모와 팀 상황에 맞는 걸 고르는 게 맞다.\n정리 상태관리의 역사를 보면 결국 한 방향으로 흘러왔다:\n1 \u0026#34;UI를 어떻게 그릴까\u0026#34; → \u0026#34;상태를 어떻게 관리할까\u0026#34; MVC에서 선언형 UI까지 오는 동안 패턴이 계속 바뀐 이유는 \u0026ldquo;상태가 바뀌면 UI가 알아서 반영되게\u0026rdquo; 하고 싶었기 때문이다. 선언형 UI가 그걸 해결했고, 이제 남은 문제는 그 상태를 어떤 범위에서 어떻게 관리할지다.\nScoped냐 Static이냐는 결국 트레이드오프다. 쉬운 걸 원하면 Static(GetX), 안전한 걸 원하면 Scoped(Provider/BLoC/Riverpod). iOS 개발할 때 싱글톤 vs 의존성 주입 고민했던 것과 본질적으로 같은 문제다.\n","date":"2026-04-28T03:00:00+09:00","permalink":"/p/ch04-state-management/","title":"Ch04. 상태관리를 왜 해야 하는가 - MVC에서 선언형 UI까지"},{"content":"강의에서 SOLID에 이어 GoF 디자인 패턴 4가지가 나왔다. GoF(Gang of Four)는 1994년에 나온 Design Patterns 책에서 정리한 23개 객체지향 설계 패턴인데, 이번에 다룬 건 그 중 4개다. MVVM 같은 아키텍처 패턴과는 다르게, 클래스/객체 단위의 설계 기법이다.\nSingleton — 인스턴스 하나만 앱 전체에서 인스턴스가 딱 하나만 존재해야 할 때 쓴다. DB 커넥션, 네트워크 클라이언트, 로거 같은 것들이 대표적이다. 여러 개 만들면 리소스 낭비거나 상태가 꼬이는 경우에 필요하다.\n1 2 3 4 5 6 7 8 9 10 class ApiClient { static final ApiClient _instance = ApiClient._(); ApiClient._(); // 진짜 생성자는 private factory ApiClient() =\u0026gt; _instance; // 어디서 호출해도 같은 인스턴스 } var a = ApiClient(); var b = ApiClient(); print(a == b); // true — 같은 놈 factory 키워드가 포인트다. 일반 생성자는 호출할 때마다 새 인스턴스를 만드는데, factory는 기존 인스턴스를 돌려줄 수 있다. ApiClient() 처럼 생성자를 부르는 것 같지만 실제로는 _instance를 반환하는 거다.\nSwift에서는 보통 static let shared로 싱글톤을 만든다:\n1 2 3 4 5 6 7 // Swift class ApiClient { static let shared = ApiClient() private init() {} } let client = ApiClient.shared Dart는 factory 생성자 덕분에 .shared 없이 그냥 ApiClient()로 쓸 수 있다는 차이가 있다. 쓰는 쪽에서는 싱글톤인지 모르고 써도 되는 셈.\nFlutter에서 GetX 쓰면 Get.put()/Get.find()가 사실상 싱글톤 관리를 해준다:\n1 2 3 // GetX 방식 — 직접 싱글톤 안 만들어도 됨 Get.put(ApiClient()); // 등록 var client = Get.find\u0026lt;ApiClient\u0026gt;(); // 어디서든 같은 인스턴스 Factory — 객체 생성을 위임 만드는 쪽이 뭐가 나올지 결정하는 패턴이다. 쓰는 쪽은 구체적인 클래스를 몰라도 된다.\nfactory 생성자 Dart의 factory 키워드는 싱글톤 외에도 여러 용도로 쓸 수 있다.\n1 2 3 4 5 6 7 8 9 10 11 12 // 1. fromJson — API 응답을 객체로 변환 class User { final String name; final int age; User(this.name, this.age); factory User.fromJson(Map\u0026lt;String, dynamic\u0026gt; json) { return User(json[\u0026#39;name\u0026#39;], json[\u0026#39;age\u0026#39;]); } } var user = User.fromJson({\u0026#39;name\u0026#39;: \u0026#39;jHoon\u0026#39;, \u0026#39;age\u0026#39;: 25}); User.fromJson이 Factory 패턴이다. JSON이라는 날것의 데이터를 받아서 알아서 User 객체를 만들어준다. API 연동하면 매번 쓰게 되는 패턴이다.\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 // 2. 조건에 따라 다른 서브클래스 반환 abstract class Payment { void pay(int amount); factory Payment(String method) { if (method == \u0026#39;card\u0026#39;) return CardPayment(); if (method == \u0026#39;cash\u0026#39;) return CashPayment(); throw \u0026#39;unknown method\u0026#39;; } } class CardPayment extends Payment { @override void pay(int amount) =\u0026gt; print(\u0026#39;카드 결제: $amount원\u0026#39;); } class CashPayment extends Payment { @override void pay(int amount) =\u0026gt; print(\u0026#39;현금 결제: $amount원\u0026#39;); } var payment = Payment(\u0026#39;card\u0026#39;); // Payment로 불렀는데 CardPayment가 나옴 payment.pay(5000); // 카드 결제: 5000원 Payment('card') 하면 CardPayment가 나오고, Payment('cash') 하면 CashPayment가 나온다. 쓰는 쪽은 CardPayment 클래스를 직접 알 필요가 없다.\n사실 Dart SDK 자체가 이 패턴을 쓰고 있다. List가 abstract class인데 List.generate()나 List.filled()로 만들 수 있는 이유가 내부에 factory 생성자가 있기 때문이다.\n앱에서 실제로 쓰는 빈도 앱 개발에서 factory를 직접 쓸 일은 대부분 싱글톤이랑 fromJson 두 가지다. 서브클래스 분기 같은 건 라이브러리나 프레임워크 만드는 사람이 쓰는 거라, \u0026ldquo;이런 게 있구나\u0026rdquo; 정도면 된다.\nBuilder — 단계적으로 조립 파라미터가 많은 객체를 하나씩 붙여가며 만드는 패턴이다. 조립식 가구처럼.\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 // Builder 패턴이 필요한 상황 (Java 스타일) class HttpRequest { String method; String url; Map\u0026lt;String, String\u0026gt; headers; int timeout; HttpRequest._(this.method, this.url, this.headers, this.timeout); } class HttpRequestBuilder { String _method = \u0026#39;GET\u0026#39;; String _url = \u0026#39;\u0026#39;; Map\u0026lt;String, String\u0026gt; _headers = {}; int _timeout = 30; HttpRequestBuilder setMethod(String m) { _method = m; return this; } HttpRequestBuilder setUrl(String u) { _url = u; return this; } HttpRequestBuilder addHeader(String k, String v) { _headers[k] = v; return this; } HttpRequestBuilder setTimeout(int t) { _timeout = t; return this; } HttpRequest build() =\u0026gt; HttpRequest._(_method, _url, _headers, _timeout); } // 사용 var request = HttpRequestBuilder() .setMethod(\u0026#39;POST\u0026#39;) .setUrl(\u0026#39;https://api.com/users\u0026#39;) .addHeader(\u0026#39;Authorization\u0026#39;, \u0026#39;Bearer abc\u0026#39;) .setTimeout(10) .build(); 근데 이거, Dart에서는 named parameter로 같은 걸 할 수 있다:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 class HttpRequest { final String method; final String url; final Map\u0026lt;String, String\u0026gt; headers; final int timeout; HttpRequest({ this.method = \u0026#39;GET\u0026#39;, required this.url, this.headers = const {}, this.timeout = 30, }); } var request = HttpRequest( method: \u0026#39;POST\u0026#39;, url: \u0026#39;https://api.com/users\u0026#39;, headers: {\u0026#39;Authorization\u0026#39;: \u0026#39;Bearer abc\u0026#39;}, timeout: 10, ); Java에는 named parameter가 없어서 Builder 클래스가 필수였는데, Dart/Swift는 언어 차원에서 지원하니까 Builder를 별도로 만들 필요가 거의 없다.\n사실 Flutter의 Widget 생성자가 전부 이 방식이다:\n1 2 3 4 5 6 7 8 9 // Flutter Widget = 사실상 Builder 패턴 AlertDialog( title: Text(\u0026#39;삭제\u0026#39;), content: Text(\u0026#39;정말 삭제할까?\u0026#39;), actions: [ TextButton(onPressed: () {}, child: Text(\u0026#39;취소\u0026#39;)), TextButton(onPressed: () {}, child: Text(\u0026#39;확인\u0026#39;)), ], ); named parameter로 하나씩 조립하는 거니까 Builder랑 본질은 같다. Dart에서 Builder 패턴을 직접 구현할 일은 거의 없다.\nCommand — 동작을 객체로 감싸기 실행할 동작 자체를 객체로 만들어서 저장하거나 되돌리거나 할 수 있게 하는 패턴이다. 리모컨 버튼이라고 생각하면 된다. 버튼에 동작을 매핑해두고, 누른 기록을 저장하면 undo도 가능하다.\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 abstract class Command { void execute(); void undo(); } class AddTextCommand extends Command { final List\u0026lt;String\u0026gt; document; final String text; AddTextCommand(this.document, this.text); @override void execute() =\u0026gt; document.add(text); @override void undo() =\u0026gt; document.removeLast(); } class DeleteTextCommand extends Command { final List\u0026lt;String\u0026gt; document; late String _deleted; DeleteTextCommand(this.document); @override void execute() { _deleted = document.removeLast(); } @override void undo() =\u0026gt; document.add(_deleted); } // 사용: 실행 이력을 스택으로 관리 var doc = \u0026lt;String\u0026gt;[]; var history = \u0026lt;Command\u0026gt;[]; // 텍스트 추가 var cmd1 = AddTextCommand(doc, \u0026#39;첫 번째 줄\u0026#39;); cmd1.execute(); history.add(cmd1); // doc: [\u0026#39;첫 번째 줄\u0026#39;] var cmd2 = AddTextCommand(doc, \u0026#39;두 번째 줄\u0026#39;); cmd2.execute(); history.add(cmd2); // doc: [\u0026#39;첫 번째 줄\u0026#39;, \u0026#39;두 번째 줄\u0026#39;] // Ctrl+Z — 되돌리기 history.removeLast().undo(); // doc: [\u0026#39;첫 번째 줄\u0026#39;] 메모장, 그림판 같은 앱에서 undo/redo가 이 패턴이다.\nFlutter에서 onPressed에 함수를 넘기는 것도 넓게 보면 Command다:\n1 2 3 4 ElevatedButton( onPressed: () =\u0026gt; cart.addItem(product), // 동작을 함수로 감싸서 전달 child: Text(\u0026#39;담기\u0026#39;), ) 다만 undo 기능이 필요 없으면 이렇게 콜백으로 충분하고, Command 클래스를 따로 만들 일은 거의 없다. 문서 편집기나 드로잉 앱처럼 실행 취소가 핵심인 앱에서나 정식으로 쓰는 패턴이다.\n정리 패턴 한 줄 요약 앱에서 쓰는 빈도 Singleton 인스턴스 하나만 자주 — 서비스, DB, 네트워크 Factory 만드는 걸 위임 자주 — fromJson, 싱글톤 Builder 단계적 조립 거의 안 씀 — named parameter가 대신함 Command 동작을 객체로 거의 안 씀 — undo 필요할 때만 GoF 패턴 23개 중 4개만 다뤘는데, 앱 개발에서 자주 쓰는 건 Singleton이랑 Factory 정도다. Builder는 Dart 언어가 이미 해결해주고 있고, Command는 특수한 경우에만 필요하다. 나머지 GoF 패턴 중에는 Observer(Stream, ChangeNotifier)가 Flutter에서 가장 많이 쓰이는데 이건 상태관리랑 같이 정리하는 게 나을 것 같다.\n","date":"2026-04-28T02:00:00+09:00","permalink":"/p/ch03-5-design-patterns/","title":"Ch03-5. GoF 디자인 패턴 - Singleton, Factory, Builder, Command"},{"content":"강의에서 SOLID, DRY, KISS 원칙이 나왔다. 코드를 따로 치진 않았는데 개념 정리는 해둬야 할 것 같아서 Dart/Flutter 기준으로 정리한다.\nSOLID 원칙 Robert C. Martin(Uncle Bob)이 정리한 객체지향 설계 5원칙이다. 원래 Java 세계에서 나온 건데 Dart/Flutter에서도 똑같이 적용된다.\nS — 단일 책임 원칙 (Single Responsibility) 클래스는 하나의 책임만 가져야 한다.\n하나의 클래스가 여러 일을 하면 한쪽을 고칠 때 다른 쪽이 깨질 수 있다.\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 // 나쁜 예: 한 클래스가 데이터 로딩 + UI 포맷팅 + 저장까지 다 함 class UserManager { Future\u0026lt;User\u0026gt; fetchUser(int id) async { ... } String formatUserName(User user) =\u0026gt; \u0026#39;${user.lastName} ${user.firstName}\u0026#39;; Future\u0026lt;void\u0026gt; saveToLocal(User user) async { ... } } // 좋은 예: 책임을 나눔 class UserRepository { Future\u0026lt;User\u0026gt; fetch(int id) async { ... } Future\u0026lt;void\u0026gt; save(User user) async { ... } } class UserFormatter { String formatName(User user) =\u0026gt; \u0026#39;${user.lastName} ${user.firstName}\u0026#39;; } Flutter에서 이게 특히 중요한 게, Widget 안에 비즈니스 로직을 때려박으면 나중에 유지보수가 지옥이다. 그래서 GetX든 BLoC든 상태관리 패턴이 전부 UI와 로직을 분리하는 구조다.\nO — 개방-폐쇄 원칙 (Open-Closed) 확장에는 열려 있고, 수정에는 닫혀 있어야 한다.\n기존 코드를 건드리지 않고 새 기능을 추가할 수 있어야 한다는 뜻이다.\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 // 나쁜 예: 새 할인 타입 추가하면 이 함수를 계속 수정해야 함 double calcDiscount(String type, double price) { if (type == \u0026#39;percent\u0026#39;) return price * 0.1; if (type == \u0026#39;fixed\u0026#39;) return 1000; // 새 타입 추가할 때마다 여기를 고침... return 0; } // 좋은 예: 새 할인은 클래스만 추가하면 됨 abstract class Discount { double calculate(double price); } class PercentDiscount extends Discount { final double rate; PercentDiscount(this.rate); @override double calculate(double price) =\u0026gt; price * rate; } class FixedDiscount extends Discount { final double amount; FixedDiscount(this.amount); @override double calculate(double price) =\u0026gt; amount; } // 새로 추가해도 기존 코드 안 건드림 class BuyOneGetOneFree extends Discount { @override double calculate(double price) =\u0026gt; price; } Swift에서 protocol + 구현체 패턴으로 하는 것과 완전히 같다. Dart는 abstract class가 그 역할을 한다.\nL — 리스코프 치환 원칙 (Liskov Substitution) 부모 타입 자리에 자식 타입을 넣어도 정상 동작해야 한다.\n이름이 어려운데 실제로는 단순하다. 상속받은 클래스가 부모의 계약을 깨면 안 된다는 거다.\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 class Rectangle { double width; double height; Rectangle(this.width, this.height); double get area =\u0026gt; width * height; } // 나쁜 예: 정사각형이 직사각형을 상속 class Square extends Rectangle { Square(double size) : super(size, size); // width를 바꾸면 height도 바뀌어야 하는데... // Rectangle을 기대하는 코드에서 예상과 다르게 동작함 @override set width(double value) { super.width = value; super.height = value; // 부모의 계약 위반 } } void printArea(Rectangle r) { r.width = 5; r.height = 3; print(r.area); // Rectangle이면 15인데, Square면 9가 나옴 } 유명한 \u0026ldquo;정사각형-직사각형 문제\u0026quot;다. 수학적으로는 정사각형이 직사각형의 하위지만, 코드에서는 상속하면 안 되는 케이스다. 이럴 때는 상속 대신 공통 인터페이스를 만드는 게 맞다.\nI — 인터페이스 분리 원칙 (Interface Segregation) 클라이언트가 쓰지 않는 메서드에 의존하지 않아야 한다.\n거대한 인터페이스 하나보다 작은 인터페이스 여러 개가 낫다.\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 // 나쁜 예: 모든 걸 하나에 때려넣음 abstract class Worker { void code(); void design(); void managePeople(); void writeTests(); } // Developer가 design()이나 managePeople()을 왜 구현해야 하지? // 좋은 예: 역할별로 분리 abstract class Coder { void code(); void writeTests(); } abstract class Designer { void design(); } abstract class Manager { void managePeople(); } class Developer implements Coder { @override void code() =\u0026gt; print(\u0026#39;코딩 중\u0026#39;); @override void writeTests() =\u0026gt; print(\u0026#39;테스트 작성 중\u0026#39;); } Dart에서는 implements로 여러 인터페이스를 동시에 구현할 수 있으니까 분리해도 조합이 자유롭다. Swift의 protocol composition(Coder \u0026amp; Manager)이랑 같은 느낌이다.\nD — 의존성 역전 원칙 (Dependency Inversion) 구체 클래스가 아니라 추상에 의존해야 한다.\n실전에서 제일 체감 큰 원칙이다. 테스트할 때 mock으로 교체하려면 이게 필수다.\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 // 나쁜 예: 구체 클래스에 직접 의존 class UserViewModel { final ApiService _api = ApiService(); // 이러면 테스트 때 교체 불가 Future\u0026lt;void\u0026gt; loadUser() async { var user = await _api.fetchUser(); } } // 좋은 예: 추상에 의존 + 생성자 주입 abstract class UserRepository { Future\u0026lt;User\u0026gt; fetchUser(); } class ApiUserRepository implements UserRepository { @override Future\u0026lt;User\u0026gt; fetchUser() async { // 실제 API 호출 } } class UserViewModel { final UserRepository _repo; // 추상 타입에 의존 UserViewModel(this._repo); // 외부에서 주입 Future\u0026lt;void\u0026gt; loadUser() async { var user = await _repo.fetchUser(); } } // 실제 사용 var vm = UserViewModel(ApiUserRepository()); // 테스트 var vm = UserViewModel(MockUserRepository()); Swift에서 protocol로 DI(의존성 주입) 하는 것과 완전히 같은 패턴이다. Flutter에서 GetX의 Get.put(), Provider의 ChangeNotifierProvider 같은 것들이 전부 이 원칙 위에서 돌아간다.\nDRY — Don\u0026rsquo;t Repeat Yourself 같은 로직을 두 번 이상 쓰지 마라.\n복붙이 3번 이상 되면 함수나 클래스로 빼라는 거다.\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 // 나쁜 예: 같은 검증 로직이 여기저기 흩어져 있음 void createUser(String email) { if (!email.contains(\u0026#39;@\u0026#39;) || email.length \u0026lt; 5) throw \u0026#39;이메일 형식 오류\u0026#39;; // ... } void updateEmail(String email) { if (!email.contains(\u0026#39;@\u0026#39;) || email.length \u0026lt; 5) throw \u0026#39;이메일 형식 오류\u0026#39;; // ... } // 좋은 예: 한 곳에서 관리 class EmailValidator { static void validate(String email) { if (!email.contains(\u0026#39;@\u0026#39;) || email.length \u0026lt; 5) throw \u0026#39;이메일 형식 오류\u0026#39;; } } void createUser(String email) { EmailValidator.validate(email); } 다만 주의할 점이 있다. \u0026ldquo;코드가 비슷하게 생겼다\u0026quot;고 무조건 합치면 안 된다. 지금은 같아 보여도 나중에 다르게 변할 수 있는 로직이면 분리해두는 게 맞다. 억지로 합치면 오히려 조건 분기가 늘어나서 더 복잡해진다.\nKISS — Keep It Simple, Stupid 단순하게 해라.\n과도한 추상화, 쓸데없는 패턴 적용을 경계하라는 거다.\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 // KISS 위반: 간단한 걸 과하게 추상화 abstract class StringProcessor { String process(String input); } class UpperCaseProcessor implements StringProcessor { @override String process(String input) =\u0026gt; input.toUpperCase(); } class ProcessorFactory { static StringProcessor create(String type) { switch (type) { case \u0026#39;upper\u0026#39;: return UpperCaseProcessor(); default: throw \u0026#39;Unknown type\u0026#39;; } } } var result = ProcessorFactory.create(\u0026#39;upper\u0026#39;).process(\u0026#39;hello\u0026#39;); // KISS: 그냥 이렇게 하면 되는데? var result = \u0026#39;hello\u0026#39;.toUpperCase(); SOLID을 배우면 뭐든 인터페이스로 빼고 패턴을 적용하고 싶어지는데, 간단한 문제에 복잡한 구조를 씌우면 오히려 코드가 읽기 어려워진다. \u0026ldquo;지금 이 추상화가 정말 필요한가?\u0026ldquo;를 항상 생각해야 한다.\n정리 원칙 한 줄 요약 Flutter에서 체감 SRP 한 클래스 = 한 책임 Widget, ViewModel, Repository 분리 OCP 수정 없이 확장 abstract class로 다형성 LSP 자식이 부모 계약 지키기 상속 설계 시 주의 ISP 인터페이스 잘게 쪼개기 implements 다중 구현 DIP 추상에 의존, 주입 받기 GetX/Provider로 DI DRY 반복 금지 공통 로직 유틸로 분리 KISS 단순하게 과도한 패턴 적용 금지 SOLID이 결국 말하는 건 \u0026ldquo;변경에 강한 코드를 짜라\u0026quot;는 거다. 그리고 DRY랑 KISS는 서로 균형을 잡아줘야 한다 — 중복을 없애되(DRY) 그 과정에서 너무 복잡해지면 안 되고(KISS). 실무에서 이 밸런스 잡는 게 제일 어렵다고 한다.\n","date":"2026-04-28T01:00:00+09:00","permalink":"/p/ch03-4-solid-dry-kiss/","title":"Ch03-4. SOLID, DRY, KISS - 설계 원칙 정리"},{"content":"Ch03 마지막은 함수형 프로그래밍이다. 람다, Iterable/yield, 절차형과 함수형의 차이를 정리한다.\n람다식 (익명 함수) Dart에서 함수는 1급 객체다. 변수에 담을 수 있고, 인자로 넘길 수 있고, 반환할 수도 있다. Swift의 클로저와 같은 개념이다.\n1 2 3 4 5 6 7 8 9 10 11 // 일반 함수 int add(int a, int b) =\u0026gt; a + b; // 익명 함수를 변수에 담기 var multiply = (int a, int b) =\u0026gt; a * b; // 블록 바디 (여러 줄) var greet = (String name) { var message = \u0026#39;Hello, $name\u0026#39;; return message; }; Swift 클로저와 비교하면 문법이 좀 다르다:\n1 2 3 // Swift let add = { (a: Int, b: Int) -\u0026gt; Int in a + b } let multiply: (Int, Int) -\u0026gt; Int = { $0 * $1 } 1 2 3 // Dart var add = (int a, int b) =\u0026gt; a + b; // $0, $1 같은 축약은 없음 Swift에서는 $0, $1로 파라미터를 축약할 수 있는데, Dart는 그런 문법이 없다. 대신 화살표 =\u0026gt;가 있어서 한 줄짜리 함수를 간결하게 쓸 수 있다.\n정렬에서의 활용 1 2 3 4 5 6 7 var list = [5, 2, 4, 1, 3]; // 오름차순 list.sort((a, b) =\u0026gt; a.compareTo(b)); // 내림차순 list.sort((a, b) =\u0026gt; b.compareTo(a)); Swift에서 sorted(by: \u0026lt;) 쓰는 것과 비슷한데, Dart는 compareTo를 쓰는 게 관례다. sort는 원본을 변경하는 것도 주의 (Swift의 sort()도 마찬가지).\n함수를 인자로 넘기기 1 2 3 4 5 6 7 8 9 10 11 // 함수를 파라미터로 받는 함수 void repeat(int times, void Function(int) action) { for (var i = 0; i \u0026lt; times; i++) { action(i); } } repeat(3, (i) =\u0026gt; print(\u0026#39;$i번째 실행\u0026#39;)); // 0번째 실행 // 1번째 실행 // 2번째 실행 Dart에서 함수 타입은 void Function(int) 식으로 쓴다. Swift의 (Int) -\u0026gt; Void와 같은 의미인데 문법이 다르다.\nTear-off 함수를 괄호 없이 이름만 쓰면 자동으로 클로저가 된다. Effective Dart에서도 람다로 감싸지 말고 tear-off를 쓰라고 권장한다.\n1 2 3 4 5 // 이것보다 items.forEach((item) =\u0026gt; print(item)); // 이렇게 (tear-off) items.forEach(print); typedef로 함수 타입에 이름 붙이기 함수 타입이 길어지면 typedef로 별칭을 만들 수 있다. Swift의 typealias와 같다.\n1 2 3 4 5 6 7 8 typedef Predicate\u0026lt;T\u0026gt; = bool Function(T item); typedef Mapper\u0026lt;T, R\u0026gt; = R Function(T item); // 사용 Predicate\u0026lt;int\u0026gt; isEven = (n) =\u0026gt; n % 2 == 0; Mapper\u0026lt;String, int\u0026gt; getLength = (s) =\u0026gt; s.length; var evenNumbers = [1, 2, 3, 4, 5].where(isEven).toList(); // [2, 4] 함수를 반환하는 함수 1 2 3 4 5 6 7 8 9 double Function(double) makeDiscounter(double percent) { return (price) =\u0026gt; price * (1 - percent / 100); } var tenOff = makeDiscounter(10); var twentyOff = makeDiscounter(20); print(tenOff(10000)); // 9000.0 print(twentyOff(10000)); // 8000.0 클로저가 percent를 캡처해서 기억하는 거다. Swift에서도 완전히 같은 패턴이 가능하다.\nIterable과 yield Iterable이란 Iterable은 순차적으로 접근할 수 있는 컬렉션의 추상 타입이다. List와 Set이 Iterable을 구현하고 있다. Dart 공식 codelab에서 자세히 설명하고 있다.\n중요한 건 **지연 평가(lazy evaluation)**다. map()이나 where() 같은 메서드가 반환하는 Iterable은 바로 계산되지 않고, 실제로 값을 꺼낼 때 계산된다.\n1 2 3 4 5 6 7 var mapped = [1, 2, 3].map((n) { print(\u0026#39;변환 중: $n\u0026#39;); return n * 2; }); // 여기까지는 아무것도 출력 안 됨! print(mapped.toList()); // 이때 비로소 \u0026#34;변환 중\u0026#34; 출력 Swift에서는 map/filter가 바로 Array를 반환한다(eager). Dart처럼 lazy로 쓰려면 .lazy.map { ... }으로 명시해야 한다. Dart는 기본이 lazy인 셈.\nsync* 제너레이터와 yield sync*로 Iterable을 직접 만들 수 있다. 값을 하나씩 yield로 내보내는 제너레이터 함수다.\n1 2 3 4 5 6 7 8 9 Iterable\u0026lt;int\u0026gt; range(int start, int end) sync* { for (int i = start; i \u0026lt;= end; i++) { yield i; } } for (var n in range(1, 5)) { print(n); // 1, 2, 3, 4, 5 } Swift에서 같은 걸 하려면 Sequence/IteratorProtocol을 직접 구현해야 하는데, Dart는 sync* + yield 두 키워드로 끝난다. 확실히 간결하다.\n1 2 3 4 5 6 7 8 9 10 // Swift: 같은 걸 하려면 이만큼 써야 함 struct Range: Sequence, IteratorProtocol { var current: Int let end: Int mutating func next() -\u0026gt; Int? { guard current \u0026lt;= end else { return nil } defer { current += 1 } return current } } yield* - 위임 yield*는 다른 Iterable이나 Stream의 모든 원소를 그대로 내보낸다.\n1 2 3 4 5 6 7 8 Iterable\u0026lt;int\u0026gt; countdown(int from) sync* { if (from \u0026gt;= 0) { yield from; yield* countdown(from - 1); // 재귀 위임 } } print(countdown(5).toList()); // [5, 4, 3, 2, 1, 0] yield* 없이 하려면 for (var v in countdown(from - 1)) yield v; 이렇게 루프를 돌려야 하는데, yield*가 이걸 한 줄로 해준다. 재귀적 구조를 다룰 때 편하다.\nsync* vs async* sync* async* 반환 타입 Iterable\u0026lt;T\u0026gt; Stream\u0026lt;T\u0026gt; 실행 동기 비동기 await 사용 불가 가능 소비 for-in await for 또는 listen sync*는 동기 제너레이터, async*는 비동기 제너레이터다. 둘 다 yield로 값을 내보내는 건 같은데, async*는 비동기 작업(네트워크, 타이머 등)을 사이에 끼울 수 있다.\n절차형 vs 함수형 같은 문제를 두 가지 방식으로 풀 수 있다.\n문제: 상품 목록에서 재고가 있는 것만 골라서 10% 할인 적용한 총액을 구하기.\n1 2 3 4 5 6 7 8 9 10 11 12 13 class Product { final String name; final int price; final bool inStock; const Product(this.name, this.price, this.inStock); } var products = [ Product(\u0026#39;노트북\u0026#39;, 1500000, true), Product(\u0026#39;마우스\u0026#39;, 35000, true), Product(\u0026#39;키보드\u0026#39;, 89000, false), Product(\u0026#39;모니터\u0026#39;, 450000, true), ]; 절차형 (명령형): 어떻게 할지를 단계별로 지시\n1 2 3 4 5 6 7 var total = 0; for (var p in products) { if (p.inStock) { total += (p.price * 0.9).round(); } } print(total); // 1786500 함수형 (선언형): 무엇을 할지를 선언\n1 2 3 4 5 var total = products .where((p) =\u0026gt; p.inStock) .map((p) =\u0026gt; (p.price * 0.9).round()) .reduce((sum, price) =\u0026gt; sum + price); print(total); // 1786500 같은 결과지만 접근 방식이 다르다:\n절차형 함수형 상태 변수를 만들고 값을 바꿈 데이터를 변환해서 새로 만듦 흐름 for, if로 직접 제어 where, map, reduce로 선언 부수 효과 있을 수 있음 최소화 가독성 한 줄씩 따라가야 함 의도가 한눈에 보임 Dart는 멀티 패러다임 언어라 둘 다 자유롭게 쓸 수 있다. Effective Dart에서는 변환 작업에는 함수형(map/where/reduce 체이닝), 부수 효과가 있는 작업에는 for-in 루프를 권장한다.\n1 2 3 4 5 6 7 8 // 변환 → 함수형 var names = users.map((u) =\u0026gt; u.name).toList(); // 부수 효과(출력, 상태 변경) → for-in for (var user in users) { print(user.name); sendNotification(user); } 메서드 체이닝 함수형 스타일의 장점은 체이닝으로 드러난다:\n1 2 3 4 5 6 7 8 // CSV 데이터 처리 var activeUsers = rawLines .skip(1) // 헤더 건너뛰기 .map((line) =\u0026gt; line.split(\u0026#39;,\u0026#39;)) // 필드 분리 .where((fields) =\u0026gt; fields.length \u0026gt;= 3) // 유효성 검사 .where((fields) =\u0026gt; fields[2] == \u0026#39;active\u0026#39;) // 활성 유저만 .map((fields) =\u0026gt; fields[0]) // 이름만 추출 .toList(); Dart에서 체이닝이 편한 이유가 where/map이 기본 lazy라서 중간에 불필요한 리스트를 만들지 않기 때문이다. Swift에서는 .lazy를 안 붙이면 각 단계마다 새 Array가 생긴다.\n캐스케이드 연산자 (..) Dart만의 문법인데, 같은 객체에 여러 메서드를 연속 호출할 때 쓴다. 메서드 체이닝이랑은 다른 개념이다:\n1 2 3 4 5 6 7 8 9 10 11 // 캐스케이드 없이 var paint = Paint(); paint.color = Colors.black; paint.strokeWidth = 5.0; paint.strokeCap = StrokeCap.round; // 캐스케이드로 축약 var paint = Paint() ..color = Colors.black ..strokeWidth = 5.0 ..strokeCap = StrokeCap.round; ..은 메서드의 반환값을 무시하고 원래 객체를 반환한다. 메서드 체이닝은 각 메서드가 새 값을 반환해야 하지만, 캐스케이드는 void 반환 메서드에도 쓸 수 있다. Swift에는 이런 문법이 없다.\n정리 Dart Swift 설명 (x) =\u0026gt; x * 2 { $0 * 2 } 익명 함수 void Function(int) (Int) -\u0026gt; Void 함수 타입 typedef typealias 함수 타입 별칭 where() filter() 필터링 expand() flatMap() 평탄화 fold() reduce(into:) 초기값 있는 축약 sync* + yield Sequence 프로토콜 구현 제너레이터 .. (cascade) 없음 같은 객체에 연속 호출 lazy 기본 .lazy 명시 필요 지연 평가 Swift에서 넘어오면서 제일 인상적이었던 건 sync*/async* 제너레이터 문법이다. Swift에서 Sequence 만들려면 struct에 IteratorProtocol 구현하고 next() 메서드 만들고\u0026hellip; 하는 게 Dart에서는 yield 한 줄이면 끝난다. 그리고 Iterable이 기본 lazy라는 것도 Swift와의 큰 차이점인데, 대량 데이터 처리할 때는 이게 유리하다.\n","date":"2026-04-27T00:00:00+09:00","permalink":"/p/ch03-3-dart-functional/","title":"Ch03-3. Dart 함수형 프로그래밍"},{"content":"Ch03 두 번째는 비동기 프로그래밍이다. Future와 Stream이 핵심인데, Swift의 async/await이랑 비교하면 이해가 빠르다.\nFuture - 미래의 값 Future\u0026lt;T\u0026gt;는 \u0026ldquo;아직 안 끝난 비동기 작업의 결과\u0026quot;를 나타낸다. Dart 공식 문서에 따르면 두 가지 상태가 있다:\nUncompleted: 작업 진행 중 Completed: 값 또는 에러로 완료 Swift에서는 async 함수가 값을 직접 반환하고 런타임이 내부적으로 관리하는데, Dart는 Future\u0026lt;T\u0026gt;라는 래퍼 객체를 명시적으로 반환한다. 타입 시그니처에 비동기 여부가 드러나는 셈이다.\n1 2 3 4 5 // Dart: 반환 타입이 Future\u0026lt;String\u0026gt; Future\u0026lt;String\u0026gt; fetchUserName() async { await Future.delayed(Duration(seconds: 1)); return \u0026#39;jHoon\u0026#39;; } 1 2 3 4 5 // Swift: 반환 타입이 그냥 String, async가 키워드로 붙음 func fetchUserName() async -\u0026gt; String { try await Task.sleep(nanoseconds: 1_000_000_000) return \u0026#34;jHoon\u0026#34; } async/await 사용법은 Swift랑 거의 똑같다. async 붙이고 await으로 기다리면 된다.\n1 2 3 4 5 6 7 8 9 10 11 12 13 void main() async { print(\u0026#39;주문 시작\u0026#39;); var order = await fetchOrder(); print(\u0026#39;주문 완료: $order\u0026#39;); } Future\u0026lt;String\u0026gt; fetchOrder() async { await Future.delayed(Duration(seconds: 2)); return \u0026#39;아이스 아메리카노\u0026#39;; } // 주문 시작 // (2초 후) // 주문 완료: 아이스 아메리카노 한 가지 차이: Dart에서 async 함수는 첫 번째 await을 만나기 전까지 동기적으로 실행된다. await 이전 코드는 바로 실행되고, await에서 비로소 비동기로 전환된다.\n에러 처리 1 2 3 4 5 6 7 8 9 10 11 12 13 // try-catch (권장) try { var data = await fetchData(); print(data); } catch (e) { print(\u0026#39;에러: $e\u0026#39;); } // then + catchError (콜백 스타일) fetchData() .then((data) =\u0026gt; print(data)) .catchError((e) =\u0026gt; print(\u0026#39;에러: $e\u0026#39;)) .whenComplete(() =\u0026gt; print(\u0026#39;완료\u0026#39;)); Swift의 do-catch/try await과 구조적으로 거의 같다. then/catchError 체이닝은 Swift의 옛날 completion handler 패턴이랑 비슷한 느낌인데, async/await이 훨씬 읽기 좋으니까 try-catch 쓰는 게 낫다.\n타임아웃 1 2 3 4 5 6 try { var result = await fetchData().timeout(Duration(seconds: 3)); print(result); } on TimeoutException { print(\u0026#39;3초 안에 응답이 없음\u0026#39;); } 순차 vs 병렬 실행 1 2 3 4 5 6 7 // 순차: 총 3초 (1+1+1) var a = await fetchA(); // 1초 var b = await fetchB(); // 1초 var c = await fetchC(); // 1초 // 병렬: 총 1초 (동시 실행, 가장 긴 것 기준) var results = await Future.wait([fetchA(), fetchB(), fetchC()]); Swift에서는 async let으로 병렬 실행하고 tuple로 받는 반면, Dart는 Future.wait으로 List에 담아서 받는다.\n1 2 3 4 // Swift 병렬 실행 async let a = fetchA() async let b = fetchB() let results = try await (a, b) // tuple 서로 의존성이 없는 API 호출은 Future.wait으로 묶으면 체감 속도가 확 빨라진다.\nStream - 연속된 비동기 데이터 Future가 한 번의 결과라면, Stream은 여러 번 값을 받을 수 있는 비동기 시퀀스다. Dart 공식 가이드에서 자세히 다루고 있다.\n채팅 메시지, 센서 데이터, 실시간 주가처럼 시간에 따라 계속 들어오는 데이터를 처리할 때 쓴다.\n만드는 방법 1: async* + yield 1 2 3 4 5 6 7 8 9 10 11 12 13 Stream\u0026lt;int\u0026gt; countDown(int from) async* { for (int i = from; i \u0026gt;= 0; i--) { await Future.delayed(Duration(seconds: 1)); yield i; // 값을 하나씩 내보냄 } } // 사용 void main() async { await for (var count in countDown(5)) { print(count); // 5, 4, 3, 2, 1, 0 (1초 간격) } } async*는 Stream을 생성하는 제너레이터 함수다. yield로 값을 하나씩 내보낸다. Swift의 AsyncStream + continuation 방식보다 훨씬 간결하다.\n1 2 3 4 5 6 7 8 9 10 11 12 // Swift: continuation 기반이라 좀 장황함 let stream = AsyncStream\u0026lt;Int\u0026gt; { continuation in for i in stride(from: 5, through: 0, by: -1) { try? await Task.sleep(nanoseconds: 1_000_000_000) continuation.yield(i) } continuation.finish() } for await count in stream { print(count) } 만드는 방법 2: StreamController 직접 이벤트를 push하고 싶을 때는 StreamController를 쓴다.\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 var controller = StreamController\u0026lt;String\u0026gt;(); // 구독 controller.stream.listen( (data) =\u0026gt; print(\u0026#39;받음: $data\u0026#39;), onError: (e) =\u0026gt; print(\u0026#39;에러: $e\u0026#39;), onDone: () =\u0026gt; print(\u0026#39;스트림 종료\u0026#39;), ); // 이벤트 push controller.add(\u0026#39;첫 번째\u0026#39;); controller.add(\u0026#39;두 번째\u0026#39;); controller.addError(Exception(\u0026#39;문제 발생\u0026#39;)); controller.add(\u0026#39;세 번째\u0026#39;); controller.close(); StreamController는 Flutter에서 위젯 간 데이터 전달이나 상태 변경 알림에도 많이 쓴다. StreamBuilder 위젯이랑 조합하면 실시간 UI 업데이트가 가능하다.\nStream 변환 Stream도 map, where 같은 변환 메서드를 지원한다:\n1 2 3 4 5 countDown(10) .where((n) =\u0026gt; n.isEven) // 짝수만 .map((n) =\u0026gt; \u0026#39;$n초 남았습니다\u0026#39;) // 문자열로 변환 .listen((msg) =\u0026gt; print(msg)); // 10초 남았습니다, 8초 남았습니다, ... BroadcastStream - 여러 리스너 기본 Stream은 단일 구독만 가능하다. 두 번 listen하면 에러가 난다. 여러 곳에서 동시에 구독하려면 BroadcastStream을 써야 한다.\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 // 방법 1: StreamController.broadcast() var controller = StreamController\u0026lt;int\u0026gt;.broadcast(); controller.stream.listen((data) =\u0026gt; print(\u0026#39;A: $data\u0026#39;)); controller.stream.listen((data) =\u0026gt; print(\u0026#39;B: $data\u0026#39;)); controller.add(1); // A: 1 // B: 1 // 방법 2: 기존 스트림을 변환 var broadcast = countDown(3).asBroadcastStream(); broadcast.listen((n) =\u0026gt; print(\u0026#39;위젯1: $n\u0026#39;)); broadcast.listen((n) =\u0026gt; print(\u0026#39;위젯2: $n\u0026#39;)); 주의할 점: broadcast 스트림은 리스너가 없을 때 발생한 이벤트는 그냥 버린다. 늦게 구독하면 이전 데이터를 못 받는다.\nSwift Combine으로 비교하면:\nDart Swift Combine 설명 StreamController() - 단일 구독 스트림 StreamController.broadcast() PassthroughSubject 다중 구독, 이전 값 안 줌 직접 구현 필요 CurrentValueSubject 최신 값 유지 + 다중 구독 yield* - 다른 스트림/이터러블 위임 yield*는 다른 스트림이나 이터러블의 모든 값을 그대로 내보낸다. 재귀적 스트림을 만들 때 유용하다.\n1 2 3 4 5 6 7 8 Stream\u0026lt;String\u0026gt; countStream(int max) async* { for (int i = 1; i \u0026lt;= max; i++) { await Future.delayed(Duration(seconds: 1)); yield \u0026#39;$i\u0026#39;; } yield \u0026#39;완료!\u0026#39;; yield* countStream(max); // 재귀: 다시 처음부터 } yield가 값 하나를 내보내는 거라면, yield*는 통째로 위임하는 거다. for 루프로 하나씩 yield하는 것보다 효율적이다.\nFuture vs Stream 정리 Future Stream 데이터 한 번 여러 번 사용 시점 API 호출, 파일 읽기 실시간 데이터, 이벤트 소비 await await for 또는 listen Swift 대응 async 함수 반환값 AsyncStream / Combine 생성 async 함수 async* + yield 또는 StreamController Swift에서 넘어오면서 느낀 건, Dart의 async* + yield가 Swift의 AsyncStream continuation 방식보다 확실히 간결하다는 거다. 스트림을 만드는 코드 자체가 직관적이라 좋았다. 반면 Future.wait vs async let 같은 병렬 처리는 Swift 쪽이 tuple로 받으니까 타입이 더 명확한 느낌이다.\n","date":"2026-04-26T00:00:00+09:00","permalink":"/p/ch03-2-dart-async/","title":"Ch03-2. Dart 비동기 - Future, Stream"},{"content":"Ch03부터는 Dart 문법을 좀 더 깊게 판다. 첫 번째는 컬렉션과 제네릭이다.\nList - Swift의 Array Dart의 List는 Swift의 Array와 거의 같다. 선언 방식도 비슷한데 몇 가지 차이가 있다.\n1 2 3 4 var scores = [95, 87, 72, 64, 91]; // List\u0026lt;int\u0026gt; 추론 var names = \u0026lt;String\u0026gt;[\u0026#39;Alice\u0026#39;, \u0026#39;Bob\u0026#39;]; // 타입 명시 var empty = \u0026lt;int\u0026gt;[]; // 빈 리스트 var generated = List.generate(5, (i) =\u0026gt; i * 2); // [0, 2, 4, 6, 8] Swift에서는 [Int]()나 Array(repeating:count:) 같은 걸 쓰는데, Dart는 List.generate로 초기값을 만들 수 있어서 좀 더 유연하다.\n주요 메서드 Swift에서 자주 쓰던 메서드들이 Dart에서는 이름이 다른 경우가 있다:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 var items = [1, 2, 3, 4, 5, 6, 7, 8]; // where == Swift의 filter var evens = items.where((n) =\u0026gt; n.isEven).toList(); // [2, 4, 6, 8] // map은 동일 var doubled = items.map((n) =\u0026gt; n * 2).toList(); // [2, 4, 6, ...] // expand == Swift의 flatMap var nested = [[1, 2], [3, 4], [5]]; var flat = nested.expand((list) =\u0026gt; list).toList(); // [1, 2, 3, 4, 5] // any == Swift의 contains(where:) var hasEven = items.any((n) =\u0026gt; n.isEven); // true // every == Swift의 allSatisfy var allPositive = items.every((n) =\u0026gt; n \u0026gt; 0); // true 여기서 중요한 게, map이나 where가 반환하는 건 List가 아니라 **Iterable**이다. 지연 평가(lazy)라서 .toList()를 호출해야 실제로 계산된다. Swift는 map/filter가 바로 Array를 반환하는 것과 다르다.\nDart Swift 설명 where() filter() 조건 필터링 expand() flatMap() 중첩 평탄화 any() contains(where:) 하나라도 만족? every() allSatisfy() 전부 만족? fold() reduce(into:) 초기값 있는 축약 take(n) prefix(n) 앞에서 n개 skip(n) dropFirst(n) 앞에서 n개 건너뛰기 reduce vs fold reduce는 첫 번째 원소를 초기값으로 쓰고, fold는 초기값을 직접 지정한다. 빈 리스트에서 reduce를 쓰면 에러가 나니까 fold가 더 안전하다.\n1 2 3 4 5 6 7 var prices = [1200, 3500, 890, 4200]; // reduce: 첫 원소가 초기값 → 빈 리스트면 에러 var total1 = prices.reduce((sum, p) =\u0026gt; sum + p); // 9790 // fold: 초기값 지정 → 빈 리스트도 안전 var total2 = prices.fold\u0026lt;int\u0026gt;(0, (sum, p) =\u0026gt; sum + p); // 9790 삽입, 교환, 정렬 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 var list = [1, 2, 3, 4, 5]; list.insert(1, 99); // [1, 99, 2, 3, 4, 5] list.removeAt(3); // 인덱스 3 제거 // 교환은 직접 구현해야 함 var temp = list[0]; list[0] = list[2]; list[2] = temp; // extension으로 만들면 편하다 extension ListSwap\u0026lt;T\u0026gt; on List\u0026lt;T\u0026gt; { void swap(int i, int j) { final temp = this[i]; this[i] = this[j]; this[j] = temp; } } list.swap(0, 2); // 깔끔 Swift에서는 swapAt()이 기본 제공되는데, Dart는 없어서 extension으로 만들어야 한다.\nSpread 연산자와 Collection if/for Dart만의 기능인데, 꽤 편하다.\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 var defaults = [\u0026#39;en\u0026#39;, \u0026#39;ko\u0026#39;]; var userLangs = [\u0026#39;ja\u0026#39;]; var allLangs = [...defaults, ...userLangs, \u0026#39;zh\u0026#39;]; // [\u0026#39;en\u0026#39;, \u0026#39;ko\u0026#39;, \u0026#39;ja\u0026#39;, \u0026#39;zh\u0026#39;] // null-aware spread List\u0026lt;String\u0026gt;? extras; var combined = [...defaults, ...?extras]; // null이면 무시 // collection if: 조건부 원소 추가 var isAdmin = true; var menu = [ \u0026#39;Home\u0026#39;, \u0026#39;Profile\u0026#39;, if (isAdmin) \u0026#39;Admin Panel\u0026#39;, ]; // collection for: 반복으로 원소 생성 var numbers = [1, 2, 3, 4, 5]; var labels = [ for (var n in numbers) if (n.isEven) \u0026#39;$n은 짝수\u0026#39;, ]; // [\u0026#39;2은 짝수\u0026#39;, \u0026#39;4은 짝수\u0026#39;] Swift에서는 이런 걸 하려면 filter + map을 체이닝하거나 따로 로직을 짜야 하는데, Dart는 리터럴 안에서 바로 된다. 처음 봤을 때 꽤 신기했음.\nMap - Swift의 Dictionary 1 2 3 4 5 6 7 8 var headers = { \u0026#39;Content-Type\u0026#39;: \u0026#39;application/json\u0026#39;, \u0026#39;Authorization\u0026#39;: \u0026#39;Bearer abc123\u0026#39;, }; // Map\u0026lt;String, String\u0026gt; // 접근 (nullable 반환 — Swift와 동일) var type = headers[\u0026#39;Content-Type\u0026#39;]; // String? headers[\u0026#39;Cache-Control\u0026#39;] = \u0026#39;no-cache\u0026#39;; // 추가/수정 Swift의 Dictionary와 거의 같은데, 유용한 메서드 몇 가지:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 var wordCount = \u0026lt;String, int\u0026gt;{}; var words = [\u0026#39;dart\u0026#39;, \u0026#39;flutter\u0026#39;, \u0026#39;dart\u0026#39;, \u0026#39;widget\u0026#39;, \u0026#39;dart\u0026#39;]; // update: 있으면 업데이트, 없으면 생성 for (var word in words) { wordCount.update(word, (count) =\u0026gt; count + 1, ifAbsent: () =\u0026gt; 1); } // {\u0026#39;dart\u0026#39;: 3, \u0026#39;flutter\u0026#39;: 1, \u0026#39;widget\u0026#39;: 1} // putIfAbsent: 없을 때만 생성 var cache = \u0026lt;String, List\u0026lt;String\u0026gt;\u0026gt;{}; cache.putIfAbsent(\u0026#39;users\u0026#39;, () =\u0026gt; []).add(\u0026#39;Alice\u0026#39;); cache.putIfAbsent(\u0026#39;users\u0026#39;, () =\u0026gt; []).add(\u0026#39;Bob\u0026#39;); // {\u0026#39;users\u0026#39;: [\u0026#39;Alice\u0026#39;, \u0026#39;Bob\u0026#39;]} // containsKey if (wordCount.containsKey(\u0026#39;dart\u0026#39;)) { print(\u0026#39;dart가 ${wordCount[\u0026#39;dart\u0026#39;]}번 나옴\u0026#39;); } Swift에서는 cache[\u0026quot;users\u0026quot;, default: []].append(\u0026quot;Alice\u0026quot;) 이런 식으로 default subscript를 쓰는데, Dart는 putIfAbsent로 처리한다.\nSet - 중복 없는 컬렉션 1 2 3 4 5 6 7 8 9 var fruits = {\u0026#39;apple\u0026#39;, \u0026#39;banana\u0026#39;, \u0026#39;apple\u0026#39;, \u0026#39;grape\u0026#39;}; // {\u0026#39;apple\u0026#39;, \u0026#39;banana\u0026#39;, \u0026#39;grape\u0026#39;} — 중복 제거 fruits.add(\u0026#39;mango\u0026#39;); fruits.contains(\u0026#39;apple\u0026#39;); // true // List에서 중복 제거할 때 유용 var list = [1, 2, 2, 3, 3, 3]; var unique = list.toSet().toList(); // [1, 2, 3] Swift의 Set과 동일하다. 교집합, 합집합도 intersection(), union()으로 가능하다.\n제네릭 Swift의 제네릭과 문법이 거의 같다. \u0026lt;T\u0026gt; 대신 제약을 걸 때 Swift는 \u0026lt;T: Protocol\u0026gt;이고 Dart는 \u0026lt;T extends Class\u0026gt; 이 차이 정도.\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 // Dart 3의 sealed class로 제네릭 Result 패턴 sealed class Result\u0026lt;T\u0026gt; { const Result(); } class Success\u0026lt;T\u0026gt; extends Result\u0026lt;T\u0026gt; { final T data; const Success(this.data); } class Failure\u0026lt;T\u0026gt; extends Result\u0026lt;T\u0026gt; { final String message; const Failure(this.message); } // 사용 Result\u0026lt;int\u0026gt; fetchScore() { var ok = true; return ok ? Success(95) : Failure(\u0026#39;서버 에러\u0026#39;); } // switch로 분기 — sealed라서 빠뜨리면 컴파일 경고 var result = fetchScore(); switch (result) { case Success(:final data): print(\u0026#39;점수: $data\u0026#39;); case Failure(:final message): print(\u0026#39;실패: $message\u0026#39;); } Swift의 enum+associated value 패턴이랑 비슷한데, Dart는 sealed class + 서브클래스로 구현한다. Dart 3 이전에는 data = null as T 같은 꼼수를 썼는데, null safety에서 터지니까 이제는 sealed가 정석이다.\n타입 제약 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 // T는 Comparable을 구현해야 함 class SortedList\u0026lt;T extends Comparable\u0026lt;T\u0026gt;\u0026gt; { final _items = \u0026lt;T\u0026gt;[]; void add(T item) { _items.add(item); _items.sort(); } T get first =\u0026gt; _items.first; T get last =\u0026gt; _items.last; } var sorted = SortedList\u0026lt;int\u0026gt;(); sorted.add(42); sorted.add(7); sorted.add(23); print(sorted.first); // 7 Swift에서 \u0026lt;T: Comparable\u0026gt; 쓰는 것과 같은 패턴이다. 키워드만 extends로 다르다.\nAbstract Class - Swift의 Protocol Dart에는 protocol 키워드가 없다. 대신 abstract class가 그 역할을 한다. 그리고 Dart의 모든 클래스는 암묵적 인터페이스를 정의한다.\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 // abstract class = Swift의 protocol + default implementation abstract class Animal { String get name; // 추상 getter (구현 강제) void eat(); // 추상 메서드 (구현 강제) void breathe() { // 기본 구현 (Swift의 protocol extension과 비슷) print(\u0026#39;$name이(가) 숨을 쉰다\u0026#39;); } } class Dog extends Animal { @override String get name =\u0026gt; \u0026#39;강아지\u0026#39;; @override void eat() =\u0026gt; print(\u0026#39;$name이(가) 사료를 먹는다\u0026#39;); } class Cat extends Animal { @override String get name =\u0026gt; \u0026#39;고양이\u0026#39;; @override void eat() =\u0026gt; print(\u0026#39;$name이(가) 참치를 먹는다\u0026#39;); } extends vs implements 1 2 3 4 5 6 7 8 9 10 11 12 13 14 // extends: 구현을 상속받음 (단일 상속) class Dog extends Animal { ... } // implements: 인터페이스만 가져옴 (모든 걸 직접 구현해야 함, 다중 가능) class Robot implements Animal { @override String get name =\u0026gt; \u0026#39;로봇\u0026#39;; @override void eat() {} // 전부 직접 구현 @override void breathe() {} // 기본 구현도 안 물려받음 } Swift에서는 protocol 채택하면 required만 구현하면 되는데, Dart의 implements는 모든 멤버를 직접 구현해야 한다. 기본 구현을 물려받고 싶으면 extends를 써야 한다.\nDart Swift 설명 abstract class protocol 추상 타입 정의 extends : (class 상속) 구현 상속, 단일만 가능 implements : (protocol 채택) 인터페이스만, 다중 가능 mixin + with protocol extension 수평적 코드 재사용 정리 Swift에서 넘어오면서 제일 헷갈렸던 건 where/expand 같은 메서드 이름 차이랑, map/where가 lazy Iterable을 반환한다는 점이다. .toList() 안 붙여서 삽질한 적이 있다. 반면 spread 연산자랑 collection if/for는 Swift에 없는 기능인데 익숙해지면 꽤 편하다.\n","date":"2026-04-25T00:00:00+09:00","permalink":"/p/ch03-1-dart-collections/","title":"Ch03-1. Dart 컬렉션과 제네릭"},{"content":"Ch01에서 기본기를 다졌으니 이번엔 토스 앱을 클론하면서 실전 UI를 만들어봤다. 멀티탭 네비게이션, 상태관리, 테마 시스템처럼 실제 앱에서 꼭 필요한 것들 위주로 정리한다.\n멀티탭 네비게이션 - IndexedStack 토스처럼 하단 5개 탭을 만들 때 가장 먼저 고민되는 게 \u0026ldquo;탭을 전환할 때 이전 탭의 상태를 유지할 것인가\u0026quot;다. SwiftUI에서는 TabView 안에 각각 NavigationStack을 넣으면 알아서 상태가 유지되는데, Flutter는 직접 해줘야 한다.\n여기서 쓰는 게 IndexedStack이다. Flutter 공식 문서에 따르면, Stack의 서브클래스로 index에 해당하는 자식만 화면에 그리되 나머지 자식도 메모리에 유지한다. 즉 탭을 왔다갔다 해도 스크롤 위치나 입력 값이 그대로 남아있다.\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 class MainScreen extends StatefulWidget { @override State\u0026lt;MainScreen\u0026gt; createState() =\u0026gt; _MainScreenState(); } class _MainScreenState extends State\u0026lt;MainScreen\u0026gt; { int _tabIndex = 0; final _pages = [HomePage(), StockPage(), BenefitPage()]; @override Widget build(BuildContext context) { return Scaffold( body: IndexedStack( index: _tabIndex, children: _pages, ), bottomNavigationBar: BottomNavigationBar( currentIndex: _tabIndex, onTap: (i) =\u0026gt; setState(() =\u0026gt; _tabIndex = i), type: BottomNavigationBarType.fixed, items: const [ BottomNavigationBarItem(icon: Icon(Icons.home), label: \u0026#39;홈\u0026#39;), BottomNavigationBarItem(icon: Icon(Icons.candlestick_chart), label: \u0026#39;주식\u0026#39;), BottomNavigationBarItem(icon: Icon(Icons.star), label: \u0026#39;혜택\u0026#39;), ], ), ); } } PageView랑 비교하면 차이가 명확하다:\nIndexedStack PageView 상태 유지 자동 (전부 메모리 유지) AutomaticKeepAliveClientMixin 필요 전환 애니메이션 없음 (즉시 전환) 스와이프 애니메이션 지연 로딩 안 됨 (전부 빌드) 됨 (보이는 것만 빌드) 탭이 3~5개 정도면 IndexedStack으로 충분한데, 탭 수가 많아지면 메모리를 잡아먹으니까 주의해야 한다.\n탭마다 독립 네비게이션 스택 토스 앱에서 주식 탭에서 종목 상세 화면으로 들어갔다가 홈 탭으로 전환하고, 다시 주식 탭으로 돌아오면 종목 상세가 그대로 남아있다. 이걸 구현하려면 탭마다 별도의 Navigator를 줘야 한다.\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 // 각 탭에 독립 Navigator를 부여 class TabNavigator extends StatelessWidget { final GlobalKey\u0026lt;NavigatorState\u0026gt; navigatorKey; final Widget rootPage; const TabNavigator({required this.navigatorKey, required this.rootPage}); @override Widget build(BuildContext context) { return Navigator( key: navigatorKey, onGenerateRoute: (_) =\u0026gt; MaterialPageRoute(builder: (_) =\u0026gt; rootPage), ); } } GlobalKey\u0026lt;NavigatorState\u0026gt;로 각 탭의 네비게이터에 접근할 수 있어서, 뒤로가기 버튼을 누르면 현재 탭 내부에서만 pop이 일어난다. SwiftUI의 TabView 안에 각각 NavigationStack을 넣는 것과 같은 구조인데, Flutter는 Navigator를 직접 배치하는 거라 좀 더 수작업이 많다.\n뒤로가기 처리도 PopScope로 직접 해야 한다. Flutter는 Android 14의 Predictive Back 제스처를 지원하기 위해 WillPopScope를 deprecated 시키고 PopScope로 교체했다. canPop으로 pop 가능 여부를 미리 선언하고, onPopInvokedWithResult에서 실제 처리를 한다.\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 PopScope( canPop: false, onPopInvokedWithResult: (didPop, _) { if (didPop) return; // 현재 탭에서 pop 가능하면 탭 내부 pop if (navigatorKeys[_tabIndex].currentState?.canPop() == true) { navigatorKeys[_tabIndex].currentState!.pop(); return; } // 홈 탭이 아니면 홈으로 이동 if (_tabIndex != 0) { setState(() =\u0026gt; _tabIndex = 0); } }, child: // ... ) SwiftUI에서는 이런 뒤로가기 분기 처리를 할 일이 거의 없는데, Flutter에서는 Android 하드웨어 백 버튼 때문에 필수다.\nCustomScrollView와 Sliver 주식 탭처럼 스크롤하면 AppBar가 접히는 효과를 만들려면 CustomScrollView + Sliver를 써야 한다.\nSliver는 Flutter에서 스크롤 가능한 영역의 조각을 뜻한다. 일반 위젯이 BoxConstraints로 레이아웃하는 것과 달리, Sliver는 SliverConstraints라는 별도 프로토콜을 써서 스크롤 위치에 따라 동적으로 크기와 위치를 결정한다. Flutter 공식 가이드에서 자세히 설명하고 있다.\n왜 ListView로는 안 되냐면, ListView는 자체적으로 하나의 스크롤 컨텍스트를 만든다. 접히는 AppBar + 리스트 + 그리드를 하나의 스크롤로 묶으려면 같은 스크롤 컨텍스트를 공유해야 하는데, 그게 CustomScrollView다.\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 CustomScrollView( slivers: [ SliverAppBar( pinned: true, // 스크롤해도 AppBar 고정 expandedHeight: 120, flexibleSpace: FlexibleSpaceBar( title: Text(\u0026#39;주식\u0026#39;), ), actions: [ IconButton(icon: Icon(Icons.search), onPressed: () {}), IconButton(icon: Icon(Icons.settings), onPressed: () {}), ], ), SliverToBoxAdapter( child: Padding( padding: EdgeInsets.all(16), child: Text(\u0026#39;S\u0026amp;P 500 3,919.29\u0026#39;, style: TextStyle(fontSize: 24)), ), ), SliverList( delegate: SliverChildBuilderDelegate( (context, index) =\u0026gt; ListTile( title: Text(stockNames[index]), trailing: Text(stockPrices[index]), ), childCount: stockNames.length, ), ), ], ) SwiftUI에서는 ScrollView 안에 .toolbar를 넣으면 접히는 효과가 자동으로 되는데, Flutter는 Sliver 조합으로 직접 구성해야 한다. 대신 세밀한 제어가 가능하다.\nSliverAppBar의 옵션을 정리하면:\n속성 동작 pinned: true 스크롤해도 AppBar가 상단에 고정 floating: true 위로 스크롤하면 AppBar가 바로 나타남 snap: true floating과 함께 쓰면 중간 상태 없이 딱 열리거나 닫힘 expandedHeight 완전히 펼쳤을 때 높이 SliverToBoxAdapter는 일반 위젯을 Sliver 안에 넣을 때 쓰는 어댑터다. Sliver 프로토콜을 모르는 일반 위젯(Text, Container 등)을 감싸서 CustomScrollView에 넣을 수 있게 해준다.\nTabBar + CustomScrollView 조합 주식 탭 안에 \u0026ldquo;내 주식\u0026rdquo; / \u0026ldquo;오늘의 발견\u0026rdquo; 같은 하위 탭을 넣을 때 주의할 점이 있다. 보통 TabBar + TabBarView를 쓰는데, TabBarView는 내부적으로 PageView를 사용하기 때문에 CustomScrollView 안에서 높이 계산 문제가 생긴다.\n그래서 TabBarView 대신 currentIndex로 직접 전환하는 방식을 썼다:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 class _StockPageState extends State\u0026lt;StockPage\u0026gt; with SingleTickerProviderStateMixin { late TabController _tabController; @override void initState() { super.initState(); _tabController = TabController(length: 2, vsync: this); _tabController.addListener(() =\u0026gt; setState(() {})); } @override void dispose() { _tabController.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return CustomScrollView( slivers: [ SliverAppBar(pinned: true, title: Text(\u0026#39;주식\u0026#39;)), SliverToBoxAdapter( child: TabBar( controller: _tabController, tabs: [Tab(text: \u0026#39;내 주식\u0026#39;), Tab(text: \u0026#39;오늘의 발견\u0026#39;)], ), ), SliverToBoxAdapter( child: _tabController.index == 0 ? MyStockList() : TodayDiscoveryList(), ), ], ); } } TabController의 addListener로 탭 변경을 감지하고 setState로 리빌드한다. dispose에서 컨트롤러 정리하는 것도 잊으면 안 된다.\nGetX 상태관리 검색 자동완성 기능에서 GetX를 써봤다. GetX는 상태관리 + 의존성 주입 + 라우팅을 한 패키지로 제공하는데, 여기서는 상태관리와 DI만 사용했다.\n반응형 상태 - .obs와 Obx GetX의 반응형 시스템은 .obs로 시작한다. 변수 뒤에 .obs를 붙이면 값 변경을 추적하는 반응형 타입(Rx\u0026lt;T\u0026gt;)이 된다. Provider의 ChangeNotifier나 BLoC의 Stream과 달리 GetX는 자체 반응형 시스템을 쓴다.\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 class SearchController extends GetxController { final results = \u0026lt;String\u0026gt;[].obs; // RxList\u0026lt;String\u0026gt; final query = \u0026#39;\u0026#39;.obs; // RxString @override void onInit() { super.onInit(); // debounce: 입력 멈추고 300ms 후에 검색 실행 debounce(query, (_) =\u0026gt; _doSearch(), time: 300.ms); } void updateQuery(String text) =\u0026gt; query.value = text; void _doSearch() { if (query.value.isEmpty) { results.clear(); return; } results.value = allItems .where((item) =\u0026gt; item.contains(query.value)) .toList(); } } Obx로 감싸면 내부에서 사용하는 .obs 변수가 바뀔 때만 해당 위젯이 리빌드된다. setState처럼 전체를 다시 그리는 게 아니라 Obx 블록만 갱신하니까 효율적이다.\n1 2 3 4 Obx(() =\u0026gt; ListView.builder( itemCount: controller.results.length, itemBuilder: (_, i) =\u0026gt; ListTile(title: Text(controller.results[i])), )) 하나 편한 점은, 같은 값으로 세팅하면 리빌드가 안 일어난다. 내부적으로 이전 값과 비교해서 실제로 바뀔 때만 UI를 갱신하는 거다.\n의존성 주입 - Get.put과 Get.find GetX의 DI는 전역 컨테이너 방식이다. Get.put()으로 등록하고 Get.find()로 어디서든 꺼내 쓴다.\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 // 화면 진입 시 등록 class _SearchScreenState extends State\u0026lt;SearchScreen\u0026gt; { @override void initState() { super.initState(); Get.put(SearchController()); } @override void dispose() { Get.delete\u0026lt;SearchController\u0026gt;(); super.dispose(); } } // 자식 위젯에서 사용 class ResultListView extends StatelessWidget { final controller = Get.find\u0026lt;SearchController\u0026gt;(); @override Widget build(BuildContext context) { return Obx(() =\u0026gt; ListView.builder( itemCount: controller.results.length, itemBuilder: (_, i) =\u0026gt; ListTile(title: Text(controller.results[i])), )); } } SwiftUI 비교로 정리하면:\nGetX SwiftUI 역할 GetxController ObservableObject 상태 클래스 .obs @Published 변경 감지 Obx(() =\u0026gt;) 자동 UI 리빌드 Get.put() .environmentObject() 등록 Get.find() @EnvironmentObject 접근 차이는 @EnvironmentObject가 위젯 트리 안에서만 접근 가능한 반면, Get.find()는 BuildContext 없이 어디서든 전역 접근이 된다는 것이다. 편하긴 한데 남용하면 의존성 추적이 어려워진다.\nMixin으로 접근 축약 여러 위젯에서 같은 컨트롤러를 Get.find()로 가져오는 게 반복되면 mixin으로 묶을 수 있다:\n1 2 3 4 5 6 7 8 9 10 11 12 13 mixin SearchDataProvider { SearchController get searchData =\u0026gt; Get.find\u0026lt;SearchController\u0026gt;(); } class AutoCompleteList extends StatelessWidget with SearchDataProvider { @override Widget build(BuildContext context) { return Obx(() =\u0026gt; ListView.builder( itemCount: searchData.results.length, itemBuilder: (_, i) =\u0026gt; Text(searchData.results[i]), )); } } GetX에는 GetView\u0026lt;T\u0026gt;라는 내장 클래스도 있어서, 상속하면 controller로 바로 접근할 수 있다. 다만 하나의 컨트롤러 타입만 바인딩되니까 여러 컨트롤러가 필요하면 mixin이 낫다.\nGetX에 대한 솔직한 생각 GetX가 편한 건 맞는데, 커뮤니티에서 논쟁이 좀 있다. 메인테이너가 한 명이라 업데이트가 느리고, 전역 상태 접근이 너무 쉬워서 코드가 커지면 의존성 파악이 어려워진다는 비판이 있다. 요즘은 새 프로젝트에서 Riverpod을 많이 쓰는 추세인 것 같다. 나도 다음 프로젝트에서는 Riverpod을 써볼 예정이다.\n다크모드 - InheritedWidget 기반 테마 Flutter 기본 ThemeData만으로도 다크모드를 할 수 있지만, 토스 앱처럼 커스텀 컬러가 많으면 InheritedWidget로 직접 테마 시스템을 만드는 게 편하다.\nInheritedWidget은 위젯 트리를 통해 데이터를 하위로 전파하는 메커니즘이다. Flutter 공식 문서에 따르면, 내부적으로 각 Element에 InheritedWidget 해시 테이블을 유지해서 O(1)로 조회가 가능하다. Provider 패키지도 사실 이걸 감싼 래퍼다.\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 // 1. 색상 추상 클래스 abstract class AppColors { Color get background; Color get text; Color get card; Color get accent; } class LightColors implements AppColors { Color get background =\u0026gt; Colors.white; Color get text =\u0026gt; Colors.black87; Color get card =\u0026gt; Colors.grey.shade100; Color get accent =\u0026gt; Color(0xFF3182F6); // 토스 블루 } class DarkColors implements AppColors { Color get background =\u0026gt; Color(0xFF1B1B1E); Color get text =\u0026gt; Colors.white; Color get card =\u0026gt; Color(0xFF2C2C2E); Color get accent =\u0026gt; Color(0xFF5B9BF5); } 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 // 2. InheritedWidget으로 트리에 전파 class ThemeHolder extends InheritedWidget { final AppColors appColors; final VoidCallback onToggle; const ThemeHolder({ required this.appColors, required this.onToggle, required super.child, }); static ThemeHolder of(BuildContext context) { return context.dependOnInheritedWidgetOfExactType\u0026lt;ThemeHolder\u0026gt;()!; } @override bool updateShouldNotify(ThemeHolder old) =\u0026gt; appColors != old.appColors; } 1 2 3 4 5 6 7 8 9 // 3. extension으로 축약 extension ThemeContext on BuildContext { AppColors get appColors =\u0026gt; ThemeHolder.of(this).appColors; VoidCallback get toggleTheme =\u0026gt; ThemeHolder.of(this).onToggle; } // 사용 Container(color: context.appColors.background) Text(\u0026#39;제목\u0026#39;, style: TextStyle(color: context.appColors.text)) Theme.of(context).colorScheme.primary 이렇게 길게 쳐야 하는 걸 context.appColors.accent로 줄인 거다. SwiftUI의 @Environment(\\.colorScheme)과 비슷한 역할이지만, Flutter는 InheritedWidget + extension 조합으로 직접 만드는 거라 초기 작업이 좀 필요하다.\n테마 전환은 상위 StatefulWidget에서 setState로 InheritedWidget을 리빌드하면 된다. updateShouldNotify가 true를 반환하면 dependOnInheritedWidgetOfExactType을 호출한 모든 하위 위젯이 자동으로 리빌드된다.\n애니메이션 - flutter_animate Flutter 기본 애니메이션은 AnimationController + StatefulWidget + TickerProviderStateMixin + dispose까지 써야 해서 보일러플레이트가 상당하다. flutter_animate 패키지를 쓰면 한 줄로 끝난다.\n1 2 3 4 5 6 7 // flutter_animate - 체이닝 API Column( children: [ Text(\u0026#39;토스뱅크\u0026#39;).animate().fadeIn(duration: 600.ms).slideY(begin: 0.3), BankAccountCard().animate().fadeIn(delay: 200.ms).slideX(begin: -0.1), ], ) 기본 AnimationController 방식이랑 비교하면:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 // 기본 방식 - 이만큼 써야 한다 class _MyWidgetState extends State\u0026lt;MyWidget\u0026gt; with SingleTickerProviderStateMixin { late AnimationController _controller; late Animation\u0026lt;double\u0026gt; _fadeAnim; @override void initState() { super.initState(); _controller = AnimationController(vsync: this, duration: 600.ms); _fadeAnim = Tween(begin: 0.0, end: 1.0).animate(_controller); _controller.forward(); } @override void dispose() { _controller.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return FadeTransition(opacity: _fadeAnim, child: Text(\u0026#39;토스뱅크\u0026#39;)); } } flutter_animate는 이 모든 걸 .animate().fadeIn() 한 줄로 처리한다. 효과를 체이닝하면 기본적으로 동시에 실행되고, .then()을 쓰면 순차 실행으로 바꿀 수 있다.\n1 2 3 4 5 6 7 8 // 동시 실행: fade + slide가 같이 widget.animate().fadeIn().slide() // 순차 실행: fade 끝나고 200ms 뒤에 slide widget.animate() .fadeIn(duration: 500.ms) .then(delay: 200.ms) .slide() SwiftUI의 .transition(.opacity), .animation(.easeInOut) 같은 선언적 방식과 비슷한데, 체이닝이 가능해서 복합 애니메이션 조합이 더 유연한 편이다.\n자주 만나는 레이아웃 에러들 Flutter 하면서 이것저것 삽질한 것들을 정리해둔다.\n1. Row/Column 안에서 Unbounded 에러\n1 2 3 4 5 // 에러: Row 안의 TextField가 너비를 못 잡음 Row(children: [TextField()]) // 해결: Expanded로 남은 공간 차지하게 Row(children: [Expanded(child: TextField())]) Row는 자식에게 무한 너비를 허용하는데 TextField는 가능한 너비를 전부 쓰려고 해서 충돌이 난다.\n2. 스크롤 안에 스크롤 - 높이 충돌\n1 2 3 4 5 6 7 8 9 10 // 에러: SingleChildScrollView 안에 ListView가 무한 높이 요구 SingleChildScrollView( child: Column(children: [ListView.builder(...)]), ) // 해결: shrinkWrap + 스크롤 비활성화 ListView.builder( shrinkWrap: true, // 콘텐츠 높이만큼만 physics: NeverScrollableScrollPhysics(), // 스크롤은 부모가 ) Ch01 인스타 클론에서도 나온 패턴인데, shrinkWrap은 성능에 영향을 주니까 아이템이 많으면 CustomScrollView + SliverList를 쓰는 게 맞다.\n3. GestureDetector 빈 영역 터치 안 됨\n1 2 3 4 5 6 7 8 9 10 11 12 // 안 됨: Spacer 영역은 터치 이벤트가 안 먹힘 GestureDetector( onTap: () {}, child: Row(children: [Text(\u0026#39;메뉴\u0026#39;), Spacer(), Icon(Icons.arrow_right)]), ) // 해결: behavior 설정 GestureDetector( behavior: HitTestBehavior.opaque, // 투명 영역도 터치 감지 onTap: () {}, child: Row(children: [Text(\u0026#39;메뉴\u0026#39;), Spacer(), Icon(Icons.arrow_right)]), ) 기본적으로 GestureDetector는 자식이 그려진 영역만 터치를 감지한다. HitTestBehavior.opaque를 주면 빈 공간도 터치 영역에 포함된다.\ndispose 잊으면 메모리 누수 initState에서 만든 리소스는 dispose에서 반드시 정리해야 한다. 안 하면 메모리 누수가 생긴다.\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 @override void initState() { super.initState(); _scrollController = ScrollController(); _textController = TextEditingController(); _tabController = TabController(length: 3, vsync: this); } @override void dispose() { _scrollController.dispose(); _textController.dispose(); _tabController.dispose(); super.dispose(); } 정리 대상: TextEditingController, ScrollController, AnimationController, TabController, Timer, StreamSubscription. 규칙은 간단하다 - 내가 만든 건 내가 정리한다.\nSwiftUI에서는 @StateObject가 알아서 해주는 부분인데, Flutter는 수동이다. 번거롭지만 정확히 뭘 정리하는지 보이니까 디버깅할 때는 오히려 낫다.\n정리 주제 SwiftUI Flutter 멀티탭 TabView + NavigationStack IndexedStack + GlobalKey\u0026lt;NavigatorState\u0026gt; 뒤로가기 제어 기본 제공 PopScope (canPop + onPopInvokedWithResult) 접히는 헤더 ScrollView + .toolbar CustomScrollView + SliverAppBar 리스트 in 스크롤 List + ScrollView SliverList or shrinkWrap 상태관리 @State, @Published setState, GetX (.obs + Obx) 의존성 주입 @EnvironmentObject Get.put() / Get.find() 다크모드 @Environment(.colorScheme) InheritedWidget + context extension 생명주기 정리 @StateObject 자동 dispose에서 수동 정리 애니메이션 .transition + .animation flutter_animate or AnimationController 전체적으로 SwiftUI 대비 보일러플레이트가 확실히 많다. 특히 네비게이션 직접 관리하는 부분이랑 dispose 수동 정리가 번거로운데, 그만큼 코드에 마법이 없어서 흐름 파악은 더 쉬운 것 같다. GetX를 쓰면 StatelessWidget만으로도 거의 다 되니까 익숙해지면 개발 속도가 꽤 빨라진다.\n다음 Ch03에서는 Dart 문법을 좀 더 깊게 파볼 예정이다.\n","date":"2026-04-23T00:00:00+09:00","permalink":"/p/ch02-toss-clone/","title":"Ch02. 토스 앱 클론 - 실전 UI와 상태관리"},{"content":"iOS 개발하다가 Flutter 시작한 지 얼마 안 됐는데, 처음부터 정리해보려고 한다. Ch01은 기본 위젯부터 시작해서 최종적으로 인스타그램 클론 UI까지 만드는 과정이다.\nContainer - 가장 기본적인 박스 SwiftUI에서 .background, .border, .shadow 이런 modifier 조합으로 스타일링하는 것과 비슷하게, Flutter에서는 Container + BoxDecoration으로 처리한다.\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 Container( width: 300, height: 300, decoration: BoxDecoration( color: Colors.pink.shade50, border: Border.all(color: Colors.red, width: 5), borderRadius: BorderRadius.circular(10), boxShadow: [ BoxShadow(color: Colors.black, offset: Offset(6, 6)) ], ), child: Center( child: Container( color: Colors.yellow, padding: EdgeInsets.symmetric(horizontal: 20), margin: EdgeInsets.symmetric(horizontal: 10), child: Text(\u0026#39;Hello Container\u0026#39;), ), ), ) SwiftUI에서는 padding이 modifier 체이닝 순서에 따라 안/밖이 달라지는데, Flutter는 padding과 margin이 명확히 분리돼 있어서 오히려 직관적인 편이다.\n레이아웃 - Column, Row, Flexible SwiftUI의 VStack, HStack이 Flutter에서는 Column, Row다. 거의 1:1 대응이라 어색하지 않았다.\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Row( mainAxisAlignment: MainAxisAlignment.center, children: [ Container(width: 100, height: 80, color: Colors.red), Container(width: 100, height: 80, color: Colors.blue), Container(width: 100, height: 80, color: Colors.green), ], ), Container(width: 300, height: 90, color: Colors.grey), ], ) 비율 배치는 Flexible의 flex 속성으로 한다. SwiftUI의 .frame(maxHeight: .infinity) 같은 것보다 깔끔함.\n1 2 3 4 5 6 7 8 Column( children: [ Flexible(flex: 1, child: Container(color: Colors.red)), Flexible(flex: 2, child: Container(color: Colors.blue)), Flexible(flex: 3, child: Container(color: Colors.green)), Flexible(flex: 4, child: Container(color: Colors.yellow)), ], ) 1:2:3:4 비율로 화면을 나눠줌. Expanded는 Flexible의 fit: FlexFit.tight 버전이라 남은 공간을 무조건 채운다.\nStack - 위젯 겹치기 SwiftUI의 ZStack이 Flutter에서는 Stack이다. 자식 위젯의 위치는 Align이나 Positioned로 잡는다.\n1 2 3 4 5 6 7 8 9 10 11 Stack( children: [ Container(width: 500, height: 500, color: Colors.black), Container(width: 400, height: 400, color: Colors.red), Container(width: 300, height: 300, color: Colors.blue), Align( alignment: Alignment.topRight, child: Container(width: 200, height: 200, color: Colors.green), ), ], ) Positioned는 top, left, right, bottom 값으로 절대 위치를 지정하고, Align은 상대 위치를 지정하는 차이가 있다.\nStatelessWidget vs StatefulWidget SwiftUI에서는 @State로 상태를 선언하면 끝인데, Flutter는 StatelessWidget과 StatefulWidget이 명확히 나뉜다.\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 // 상태 없는 위젯 - 한 번 그리면 끝 class ExampleStateless extends StatelessWidget { @override Widget build(BuildContext context) { return Container(color: Colors.red); } } // 상태 있는 위젯 - setState로 업데이트 class ExampleStateful extends StatefulWidget { final int index; const ExampleStateful({required this.index}); @override State\u0026lt;ExampleStateful\u0026gt; createState() =\u0026gt; _ExampleStatefulState(); } class _ExampleStatefulState extends State\u0026lt;ExampleStateful\u0026gt; { late int _index; @override void initState() { super.initState(); _index = widget.index; } @override Widget build(BuildContext context) { return GestureDetector( onTap: () { setState(() { _index = _index == 5 ? 0 : _index + 1; }); }, child: Container( color: Colors.blue, child: Center(child: Text(\u0026#39;index: $_index\u0026#39;)), ), ); } } 처음에 setState 쓰는 게 좀 번거로웠는데, SwiftUI의 @State가 내부적으로 해주는 걸 Flutter는 직접 명시하는 거라고 생각하니까 이해됐다. initState는 SwiftUI의 .onAppear, dispose는 .onDisappear랑 비슷한 라이프사이클이다.\n입력 위젯들 Checkbox, Radio, Slider, Switch 같은 기본 입력 위젯들도 전부 StatefulWidget + setState 패턴이다.\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 // Slider 예제 class TestSlider extends StatefulWidget { @override State\u0026lt;TestSlider\u0026gt; createState() =\u0026gt; _TestSliderState(); } class _TestSliderState extends State\u0026lt;TestSlider\u0026gt; { double value = 0; @override Widget build(BuildContext context) { return Column( children: [ Text(\u0026#39;$value\u0026#39;), Slider( value: value, onChanged: (newValue) =\u0026gt; setState(() =\u0026gt; value = newValue), divisions: 100, activeColor: Colors.red, ), ], ); } } iOS 스타일을 쓰고 싶으면 CupertinoSwitch, CupertinoContextMenu 같은 Cupertino 위젯도 있다. Material이랑 Cupertino를 섞어서 쓸 수 있는 게 Flutter의 장점인듯.\n네비게이션 - GoRouter Flutter의 기본 Navigator도 있지만, go_router 패키지가 훨씬 편하다. SwiftUI의 NavigationStack + .navigationDestination과 비슷한 느낌.\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 MaterialApp.router( routerConfig: GoRouter( initialLocation: \u0026#39;/\u0026#39;, routes: [ GoRoute( path: \u0026#39;/\u0026#39;, name: \u0026#39;home\u0026#39;, builder: (context, _) =\u0026gt; const HomeWidget(), ), GoRoute( path: \u0026#39;/new\u0026#39;, name: \u0026#39;new\u0026#39;, builder: (context, _) =\u0026gt; const NewPage(), ), ], ), ) 화면 이동은 context.pushNamed('new'), 뒤로가기는 context.pop(). 직관적이다.\nBottomNavigationBar는 SwiftUI의 TabView와 같은 역할인데, currentIndex와 onTap으로 탭 전환을 직접 관리한다.\n1 2 3 4 5 6 7 8 BottomNavigationBar( currentIndex: index, onTap: (newIndex) =\u0026gt; setState(() =\u0026gt; index = newIndex), items: [ BottomNavigationBarItem(icon: Icon(Icons.home_filled), label: \u0026#39;홈\u0026#39;), BottomNavigationBarItem(icon: Icon(Icons.search), label: \u0026#39;검색\u0026#39;), ], ) ThemeData 앱 전체 스타일을 한 곳에서 관리하는 건 SwiftUI랑 비슷하다. ColorScheme이랑 TextTheme을 설정해두면 Theme.of(context)로 어디서든 가져다 쓸 수 있다.\n1 2 3 4 5 6 7 8 9 10 11 12 MaterialApp( theme: ThemeData( colorScheme: ColorScheme.fromSeed(seedColor: Colors.indigo), textTheme: TextTheme( bodyMedium: TextStyle(fontWeight: FontWeight.normal, fontSize: 28), ), ), ) // 사용할 때 final textTheme = Theme.of(context).textTheme; Text(\u0026#39;Press Count\u0026#39;, style: textTheme.bodyMedium); 인스타그램 클론 - 배운 거 총동원 Ch01 마무리로 인스타그램 클론 UI를 만들었다. 위에서 배운 거 거의 다 써먹은 프로젝트다.\n구조 1 2 3 4 5 6 lib/ ├── main.dart // 앱 진입점, 테마, 하단 탭 ├── body.dart // 탭별 화면 라우팅 └── screen/ ├── home_screen.dart // 피드, 스토리 └── search_screen.dart // 검색, 그리드 메인 - 테마와 탭 바 1 2 3 4 5 6 7 8 9 10 11 12 13 14 MaterialApp( home: const InstaCloneHome(), theme: ThemeData( colorScheme: const ColorScheme.light( primary: Colors.white, secondary: Colors.black, ), bottomNavigationBarTheme: const BottomNavigationBarThemeData( showSelectedLabels: false, showUnselectedLabels: false, selectedItemColor: Colors.black, ), ), ) 인스타그램 특유의 흰 배경 + 검정 아이콘 스타일을 ColorScheme으로 잡았다. AppBar에는 google_fonts 패키지로 Lobster Two 폰트를 적용했다.\n스토리 영역 - 가로 스크롤 1 2 3 4 5 6 7 8 9 10 11 12 13 14 class StoryArea extends StatelessWidget { @override Widget build(BuildContext context) { return SingleChildScrollView( scrollDirection: Axis.horizontal, child: Row( children: List.generate( 10, (index) =\u0026gt; UserStory(userName: \u0026#39;User $index\u0026#39;), ), ), ); } } SwiftUI에서 ScrollView(.horizontal) 안에 HStack 넣는 것과 같은 패턴. List.generate로 더미 데이터를 만들어서 넣었다.\n피드 리스트 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 class FeedData { final String userName; final int likeCount; final String content; const FeedData({required this.userName, required this.likeCount, required this.content}); } class FeedList extends StatelessWidget { @override Widget build(BuildContext context) { return ListView.builder( shrinkWrap: true, physics: NeverScrollableScrollPhysics(), itemBuilder: (context, index) =\u0026gt; FeedItem(feedDataList[index]), itemCount: feedDataList.length, ); } } 여기서 shrinkWrap: true랑 NeverScrollableScrollPhysics()가 핵심이다. 부모가 SingleChildScrollView니까 ListView가 자체 스크롤하면 충돌한다. shrinkWrap으로 콘텐츠 크기만큼만 차지하게 하고, 스크롤은 부모한테 위임하는 거다.\n검색 화면 - GridView 1 2 3 4 5 6 7 8 GridView.count( crossAxisCount: 3, mainAxisSpacing: 4, crossAxisSpacing: 4, shrinkWrap: true, physics: const NeverScrollableScrollPhysics(), children: gridItem.map((color) =\u0026gt; Container(color: color)).toList(), ) 3열 그리드. SwiftUI의 LazyVGrid와 비슷한데, GridView.count가 더 간결하다.\n정리 주제 SwiftUI Flutter 박스 스타일링 modifier 체이닝 Container + BoxDecoration 수직/수평 배치 VStack / HStack Column / Row 비율 배치 .frame + GeometryReader Flexible / Expanded 겹치기 ZStack Stack + Align/Positioned 상태관리 @State StatefulWidget + setState 화면전환 NavigationStack GoRouter 전역 테마 .environment ThemeData + Theme.of(context) 전반적으로 SwiftUI보다 보일러플레이트가 좀 더 많긴 한데, 그만큼 명시적이라서 코드를 읽을 때 뭘 하는지 파악이 더 쉬운 것 같다. 특히 레이아웃 시스템은 SwiftUI보다 예측 가능해서 좋았음. GeometryReader 같은 트릭 안 써도 Flexible로 비율 잡으면 깔끔하게 떨어진다.\n다음 Ch02에서는 토스 앱 클론 만들면서 좀 더 실전적인 UI를 다룰 예정이다.\n","date":"2026-04-13T00:00:00+09:00","permalink":"/p/ch01-flutter-basics/","title":"Ch01. Flutter 기초 - 위젯부터 인스타 클론까지"},{"content":"Flutter 공부 기록용 블로그를 열었다.\n","date":"2026-03-01T00:00:00+09:00","permalink":"/p/hello/","title":"블로그 시작"}]