배달의민족 주문접수 채널에 Flutter를 도입하며 고민한 것

1 month ago 12

들어가며

배달의민족 주문접수 채널은 파트너의 주문 관리를 돕기 위해 매일 수백만 건의 주문을 실시간으로 처리하며, 다양한 디바이스를 지원하고 있습니다.

점차 다양해지는 디바이스 생태계에 대응하고자, 신규 제품에 Flutter + Clean Architecture를 선택했고, 나아가 비즈니스 요구사항에 더 빠르게 대응하기 위해 웹뷰 기반 컨테이너 앱(App Shell)으로 전환까지 진행 중입니다.

이 글에서는 Flutter 도입 과정과 아키텍처 설계 경험을 공유합니다.


1. 왜 Flutter였나?

비즈니스 관점의 멀티 플랫폼

늘어나는 플랫폼 요구사항

최근까지 Windows, Android 모바일, iOS 세 가지 플랫폼을 지원하고 있었지만, 다양한 디바이스에서 주문을 관리하고 싶다는 파트너 요구가 커져 플랫폼 확장이 필요했습니다.

  • macOS 데스크톱: Mac 디바이스로 주문을 관리하고 싶은 요청 증가
  • Android 태블릿/POS: Windows POS에서 Android 기반 디바이스로 전환 추세
  • 향후 확장성: 신규 디바이스 지원 및 접수 채널 통합 대비

개발 생산성과 비용

기존에는 Windows, Android 모바일, iOS 각 플랫폼별로 주문접수 채널을 개발해야 했고, 하나의 기능을 추가하려면 각 플랫폼에 맞춰 반복 구현해야 했습니다.

현재는 Android 태블릿/POS와 macOS 버전을 Flutter로 운영 중이며, Windows와 모바일 플랫폼(Android 모바일, iOS)은 점진적으로 전환하고 있습니다.

Flutter 도입으로 현재까지 확인된 효과는 다음과 같습니다.

  • 인력 절감: Android와 macOS 각각 별도의 개발자가 필요했지만, Flutter 개발자가 두 플랫폼을 담당합니다.
  • 개발 속도 향상: 한 번의 구현으로 멀티 플랫폼에 동시 배포할 수 있게 되었습니다.

유지보수 효율성

버그 수정이나 기능 변경 시에도 한 번의 수정으로 모든 플랫폼에 반영됩니다. 플랫폼 간 동기화 이슈가 줄어들고, 동일한 기능과 UX를 제공할 수 있습니다.

일관된 사용자 경험

파트너님들에게 동일한 UX를 제공할 수 있습니다. 이는 학습 비용을 줄이고, OS별 다른 UI로 인한 혼란을 줄일 수 있습니다.

저희의 선택

장기적으로 Windows, Android, iOS, macOS 4개 플랫폼을 모두 지원해야 하는 상황에서 생산성을 확보하려면 단일 코드베이스가 필수적이었습니다. 이 조건을 만족하면서 성숙한 생태계를 갖춘 프레임워크는 Flutter가 유일했고, 저희에게 적합한 선택이었습니다.

Flutter의 한계와 해결: "Write Once, Adapt Everywhere"

플랫폼 차이의 현실

처음에는 코드 하나로 모든 플랫폼을 동일하게 지원할 수 있을 것이라, 즉, "Write Once, Run Everywhere"를 기대했지만, 실제로는 플랫폼마다 주변기기 제어, 권한 시스템, 알림 방식, 업데이트 등 메커니즘이 다른 부분들이 존재합니다. 저희 경험상 "Write Once, Adapt Everywhere"에 가깝다고 느꼈습니다.

예를 들어 앱 업데이트 하나를 구현하더라도 각 플랫폼마다 다른 방식이 필요했습니다.

  • Android: Google Play In-App Update API
  • macOS: Sparkle 프레임워크 + 자체 Feed URL 서버

각 플랫폼의 업데이트 방식이 완전히 다르지만, 비즈니스 로직에서는 "업데이트 확인 및 실행"이라는 하나의 인터페이스로 통일할 수 있었습니다.

해결 방법

공통 인터페이스를 정의하고 플랫폼별 구현을 분리했습니다. Clean Architecture의 계층 분리 덕분에 플랫폼 의존성을 격리하고 비즈니스 로직을 독립적으로 유지할 수 있었습니다.

