Ch05-6. 아키텍처와 상태관리 총정리 - 정답은 없고 차이만 있다

Ch05 시리즈를 마무리하며 아키텍처와 상태관리를 선택할 때 고려할 5가지 기준, Scoped vs Static 접근성 비교, 그리고 공부법에 대한 개인적인 생각을 정리한 기록

Ch05 시리즈를 통해 같은 Todo 앱을 ValueNotifier → GetX → BLoC → Riverpod → Hooks까지 5가지 방식으로 다뤄봤다. 강의 마지막에 “그래서 뭘 써야 하나"에 대한 총정리가 나왔는데, 여기에 내 생각도 섞어서 정리한다.

왜 아키텍처를 고민하는가

많은 개발자들이 앱의 아키텍처를 궁금해하고, 어떻게 해야 좋은지 고뇌한다. 내 생각은 결국 쉬운 구조를 위해서가 아닐까 싶다.

그 “쉬운 구조"를 판단하는 기준은 크게 5가지로 나눌 수 있다.

1. 쉬운 개발

가장 직관적인 기준이다.

  • 구조 파악이 쉬운가 — 코드를 처음 보는 사람도 흐름을 빠르게 이해할 수 있는가
  • 작성하는 코드의 양이 적은가 — 보일러플레이트가 적을수록 생산성이 올라간다
  • 가독성이 좋은가 — 6개월 뒤의 내가 봐도 바로 이해되는가

Ch05에서 직접 체감한 걸 돌아보면:

방식보일러플레이트진입 장벽구조 파악
ValueNotifier중간낮음쉬움
GetX적음낮음쉬움
BLoC많음높음Event 따라가면 명확
Riverpod적음중간Provider 단위로 명확
Hooks + Riverpod적음중간역할 분리 깔끔

BLoC은 Event/State/Bloc 클래스를 다 만들어야 하니까 코드량이 확실히 많다. 근데 그 대신 “이 이벤트가 이 상태를 바꾼다"가 명확해서 대규모 팀에서는 오히려 파악이 쉬울 수 있다. 결국 “쉬움"의 기준도 상황마다 다르다.

2. 안정성

여기서 말하는 안정성은 “앱이 잘 돌아가느냐"가 아니다.

일반적으로 코드를 짜면 코딩한 대로 동작하기 마련이다. 어떤 아키텍처를 쓰든, 어떤 상태관리를 쓰든, 출시 전에 개발자와 테스터가 테스트를 마치고 앱이 나가기 때문에 작동하지 않는 경우는 없다. 이런 관점에서는 모두 안정적이고 차이가 거의 없다.

여기서 말하는 안정성은 Test Code를 작성할 수 있는가다.

스펙이 변경되고 그에 따른 수정을 할 때, 예상하지 못한 버그가 일어나는 걸 최소화하려면 테스트 코드가 필요하다. 새 기능을 추가했는데 기존 기능이 깨지는 걸 사전에 잡아내는 것. 이게 진짜 안정성이다.

방식테스트 용이성이유
GetX (Static)어려움전역 상태라 격리가 힘듦
BLoC (Scoped)좋음Event 입력 → State 출력 검증이 깔끔
Riverpod (Scoped)좋음ProviderScope로 격리, override로 mock 주입
ValueNotifier (Scoped)보통가능하지만 편의 기능이 적음

3. 성능

사실 성능은 일반적인 모바일 앱에서는 거의 동일하다.

  • 500만 번 정도의 연산이 일어나야 120Hz 렌더링을 방해할 수 있다 (애니메이션 예제 기준)
  • 코드를 잘못 짜서 상호참조나 무한루프를 만드는 게 아닌 이상, 성능 차이를 체감하기는 어렵다
  • Flutter 자체가 **C 레벨의 네이티브(Skia/Impeller)**로 렌더링하기 때문에 성능이 워낙 좋다

