과연 우리는 깨끗한가

모바일 포스와 함께하는 클린한 아키텍처 적용기
2025 하반기 개발자 컨퍼런스 - 유종욱


목차

  1. 주제를 선정하게 된 이유
  2. 아키텍처가 뭔가요?
  3. 유명한 아키텍처에 대한 간단한 소개
  4. 모바일 포스의 아키텍처 소개
  5. 개선 가능한 점

주제를 선정하게 된 이유

“우주에 변하지 않는 유일한 것은 ‘변한다’는 사실뿐이다.”
— 헤라클레이토스(그리스 철학자)

끊임없는 변화 속에서 유연하고 유지보수 가능한 구조가 필요합니다.


아키텍처가 뭔가요?

프로젝트 요구 사항과 팀의 성장에 따라 앱을 확장하고 구성하며 디자인하는 방법

중요한 점

  • 변화에 유연해야 한다
  • 이해하기 쉬워야 한다
  • → 유지보수 시간이 짧아진다

유명한 아키텍처에 대한 간단한 소개

🧱 Layered Architecture

역할별로 계층을 나누어 관심사 분리, 유지보수 용이성, 테스트 효율성을 높이는 설계

구성:

  • Presentation Layer
  • Application Layer
  • Domain Layer
  • Infrastructure Layer

장점:

  • 역할 분담 명확
  • 테스트 용이
  • 시스템 확장성 향상

단점 및 주의사항:

  • 계층 간 불필요한 호출 누적 가능
  • 책임이 명확하지 않으면 중복, 비효율 초래
  • 의존성 역전 원칙(DIP) 적용 필요

한줄 요약:

“서비스 복잡도는 높아지지만, 유지보수는 더 쉬워집니다.”

레이어드


🧼 Clean Architecture

비즈니스 로직을 중심에 두고 외부 기술 요소와의 의존성을 분리하는 설계

문제점:

  • 로직이 UI, DB에 의존 → 변경에 취약
  • 테스트 어려움

해결 방안:

  • 의존성 방향을 외부 → 내부로 통제
  • 도메인 완전 분리

기대 효과:

  • 기술 변경 시 영향 최소화
  • 로직 재사용성 향상
  • 테스트 용이성 확보

한줄 요약:

“우리는 기술보다 로직을 중심에 둡니다.”

클린아키텍처


모바일 포스의 아키텍처 적용 사례

✅ V 0.0.0

버전0

기능 요청: 판매 기능 추가 판매페이지

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// View
Widget cardPaymentButton(BuildContext context) {
  return CustomButtonWidget(
    child: Text("카드"),
    onClick: () {
      PaymentBloc viewModel = context.read<PaymentBloc>();
      viewModel.add(PayButtonClicked());
    },
  ); // CustomButtonWidget
}

버튼을 보여주고, 이 버튼이 눌렸을 때 뷰모델에 있는 함수를 호출합니다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
class PaymentViewModel {
  void onPayButtonClicked() {
    PaymentViewModelRepository viewModelRepository =
        PaymentViewModelRepository();
    ApiRepository apiRepository = ApiRepository();

    // 결제 단말기 통신 로직 생략

    apiRepository.saveSales(
      cartProductList: viewModelRepository.cartProductEntity.toString(),
      price: viewModelRepository.totalPaymentPrice,
    );
  }
}

뷰모델에서는 결제 단말기와 결제 통신을 하고 성공했으면 판매 데이터를 정제해서 포스 서버로 전송해줍니다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
class PaymentViewModelRepository {
  /// 카드에 담긴 상품
  List<CartProductEntity> cartProductEntity = [];

  /// 결제 금액
  int paymentPrice = 0;

  /// payment page 에서 적용된 별도 할인 로직이 적용된 금액
  int get totalPaymentPrice {
    int amountPrice = paymentPrice - pointDiscount;
    return amountPrice;
  }

  /// 포인트 할인
  int pointDiscount = 0;
}