// 1. 공통 인터페이스 정의 abstract class AppUpdateManager { Future<UpdateResult> checkForUpdates({bool inBackground = false}); } // 2. Android 구현 - Google Play In-App Update class AndroidAppUpdateManager implements AppUpdateManager { @override Future<UpdateResult> checkForUpdates({bool inBackground = false}) async { final updateInfo = await InAppUpdate.checkForUpdate(); if (updateInfo.updateAvailability == UpdateAvailability.updateAvailable) { if (updateInfo.immediateUpdateAllowed && !inBackground) { await InAppUpdate.performImmediateUpdate(); } return UpdateResult.updateRequired(); } return UpdateResult.noUpdateRequired(); } } // 3. macOS 구현 - Sparkle Framework class MacOsAppUpdateManager implements AppUpdateManager { @override Future<UpdateResult> checkForUpdates({bool inBackground = false}) async { autoUpdater.checkForUpdates(inBackground: inBackground); return UpdateResult.noUpdateRequired(); } } // 4. 런타임 주입 if (Platform.isAndroid) { diContainer.registerSingleton<AppUpdateManager>( AndroidAppUpdateManager() ); } else if (Platform.isMacOS) { diContainer.registerSingleton<AppUpdateManager>( MacOsAppUpdateManager() ); }

비즈니스 로직은 플랫폼 독립적

final updateManager = diContainer<AppUpdateManager>(); final result = await updateManager.checkForUpdates(); if (result.result == UpdateResultType.updateRequired) { showUpdateDialog(); }

실용주의적 추상화: 변경 지점에만

추상화의 비용

모든 것을 추상화하면 인터페이스 파일과 구현체 파일들로 코드량이 증가하고 간단한 로직도 여러 파일을 오가며 추적해야 합니다. 저희는 "변경 지점에만 추상화한다."는 원칙을 따랐습니다.

추상화 기준
다음 조건 중 하나라도 해당하면 추상화했습니다.

  1. 플랫폼별로 구현이 다른 경우
  2. 외부 라이브러리에 의존하며 교체 가능성이 있는 경우
  3. 테스트를 위해 Mock이 필요한 경우

추상화한 것(예시)

  • AppPermissionManager: 플랫폼별 권한 차이
  • AppTerminator: 플랫폼별 종료 차이
  • AppUpdateManager: 플랫폼별 업데이트 차이
  • LocalNotification: 플랫폼별 알림 차이
  • ApiClient: 테스트 Mock 필요, 외부 의존성 격리
  • LocalStorageManager: 라이브러리 교체 가능성

추상화하지 않은 것

  • Flutter Widget: 이미 플랫폼 독립적
  • Mapper: 변환 함수
  • 대부분의 유틸리티 함수

사례: 실시간 통신 라이브러리 전환

최근 실시간 메시지 수신 방식을 MQTT에서 SSE(Server-Sent Events)로 변경했습니다. ServerEventReceiver 인터페이스로 추상화해두었기 때문에 아래와 같이 효율적으로 전환할 수 있었습니다.

  • 구현체만 교체(MqttEventReceiver → SseEventReceiver)
  • 비즈니스 로직은 변경 없음
  • 전환 작업 시간 최소화

추상화가 없었다면 MQTT 코드가 전체 코드베이스에 퍼져있어 대규모 수정이 필요했을 것입니다.


2. 아키텍처 선택: Clean Architecture + BLoC

Clean Architecture란?
Robert C. Martin이 제안한 소프트웨어 설계 원칙으로, 관심사를 계층별로 분리하여 비즈니스 로직을 UI, 프레임워크, 외부 의존성으로부터 독립시키는 아키텍처입니다.
Clean Architecture 자세히 보기

왜 Clean Architecture인가?

Flutter 프로젝트에서는 주로 BLoC 패턴이나 MVVM + Provider를 사용합니다. 하지만 저희는 플랫폼 확장과 큰 변경을 대비해 계층 분리가 명확한 Clean Architecture + BLoC을 선택했습니다.