물론 위젯 트리 구조대로 필요한 곳만 빌드하게 해서 성능을 최적화할 수는 있다. const 위젯이나 Consumer/BlocBuilder 같은 걸로 리빌드 범위를 줄이는 식으로.

하지만 배터리 타임이나 CPU 타임에 영향을 줄 정도로 아키텍처나 상태관리 패키지가 차이를 만들지는 않는다. 아키텍처 선택이 성능을 좌우한다는 건 사실상 미신이다.

4. 확장성

스펙이 바뀌거나 기능이 추가될 때, 기존 아키텍처가 유지되는 게 베스트다.

  • 새 화면 추가할 때 기존 구조를 건드리지 않아도 되는가
  • 상태 하나 추가할 때 수정 범위가 해당 모듈 안에서 끝나는가
  • 팀원이 늘어나도 각자 독립적으로 작업할 수 있는가

Scoped 방식(BLoC, Riverpod)은 Provider/Bloc 단위로 관심사가 나뉘어 있어서 확장할 때 기존 코드를 건드릴 일이 적다. GetX도 Controller 단위로 나누면 되지만, 전역 접근이 가능하다는 특성상 의도치 않은 의존성이 생길 수 있다.

5. 제공되는 추가 기능들

각 패키지마다 별도로 제공하는 기능들이 있고, 이것도 선택에 영향을 준다.

패키지특화 기능
GetX라우팅, 다이얼로그, 스낵바, 다국어 — 올인원
BLoCBlocObserver (Event 로깅), bloc_test
Riverpod.when (비동기 분기), family (키 기반 상태), @riverpod 코드 생성
HooksController 자동 dispose, Custom Hook 재사용

GetX는 상태관리 외에도 네비게이션, UI 유틸리티까지 한 패키지에 들어있다. BLoC은 이벤트 추적에 강하다. Riverpod은 비동기 처리가 압도적이다. 프로젝트에서 어떤 기능이 중요한지에 따라 선택이 달라진다.

정답은 없다, 차이만 있다

아키텍처와 상태관리는 맞고 틀리고의 정답이 없다. ‘차이점’만 존재한다.

5가지 기준을 한눈에 정리하면:

기준GetXBLoCRiverpod
쉬운 개발쉬움보일러플레이트 많음적당함
안정성 (테스트)격리 어려움Event/State 검증 용이override로 mock 주입
성능동일동일동일
확장성전역이라 의존성 주의Bloc 단위 분리Provider 단위 분리
추가 기능올인원Event 로깅.when, family

Scoped vs Static — 접근성 다시 보기

Ch04에서 Scoped Model과 Static Model을 다뤘었다. 실제로 써보고 나니 “접근"에 대한 생각이 좀 바뀌었다.

모델상태 접근 방식대표
Scopedcontext.read() (BLoC), ref.read() (Riverpod)BLoC, Riverpod
StaticGet.find() — 어디서나 접근GetX

Static이 언뜻 보면 쉬워 보인다. contextref니 하는 참조 엘리먼트를 안 써도 되니까. 하지만 실무적으로 살짝 불편할 뿐, 구조적으로는 접근에 문제가 없다.

애초에 앱을 실행시키는 main 함수에서 앱 객체를 만들 때부터 코드에서는 contextref를 참조할 수 있다. initStateafterFirstLayout에서 context 접근이 가능하고, Riverpod은 ConsumerWidget만 쓰면 ref로 필요한 접근이 다 된다.

그렇기 때문에 “접근” 자체는 정말 사소하게 불편할 뿐, 참조를 연결만 한다면 구조적으로 언제나 접근이 된다고 봐도 무방하다.

Widget Tree 기반 설계의 차이

진짜 차이가 나는 건 Widget Tree에 기반한 설계다.

Scoped 방식(BLoC, Riverpod)은 위젯 트리 위치에 따라 상태의 범위가 자연스럽게 정해진다. 화면이 닫히면 해당 스코프의 상태도 자동으로 정리된다.

