과연 우리는 깨끗한가#
모바일 포스와 함께하는 클린한 아키텍처 적용기
2025 하반기 개발자 컨퍼런스 - 유종욱
- 주제를 선정하게 된 이유
- 아키텍처가 뭔가요?
- 유명한 아키텍처에 대한 간단한 소개
- 모바일 포스의 아키텍처 소개
- 개선 가능한 점
주제를 선정하게 된 이유#
“우주에 변하지 않는 유일한 것은 ‘변한다’는 사실뿐이다.”
— 헤라클레이토스(그리스 철학자)
끊임없는 변화 속에서 유연하고 유지보수 가능한 구조가 필요합니다.
아키텍처가 뭔가요?#
프로젝트 요구 사항과 팀의 성장에 따라 앱을 확장하고 구성하며 디자인하는 방법
중요한 점#
- 변화에 유연해야 한다
- 이해하기 쉬워야 한다
- → 유지보수 시간이 짧아진다
유명한 아키텍처에 대한 간단한 소개#
🧱 Layered Architecture#
역할별로 계층을 나누어 관심사 분리, 유지보수 용이성, 테스트 효율성을 높이는 설계
구성:
- Presentation Layer
- Application Layer
- Domain Layer
- Infrastructure Layer
장점:
단점 및 주의사항:
- 계층 간 불필요한 호출 누적 가능
- 책임이 명확하지 않으면 중복, 비효율 초래
- 의존성 역전 원칙(DIP) 적용 필요
한줄 요약:
“서비스 복잡도는 높아지지만, 유지보수는 더 쉬워집니다.”

🧼 Clean Architecture#
비즈니스 로직을 중심에 두고 외부 기술 요소와의 의존성을 분리하는 설계
문제점:
- 로직이 UI, DB에 의존 → 변경에 취약
- 테스트 어려움
해결 방안:
- 의존성 방향을
외부 → 내부
로 통제 - 도메인 완전 분리
기대 효과:
- 기술 변경 시 영향 최소화
- 로직 재사용성 향상
- 테스트 용이성 확보
한줄 요약:
“우리는 기술보다 로직을 중심에 둡니다.”

모바일 포스의 아키텍처 적용 사례#
✅ V 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
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 간 로직 공유 불가 → 중복 로직
✅ V 1.5.0#

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

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 (현재 버전)#

문제점#
- ViewModel과 UseCase 간 책임 분리 기준 모호
- 테스트 전략 미흡
- 구조 기반 테스트 코드 추가
- UI ↔ Domain 계층 기준 정립
“아키텍처는 처음부터 완벽할 수 없다. 유명한 아키텍처에 집착하기 보단 처음에는 깨끗하지 않더라도 상황에 맞게 점차 변화해나가.”
참고 자료#
- 아키텍처 정의: Flutter 문서 - Architecting Flutter apps
- 레이어드 아키텍처: Android Developer - 앱 아키텍처 가이드
- 클린 아키텍처: The Clean Code Blog
- 리듬세상 이미지: 닌텐도 코리아 유튜브