Clean Architecture를 선택한 이유

  • 계층 분리로 충분한 관심사 분리
  • UI 프레임워크 교체 가능성 확보
  • 인터페이스 기반 계층 분리로 테스트 가능성과 플랫폼 독립성 확보
  • 팀의 학습 곡선이 완만함

프로젝트 구조
Feature 단위로 모듈을 분리하고, 각 Feature 내부는 Data, Domain, Presentation 계층으로 구성했습니다. 추가로 Infrastructure 계층에서 순수 기술 구현(프린터, 오디오, 실시간 통신 등)을 제공합니다.

lib/ ├── core/ # 공통 기능 │ ├── infrastructure/ # 공통 기술적 구현 │ └── domain/ # 공통 도메인 ├── features/ # Feature 기반 모듈 │ └── [feature]/ │ ├── data/ # Repository Impl, DataSource, DTO │ ├── domain/ # Entity, UseCase, Repository Interface │ ├── infrastructure/ # 기술적 구현 │ └── presentation/ # BLoC, UI └── di_container.dart # 의존성 주입

BLoC 선택: 명시적 상태 관리의 중요성

BLoC(Business Logic Component)란?
Flutter에서 UI와 비즈니스 로직을 분리하기 위한 상태 관리 패턴입니다. Event를 입력받아 State를 출력하는 스트림 기반 아키텍처로, 명시적인 상태 변화 추적이 가능합니다.
BLoC 공식 문서

"보일러플레이트가 많아도 유지보수성이 더 중요하다."

Flutter에는 Provider, Riverpod, GetX, BLoC 등 다양한 상태 관리 라이브러리가 있습니다. 저희는 명시적인 이벤트 로깅과 상태 추적이 가능한 BLoC을 선택했습니다.

1. 명확한 상태 추적

// BLoC: 모든 상태 변경이 Event로 기록됨 class OrderListBloc extends Bloc<OrderListEvent, OrderListState> { OrderListBloc() : super(InitializedOrderListState()) { on<InitializeListEvent>(_onInitialize); on<LoadOrdersEvent>(_onLoadOrders); on<SelectOrderEvent>(_onSelectOrder); } Future<void> _onLoadOrders(...) async { emit(LoadingOrderListState()); // UseCase 호출 emit(LoadedOrderListState(orders: orders)); } } // 사용 - 의도가 명확 orderListBloc.add(LoadOrdersEvent());

디버깅 시 로그만 봐도 전체 흐름을 파악할 수 있습니다.

[LOG] InitializeListEvent [LOG] LoadingOrderListState [LOG] LoadOrdersEvent [LOG] LoadedOrderListState(10 orders)

2. 버그 디버깅 시간 단축

예시: "주문 목록이 업데이트되지 않는 버그"

BLoC을 사용하면 이벤트 로그만 확인하면 됩니다.

[LOG] 22:10:04 - OrderSyncReceivedEvent(서버 → 새 주문 알림) // LoadOrdersEvent 로그가 없음! → 문제 발견: OrderSyncReceivedEvent 핸들러에서 LoadOrdersEvent 호출이 누락됨

BLoC의 명시적인 이벤트 로깅이 없었다면 디버깅에 더 많은 시간이 소요되었을 것입니다.

3. 화면 단위 BLoC: 단일 책임 원칙

주문 화면에는 7개의 BLoC이 사용됩니다.
주문 화면은 실시간 주문 관리, 액션 처리, 매출 분석 등 복잡한 기능이 집중된 핵심화면입니다. 7개의 BLoC을 사용하여 각 기능을 명확히 분리했고 각 BLoC이 300줄을 넘지 않도록 관리하고 있습니다.

OrderScreen ├─ OrderScreenBloc # 화면 네비게이션 ├─ OrderListBloc # 주문 목록 ├─ OrderDetailBloc # 주문 상세 ├─ OrderActionBloc # 주문 액션(접수, 취소, 완료 등) ├─ SalesOrderReportBloc # 매출 조회 ├─ BannerBloc # 배너 └─ SelfServiceBloc # 셀프 서비스

코드량보다 유지보수성을 선택한 이유