GetX로 같은 걸 하려면 직접 구조를 구현해야 한다. 이전 블로그에서 다뤘듯이 임시 ID(tag)를 부여해서 관리해야 하는데, 해당 트리에 해당되는 위젯 생성자에 ID를 매번 전달하거나, InheritedWidget을 직접 구현해야 한다. 메모리 관리도 따로 해줘야 해서 오히려 더 불편해진다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// Riverpod: 스코프가 자연스럽게 관리됨
ProviderScope(
  overrides: [commentProvider.overrideWith(() => CommentNotifier(videoId: id))],
  child: CommentSection(),
)
// 화면 닫히면 자동 정리

// GetX: tag로 직접 관리해야 함
Get.put(CommentController(videoId: id), tag: id);
// ...
Get.delete<CommentController>(tag: id);  // 직접 정리

4가지 방식 최종 비교

Ch05 전체를 통해 같은 Todo 앱을 돌려본 결과:

ValueNotifierGetXBLoCRiverpod
모델Scoped (내장)StaticScopedScoped
상태 변경notifyListeners().obs 자동emit()state = 재할당
UI 구독ValueListenableBuilderObxBlocBuilderref.watch
비동기 분기직접 처리직접 처리직접 처리.when 자동
보일러플레이트중간적음많음적음
추적성낮음낮음높음중간
메모리 관리자동수동자동자동
테스트보통어려움좋음좋음
접근 방식contextGet.find()contextref

여기에 Hooks를 더하면 로컬 UI 상태(useState, useTextEditingController)는 Hooks가, 공유 비즈니스 상태는 Riverpod이 맡아서 역할 분리가 깔끔해진다.

공부법에 대한 생각

Flutter를 공부해보니 몇 개월, 몇 년 이상 공부한 사람들은 패키지, 아키텍처, 상태관리 관련해서 블로그나 유튜브 등을 찾아보면서 시간을 꽤 많이 쓰지 않았을까 싶다. 물론 좋은 방향이지만, 너무 알아보고 너무 살펴보는 데 많은 시간을 쏟는다면 조금은 낭비가 아닐까 조심스레 생각해본다.

필자의 성격은 새로운 하나를 접하면(개발 외 모든 분야) A에서 Z까지 파보는 성격이다. 처음 개발을 접할 때도 그렇게 공부했다가 몇 달 만에 번아웃이 와버렸다.

이번에 Flutter를 새로 공부하면서 그 습관은 버리고, 직접 프로젝트에 적용해 가며 안 되는 것들을 찾아가고, 이를 해결하기 위해 직접 코드를 구현하거나 다른 패키지들을 도입해보면서 해결할 수 있는 능력을 길렀다. 해보지도 않고 이론에만 시간 쏟는 것보다 와닿는 게 훨씬 빨랐다.

물론 매우 주관적인 의견일 수 있다. 하지만 적어도 나한테는 이 방법이 맞았다:

  1. 일단 만들어본다 — Todo 앱 하나를 4가지 방식으로 전환해본 게 블로그 10개 읽는 것보다 나았다
  2. 안 되는 걸 찾아본다 — 필요할 때 찾아보는 게 기억에 남는다
  3. 비교는 직접 해본 뒤에 — 써보지도 않고 “BLoC vs Riverpod” 비교 글만 읽으면 감이 안 온다

마무리

앞으로 많은 프로젝트를 할 예정이고, 현재 운영 중인 iOS 네이티브 앱을 Flutter로 전환하는 계획도 있다. 상태관리, 아키텍처, 각종 패키지 등 프로젝트 상황에 맞게 잘 적용해보자.

Ch05 시리즈를 한 문장으로 정리하면:

아키텍처와 상태관리는 정답이 없다. 차이점만 있을 뿐이다. 직접 써보고 프로젝트에 맞는 걸 고르자.