뷰모델 Repository는 카드에 담긴 상품 정보, 결제된 금액 같은 데이터 들을 가지고 있어요

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
@RestApi()
abstract class ApiRepository {
  factory ApiRepository() = _ApiRepository;

  @POST("order")
  Future<HttpResponse<CommonProcessResponse>> saveSales(
    {@Path("cartProductList") String cartProductList,
     @Path("price") int price}
  );

  @GET("customer")
  Future getCustomers();

  @GET("coupon")
  Future getCoupons();
}

api Repository 는 정제된 데이터를 전송해주는 역할을 합니다.

문제점

  • ViewModel / Repository 네이밍 혼동
  • ViewModel, ViewModel Repository 역할 모호
  • API Repository 역할 과부하

개선

  • ViewModel Repository 통합
  • API Repository 역할별 분리

✅ V 1.0.0

버전1

기능 요청: 환불 기능 추가 환불페이지

 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
class RefundViewModel {
  /// 카드에 담긴 상품
  List<CartProductEntity> cartProductEntity = [];

  /// 결제 금액
  int paymentPrice = 0;

  /// 포인트 할인
  int pointDiscount = 0;

  /// 총 결제 금액
  int get totalPaymentPrice {
    int amountPrice = paymentPrice - pointDiscount;
    return amountPrice;
  }

  void onPayButtonClicked() {
    OrderRepository orderRepository = OrderRepository();

    // 결제 단말기 통신 로직 생략
    orderRepository.saveRefund(
      cartProductList: cartProductEntity.toString(),
      price: totalPaymentPrice,
    );
  }
}

금액들을 다 관리하고, 계산하는 함수, Repository 랑 통신하는 로직도 가지고 있죠 Repository 도 아까와 내부 로직은 동일하지만 각각의 책임에 맞게 분리된 모습을 보실 수 있습니다.

문제점

  • ViewModel 간 로직 공유 불가 → 중복 로직

개선

  • 공통 로직 분리를 위한 UseCase 도입

✅ V 1.5.0

버전1.5

기능 요청: 유선/블루투스 영수증 프린터 추가

영수증설정

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
class ReceiptUsecase {
  final ReceiptRepository receiptRepository = getIt<ReceiptRepository>();

  final ReceiptSettingEntity receiptSettingEntity = ReceiptSettingEntity(
    printerConnectionType: PrinterConnectionType.bluetooth,
  );

  Future<void> connect() async {
    switch (receiptSettingEntity.printerConnectionType) {
      case PrinterConnectionType.serial:
        bool connectResult =
            await receiptRepository.connectSerialPrinter();
        break;
      case PrinterConnectionType.bluetooth:
        BaseEntity<bool, Exception> connectResult =
            await receiptRepository.connectBluetoothPrinter();
        return;
    }
  }
}

프린터랑 연결을 해줘야 하는데 방식이 두개입니다. 지금의 구조상 동일한 역할을 가진 레포끼지 분리할 방법이 없어서 위와 같은 로직이 생기게 되었습니다.

문제점

  • 복수 데이터 소스 대응 부족
  • Data Layer 의존성 → 도메인, UI 영향

개선

  • Repository 추상화
  • Data Source 분리 적용

✅ V 2.0.0 (현재 버전)

버전2

문제점

  • ViewModel과 UseCase 간 책임 분리 기준 모호
  • 테스트 전략 미흡

개선

  • 구조 기반 테스트 코드 추가
  • UI ↔ Domain 계층 기준 정립

결론

“아키텍처는 처음부터 완벽할 수 없다. 유명한 아키텍처에 집착하기 보단 처음에는 깨끗하지 않더라도 상황에 맞게 점차 변화해나가.”


참고 자료

  • 아키텍처 정의: Flutter 문서 - Architecting Flutter apps
  • 레이어드 아키텍처: Android Developer - 앱 아키텍처 가이드
  • 클린 아키텍처: The Clean Code Blog
  • 리듬세상 이미지: 닌텐도 코리아 유튜브