솔직히 말하면, BLoC은 코드가 많습니다.(Event 파일 + State 파일 + BLoC 파일) 하지만 이러한 구조 덕분에 상태 변화 흐름이 명확해지고, 대규모 프로젝트에서도 유지보수성을 유지할 수 있었습니다.

  • 버그 디버깅: 이벤트 로그 추적으로 빠른 원인 파악
  • 신규 기능: Event/State/Handler 패턴 반복
  • 팀 온보딩: 일관된 구조로 학습 곡선 완만
  • 코드 리뷰: 명확한 기준(역할 분리)

BLoC의 올바른 역할: Presenter

"BLoC = Business Logic Component"의 함정

BLoC은 "Business Logic Component"의 약자입니다. 이름 때문에 모든 비즈니스 로직을 BLoC에 넣어야 한다고 생각하기 쉽습니다. 하지만 저희는 BLoC을 Presenter로 정의했습니다. Presentation Logic만 처리하고, 핵심 비즈니스 로직은 Domain 계층의 UseCase에 분리했습니다.

왜 분리했나요?

  • 테스트 용이성: UseCase는 Flutter 의존성 없이 순수 Dart로 테스트 가능
  • 재사용성: 같은 UseCase를 다른 화면, 다른 BLoC에서 재사용
  • 책임 명확화: BLoC은 "어떻게 보여줄까", UseCase는 "무엇을 할까"

역할 정의

Presentation 계층(BLoC) ← Presenter 역할 - UI State 관리 - Event → State 변환 - UseCase 호출 - Presentation Logic ↓ Domain 계층(UseCase) ← 비즈니스 로직은 여기! - 비즈니스 로직 실행(주문 조회/접수/취소/완료 등)

예시

class OrderActionBloc extends Bloc<OrderActionEvent, OrderActionState> { final AcceptOrderUseCase acceptOrderUseCase; Future<void> _onAcceptOrder(...) async { // 1. Presentation Logic: UI 상태 변경 emit(ProcessingOrderActionState(orderId: event.orderId)); // 2. Presentation Logic: 사용자 액션 로깅 logClick(group: 'ActiveOrd', event: 'Accept'); // 3. UseCase 호출 (비즈니스 로직은 UseCase가 실행) final result = await acceptOrderUseCase.call(params); // 4. Presentation Logic: 결과를 State로 변환 result.fold( (failure) => emit(FailureOrderActionState(...)), (success) => emit(SuccessOrderActionState(...)), ); } }

계층별 데이터 모델: DTO → Entity → UI State

저희는 프론트엔드에도 Domain 계층을 뒀습니다. 타입 안정성, 플랫폼 독립성, 변경의 격리, 클라이언트 비즈니스 로직 관리 측면에서 이점이 있다고 판단했습니다.

데이터는 세 단계를 거쳐 변환됩니다.

  • DTO (Data Transfer Object): 서버 응답 그대로 매핑. nullable하고 원시 타입(String, int, bool 등) 기반입니다.
  • Entity: 비즈니스 도메인 개념으로 변환. 타입 안전성과 불변성을 보장하고 Flutter에 의존하지 않는 순수 Dart 객체입니다.
  • UI State: UI 표현에 필요한 정보로 변환. 화면에 표시할 텍스트, 색상, 버튼 상태 등 화면 렌더링에 특화된 데이터입니다.

각 계층은 명확히 분리된 책임을 가지며, 서버 변경은 Data 계층에서, UI 변경은 Presentation 계층에서만 처리됩니다.

데이터 변환 흐름

예시

// 1. DTO: 서버 응답 class OrderResponseData { String? orderId; String? orderStatus; String? time; } // 2. Entity: 도메인 개념 class OrderDetails { final OrderId id; final OrderStatus status; final DateTime orderTime; } // 3. UI State: UI 표현에 특화된 정보 class OrderDetailUiState { final String title; // "배민배달 J2Y2" final Color statusColor; // Colors.orange final ButtonUiState acceptButton; final TimerInfoUiState timer; }

장점

  1. 타입 안정성: nullable 지옥 탈출, Enum으로 타입 안전
  2. 플랫폼 독립성: Entity는 Flutter에 의존하지 않는 순수 Dart(테스트 시 유리)
  3. 변경의 격리: 서버 API 변경 시 Mapper만 수정, 나머지 계층 불변
  4. 비즈니스 로직 캡슐화: 클라이언트에서 처리해야만 하는 비즈니스 로직을 Domain에서 관리
  5. UI 로직 분리: isVisible, ButtonStatus 같은 UI 로직이 Entity에 없음

3. 아키텍처의 진가: 웹뷰 컨테이너 앱으로의 전환

웹뷰 기반 앱으로 전환 배경

현재는 macOS와 Android 태블릿/POS 버전만 Flutter로 운영하고 있습니다. Windows, iOS까지 전면 전환을 계획하면서 배포 속도 문제를 고민하게 되었습니다.

주문접수 채널은 실시간성이 중요한 서비스입니다. 긴급 버그 수정이 필요할 때 네이티브 앱은 특정 플랫폼에서 빌드, 심사, 배포까지 며칠이 소요될 수 있어, 플랫폼이 늘어나면 이 시간 동안의 비즈니스 영향도 커집니다.

웹뷰 기반 아키텍처의 장점

  • 앱 심사 없이 즉시 배포 가능
  • 모든 플랫폼에 동시 반영
  • 긴급 버그 수정 시간 단축

결정: 비즈니스 로직을 웹으로

고민 끝에 웹뷰 기반 컨테이너 앱으로 전환하기로 결정했습니다. 이를 통해 얻을 수 있는 주요 이점은 다음과 같습니다.

  • 긴급 버그 → 즉시 수정 → 즉시 배포
  • 주문 손실 최소화
  • 플랫폼 수와 무관하게 동일한 배포 속도

비즈니스 로직을 웹으로 이관하는 대규모 전환 프로젝트를 진행 중입니다.

전환 내용

비즈니스 로직(Domain, Data)은 웹으로 이동하고, Presentation 계층은 WebView + Bridge로 새롭게 구현했습니다. Infrastructure 계층(프린터, 권한, 알림 등)은 그대로 재사용합니다.

Infrastructure 계층 100% 재사용

모든 비즈니스 로직이 웹으로 이동했지만, 웹뷰가 처리하기 어려운 네이티브 기능들은 그대로 활용할 수 있었습니다.

  • AppPermissionManager: 권한 제어
  • PrinterOutputService: 영수증 출력 제어
  • LocalNotificationService: 로컬 푸시 알림
  • LocalStorageManager: 데이터 저장
  • 기타

웹뷰 브리지 구현 예시

class WebViewBridge { final AppPermissionManager permissionManager; // 기존 인프라 재사용 final PrinterOutputService printerOutputService; // 기존 인프라 재사용 // 웹에서 네이티브 기능 호출 Future<void> handleWebRequest(String method, String params) async { switch (method) { case 'requestPermission': return await permissionManager.requestPermissionsFor(params); case 'printReceipt': return await printerOutputService.print(params); // ... } } }

전환 과정에서 확인한 것

아키텍처의 장점

개발 과정에서 확인한 주요 효과는 다음과 같습니다.

  • Infrastructure 계층을 그대로 재사용(주변기기 제어, 권한, 알림 등)
  • WebView 브리지 구현만으로 네이티브 기능 연동 완료
  • 신규 플랫폼 추가 시 네이티브 컨테이너만 개발하면 됨

변경 범위

  • Presentation 계층: 100% 교체(Native UI → WebView)
  • Domain/Data 계층: 100% 제거(웹으로 이동)
  • Infrastructure 계층: 0% 변경(그대로 재사용)

계층 분리 덕분에 Infrastructure는 그대로 재사용하고 WebView 브리지만 구현했습니다. 변경 범위를 최소화할 수 있었고, 이것이 Clean Architecture를 선택한 이유 중 하나입니다.


4. 유지보수하기 쉬운 코드 만들기

계층별 네이밍 전략: "이름을 보면 계층이 보인다."

"이름으로 계층과 역할을 구분할 수 있어야 합니다."
각 계층은 자신만의 책임을 수행합니다. 저희는 클래스명과 메서드명에 일관된 규칙을 적용하여, 코드를 읽는 사람이 즉시 "어느 계층의 어떤 역할"인지 파악할 수 있도록 했습니다.

예를 들어

  • GetOrderListUseCase → Domain 계층
  • fetchOrderList(), OrderListResponseDto → Data 계층
  • OrderListBloc, LoadOrdersEvent → Presentation 계층

특히 같은 이름이 여러 계층에 반복되면 각 메서드의 역할이 불명확해집니다. 저희는 계층 간 이름 중복을 지양하며, 불가피하게 같은 이름을 쓸 때는 반환 타입으로 구분하거나 검색 시 의미 파악이 가능하도록 네이밍했습니다.

계층별 네이밍 규칙

계층 타입 클래스명 규칙 메서드명 규칙 예시
Domain Entity {Domain} OrderDetails, OrderList
Domain UseCase {Action}UseCase call GetOrderListUseCase.call
Data Repository {Domain}Repository Query: fetch{Data}
Command: {동사}{대상}
fetchOrderList
acceptOrder
Data DTO {Data}ResponseDto OrderListResponseDto
Presentation BLoC {Feature}Bloc OrderListBloc
Presentation Event {Action}Event LoadOrdersEvent
Presentation State {Status}{Feature}State LoadingOrderListState
Presentation UI State {Feature}UiState OrderDetailUiState

예시: 주문 상세 조회

// 1. UI에서 Event 발행(사용자 액션) orderDetailBloc.add(LoadOrderDetailsEvent(orderId)); // 2. BLoC: Event 처리 Future<void> _onLoadOrderDetails(LoadOrderDetailsEvent event, ...) async { // 3. UseCase 호출 final result = await getOrderDetailsUseCase.call( GetOrderDetailsParams(orderId: event.orderId) ); // → UseCase 내부에서는 orderQueryRepository.fetchOrderDetailsById 호출 // → DTO(OrderDetailsResponseDto) 파싱 후 Entity(OrderDetails)로 변환 // 4. BLoC: 결과를 UI State로 변환 후 emit result.fold( (failure) => emit(OrderDetailsLoadFailedState(failure.message)), (orderDetails) { final uiState = OrderDetailsUiState.fromDomain(orderDetails); emit(OrderDetailsLoadedState(uiState)); }, ); }

이 네이밍 규칙의 장점

  • 코드만 봐도 위치와 컨텍스트 파악
    • fetchOrderList() → Data 계층의 Repository 메서드
    • GetOrderListUseCase → Domain 계층의 UseCase
    • OrderListBloc → Presentation 계층의 BLoC
    • 클래스명과 메서드명만으로 어디에 있는지 즉시 알 수 있습니다.
  • 검색과 리팩토링 효율
    • IDE에서 "fetchOrderList" 검색 → Repository만 검색됨
    • "LoadOrdersEvent" 검색 → Event 클래스 1개만 나옴
    • 이름이 겹치지 않아 안전하게 rename 가능
  • 온보딩 시간 단축
    • 새로운 개발자가 일관된 패턴을 빠르게 학습
    • "Bloc", "Event", "UseCase", "fetch" 등 접미사, 접두사만 보고도 역할 파악
    • 코드 컨벤션 문서 없이도 코드만 보고 이해 가능
  • 도메인 표현력
    • acceptOrder, completeOrder 같은 도메인 액션을 직접 사용
    • 비즈니스 의도가 코드에 명확히 드러납니다.

타입 안전한 에러 핸들링: Either

전통적인 예외 처리는 컴파일 타임에 강제되지 않아, try-catch를 빠뜨리면 런타임 에러가 발생할 수 있습니다.

저희는 에러를 타입 시스템 안에서 명시적으로 표현하는 Either 타입을 사용했습니다. 이는 함수형 프로그래밍에서 나온 개념이지만, 실용적인 장점 때문에 도입했습니다.

예시

// 반환 타입만 봐도 실패 가능성을 알 수 있음 Future<Either<Failure, OrderList>> fetchOrderList() async { try { final response = await apiClient.get('/api/orders'); final dto = OrderListResponseDto.fromJson(response.data); final orderList = OrderListMapper.toDomain(dto.data); if (orderList.isEmpty) return Left(NoDataFailure()); return Right(orderList); } catch (e) { return Left(ServerFailure()); } } final result = await repository.fetchOrderList(); result.fold( (failure) { switch (failure) { case NetworkFailure(): case ConnectionFailure(): showNetworkError('네트워크를 확인해주세요'); case ServerFailureExpiredToken(): redirectToLogin(); case NoDataFailure(): showNoDataMessage('주문 데이터가 없습니다'); default: showError(failure.message); } }, (orders) => displayOrders(orders), );

장점

  1. 실패 가능성이 타입에 드러남: Either를 보면 이 함수가 실패할 수 있다는 것이 명확
    (vs try-catch는 함수 시그니처만 봐선 예외 발생 여부를 알 수 없음)
  2. 에러 처리 강제: 기본적으로 사용되는 fold()는 성공/실패 양쪽을 모두 처리해야 값을 얻을 수 있음
    (vs try-catch는 감싸는 걸 깜빡하면 런타임 에러 발생 가능성)
  3. 명시적 에러 전파: 어느 계층에서 어떤 에러가 발생했는지 추적하고 어떻게 처리할지가 명확해짐

Failure 타입 계층:

abstract class Failure { final String message; const Failure({this.message = ''}); } class TimeOutFailure extends Failure { const TimeOutFailure({super.message = '요청 시간이 만료되었습니다.'}); } // 서버 실패는 에러 코드 포함 class ServerFailure extends Failure { final String code; const ServerFailure({required this.code, required super.message}); } // 추가 컨텍스트가 필요한 경우 class LoginFailure extends ServerFailure { final bool blocked; const LoginFailure({ required super.code, super.message = '로그인에 실패했습니다.', this.blocked = false, }); }

계층 구조의 이점

  • 공통 속성은 상위 클래스에서 관리(message, code)
  • 각 실패 타입은 필요한 추가 정보만 포함(blocked 등)
  • switch 문으로 타입별 분기 처리 가능

네트워크 오류, 토큰 만료, 데이터 부재 등 다양한 실패 케이스를 명시적으로 처리하여, 상황에 맞는 안내 메시지를 제공할 수 있었습니다.


5. 성과와 교훈

프로젝트로 얻은 주요 성과와 교훈을 정리하면 다음과 같습니다.

1. 플랫폼 차이 대응

Flutter는 크로스플랫폼이지만, 플랫폼 차이는 존재합니다. 저희는 공통 인터페이스를 정의하고 플랫폼별 구현을 분리하여 해결했습니다.

2. 보일러플레이트 코드의 장단점

BLoC은 코드량이 증가하지만, 저희 경험상 유지보수 시간이 크게 단축되었습니다. 명시적 상태 관리가 장기적으로 유리하다고 느꼈습니다.

3. 필요한 곳에만 추상화

모든 것을 추상화하면 복잡도만 증가할 수 있습니다. 저희는 플랫폼별 구현이 다른 곳, 외부 의존성 격리, 테스트 Mock이 필요한 곳만 추상화했습니다.

4. 일관된 네이밍 컨벤션

메서드 이름만 봐도 어느 계층인지 알 수 있도록 일관된 네이밍 컨벤션을 유지했습니다.

5. BLoC의 역할

비즈니스 로직은 UseCase에, BLoC은 UI State 관리에 집중했습니다.

프로젝트를 돌아보며

완벽한 아키텍처는 없다고 생각합니다. 프로젝트 특성에 맞게 적용하고, 팀이 행복하게 일할 수 있는 구조가 좋은 아키텍처라고 생각합니다.

Flutter와 Clean Architecture는 저희 팀에 잘 맞았지만, 여러분의 프로젝트에는 다른 해답이 있을 수 있습니다.

저희가 선택한 패턴들을 돌아보면, 이 글에서 다룬 패턴들(계층 분리, 네이밍 규칙, UI State, Either 등)은 모두 코드로 하는 소통입니다.

좋은 제품과 코드는 소통에서 나온다고 생각합니다. 코드뿐 아니라 팀원들과의 대화, AI 활용, 미래의 유지보수까지 고려하며 명확히 의도를 전달하는 것이 중요하다고 느꼈습니다.


마치며

배달의민족 주문접수 채널에 Flutter를 도입했던 과정을 정리했습니다. 더 많은 고민과 시행착오가 있었지만, 핵심만 담아 공유했습니다.

이 글이 멀티 플랫폼 전략과 Flutter 아키텍처를 고민하는 분들께 조금이나마 도움이 되었으면 좋겠습니다.

Read Entire Article