부제: 표현 계층부터 서비스 경계까지, 다섯 패턴을 한 줄에 꿰는 지도
Clean Architecture부터
MSA까지
시리즈를 이어 오며 반복해서 마주친 사실이 하나 있다. 이름과 다이어그램은
저마다 다르지만, 아키텍처 패턴이 실제로 다루는 질문은 거의 언제나
같았다는 것이다 — "이 코드는 어디에 두어야 하는가."
이 마지막 장에서는 표현 계층의 세 패턴(MVC·MVP·MVVM)부터 시스템 전체의
두 패턴(Clean·Hexagonal), 그리고 그것을 서비스 단위로 쪼갠 EDA·MSA까지
한 줄에 꿰어 본다.
한눈에 요약
- MVC·MVP·MVVM은 중재자(C·P·VM)만 다르다.
- 그 아래에는 늘 Service(Business) + Repository가 세트로 붙는다.
- Repository와 Active Record는 쿼리 실행이 어디서 일어나는가로 갈린다.
- MVC엔 Scheduler·Consumer를 둘 자리가 없다 — Clean·Hexagonal이 이를 일급 진입점으로 해결한다.
- Clean·Hexagonal은 UI 패턴과 층위가 다르다 — 함께 쓴다.
- EDA·MSA는 이 경계를 서비스로 물리적으로 나눈 것이다.
1. MVC·MVP·MVVM — 사실 C·P·VM만 다르다
세 패턴은 흔히 별개의 것처럼 소개되지만, 뼈대는 완전히 같다. 화면(View)과
데이터(Model)를 분리하고, 그 사이에 중재자를 둔다. 다른 것은 오직
중재자의 이름과 일하는 방식뿐이다. 그래서 이 셋은 C·P·VM의 차이 하나만
이해하면 한꺼번에 이해된다.
- MVC의 C = Controller — 사용자 입력이 가장 먼저 도착하는 진입점.
입력을 해석해 Model을 갱신하고, View는 그 Model을 바라본다. 중재자와
화면의 관계가 비교적 느슨하다. - MVP의 P = Presenter — View와 1:1로 붙는다. View는 스스로
아무것도 결정하지 않는 수동 뷰(Passive View)가 되고, Presenter가
View 인터페이스를 호출해 화면을 직접 갱신한다. 화면 로직을 Presenter로
끌어내므로 테스트가 쉬워진다. - MVVM의 VM = ViewModel — View가 ViewModel의 상태를
데이터 바인딩으로 구독한다(디자인 패턴의 옵저버(Observer) 패턴을 응용한 것이다 — 관찰 가능한 상태를 노출해 두면 구독자가 변화를 알아서 반영한다). 결정적 차이는 ViewModel이 View를
전혀 모른다는 점이다(참조가 없다). 상태만 바꾸면 화면이 알아서
따라온다.
| 패턴 | 중재자 | View와의 관계 | View 갱신 방식 | View→중재자 참조 | 대표 환경 |
|---|---|---|---|---|---|
| MVC | Controller | 느슨(1:N) | View가 Model을 조회·관찰 | 입력이 Controller로 진입 | Spring MVC, Rails |
| MVP | Presenter | 1:1 | Presenter가 View 인터페이스 호출 | 양방향(서로 참조) | 초기 Android, WinForms |
| MVVM | ViewModel | 1:N(바인딩) | 데이터 바인딩으로 자동 | 없음(VM은 View 모름) | WPF, Android Jetpack, Vue·React류 |
정리하면 흐름의 방향과 결합도가 Controller → Presenter → ViewModel로
갈수록 화면과 로직이 더 깔끔하게 떨어진다. 세 패턴의 ‘진화’라기보다,
플랫폼이 제공하는 도구(특히 데이터 바인딩)에 맞춰 중재자의 모양이 달라진
것에 가깝다.
2. 폴더 구조로 보면 더 분명하다 — 그리고 늘 따라오는 세트
세 패턴의 차이는 디렉터리만 봐도 드러난다. 그리고 여기서 이 시리즈를
관통하는 또 하나의 관찰이 나온다. 표현 계층 아래에는 거의 항상
Service(Business) Layer와 Repository Layer가 세트로 따라붙는다(아래
★ 표시). UI 패턴이 무엇이든, 그 밑의 도메인·데이터 계층은 공통이기
때문이다.
MVC
src/
├─ controllers/ # 입력 진입점 — 요청을 받아 서비스를 호출
│ └─ OrderController
├─ models/ # 도메인 데이터 (Active Record면 여기서 쿼리까지)
│ └─ Order
├─ views/ # 화면 템플릿
│ └─ order_list.html
├─ services/ ★ # 비즈니스 로직 (세트)
│ └─ OrderService
└─ repositories/ ★ # 데이터 접근 (세트)
└─ OrderRepository
MVP — View는 ‘계약(interface)’으로 존재하고, Presenter가 그 계약만 보고 UI를 움직인다.
src/
├─ views/
│ ├─ OrderView # 인터페이스: showOrders(), showError()
│ └─ OrderScreen # 수동 뷰(Passive View) 구현
├─ presenters/
│ └─ OrderPresenter # View 인터페이스만 알고 화면을 직접 갱신
├─ services/ ★
└─ repositories/ ★
MVVM — View가 ViewModel의 상태를 바인딩으로 구독한다.
src/
├─ views/
│ └─ OrderScreen # OrderViewModel의 state를 바인딩
├─ viewmodels/
│ └─ OrderViewModel # 관찰 가능한 state 노출, View는 모름
├─ services/ ★
└─ repositories/ ★
세 폴더 트리에서 위쪽 한두 디렉터리(controllers/ · presenters/ ·
viewmodels/)만 바뀌고 services/·repositories/는 그대로다. 이것이
"중재자만 다르다"는 말의 실체다.
3. Repository Pattern vs Active Record Pattern
그 세트의 아래쪽, repositories/로 표시한 데이터 접근을 설계하는
방식은 크게 둘로 갈린다. 둘을 가르는 기준은 딱 하나 — 쿼리 실행이
‘어디서’ 일어나는가이다.
-
Active Record — 도메인 객체가 자신의 영속성을 스스로 안다.
객체 하나가 테이블의 한 행에 대응하고, 저장·조회 쿼리도 그 객체가
직접 실행한다.order = Order.find(42) # 조회 쿼리를 엔티티가 실행 order.status = "PAID" order.save() # 저장 쿼리도 엔티티가 실행 -
Repository — 도메인 객체는 영속성을 전혀 모른다. 쿼리 실행은
별도의 Repository가 전담하고, 도메인은 순수한 상태로 남는다.order = orderRepository.find(42) # 쿼리는 Repository 안에서 order.markPaid() # 도메인은 로직만 orderRepository.save(order) # 저장도 Repository가
쿼리 실행 레이어 비교 (부가)
| 축 | Active Record | Repository |
|---|---|---|
| 쿼리 실행 위치 | 도메인 객체 안 (Order.find, order.save) |
Repository 구현체 (도메인 밖) |
| 도메인 ↔ DB 결합 | 강함 (객체 = 테이블 행) | 약함 (도메인은 DB를 모름) |
| 테스트 | DB 의존이 객체까지 번짐 | Repository만 대체(목킹)하면 됨 |
| 대표 | Rails · Laravel Eloquent · Django ORM | Spring Data · DDD 스타일 |
| 잘 맞는 곳 | CRUD 중심, 빠른 개발 | 복잡한 도메인, 경계 분리 |
앞선 폴더 구조에서 services/와 repositories/가 늘 세트였던 이유가
여기서 드러난다. Repository를 택하는 순간 도메인 로직은 자연스럽게
services/로, 데이터 접근은 repositories/로 갈라진다. 반대로 Active
Record는 이 둘을 엔티티 안으로 합치는 선택이라, 서비스 계층이 얇아지는
대신 도메인과 DB가 한 몸이 된다.
4. MVC·MVP·MVVM의 한계 — Scheduler와 Consumer는 어디에 두나
세 UI 패턴을 실제로 쓰다 보면 부딪히는 벽이 있다. 화면도 요청도
없는 진입점을 어디에 둘지 애매하다는 것이다. MVC·MVP·MVVM의
진입점(Controller·Presenter·ViewModel)은 전부 사용자 요청 →
View 사이클을 전제로 설계됐기 때문이다.
내가 직접 겪은 두 가지가 대표적이다.
- Scheduler(정기 배치) — View도, HTTP 요청도 없이 시간에
맞춰 스스로 돈다. MVC에는 이걸 받아 줄 자리가 없어, Controller에 억지로
끼우거나 Service·유틸에 흩뿌리게 된다. 정해진 레이어가 없다. - MessageQueue Consumer — 브로커에서 들어오는 메시지에
반응해 도는데, 이 성격이 Scheduler와 똑같다. 사용자
요청이 아니라 ‘외부에서 들어오는 트리거’로 시작하는 비-요청 진입점이라,
역시 MVC에는 마땅한 자리가 없다.
이 두 진입점을 놓을 곳이 없다는 것이, 내가 UI 패턴을 넘어
Clean Architecture·Hexagonal Architecture를 택하게 된 결정적
계기였다.
두 패턴은 진입점 개념을 일반화해서 이 문제를 정면으로
없앤다. HTTP 핸들러든 Scheduler든 MQ Consumer든, 애플리케이션을
호출하는 쪽은 전부 동등한 ‘구동(primary) 어댑터’로
취급되고, 셋 다 같은 inbound port(UseCase)를 부른다. HTTP만 특별대우하지
않는 것이다.
adapter/
├─ http/ # 구동: 사용자 요청 진입점
│ └─ order_handler.go
├─ scheduler/ # 구동: 시간이 트리거하는 진입점 ← MVC엔 없던 자리
│ └─ settlement_job.go
└─ messaging/ # 구동: 메시지가 트리거하는 진입점 ← MVC엔 없던 자리
└─ order_consumer.go
Scheduler와 Consumer가 adapter/ 아래 각자의 자리를 가진 일급
시민이 된다. "어디에 두지?"라는 질문이 사라지는 것 — 이것이 표현
계층 패턴에서 전체 아키텍처 패턴으로 넘어가며 얻는 가장 실질적인 이득이다.
5. 최종 비교 — MVC·MVP·MVVM·Clean·Hexagonal
여기서 흔한 오해를 하나 풀어야 한다. MVC·MVP·MVVM과 Clean·Hexagonal은
경쟁 관계가 아니다. 다루는 층위가 다르기 때문이다.
- MVC·MVP·MVVM = 표현 계층(UI)을 조직하는 패턴
- Clean·Hexagonal = 시스템 전체의 의존성 방향을 정하는 패턴
즉 한 애플리케이션이 화면은 MVVM으로 정리하면서, 전체 골격은
Clean이나
Hexagonal로 잡는 조합이 자연스럽다.
| 패턴 | 무엇을 분리하나 | 층위 | 의존성 방향 |
|---|---|---|---|
| MVC / MVP / MVVM | 화면(View)과 모델(Model) | 표현 계층(UI) | 중재자 → 모델 |
| Clean | 관심사를 동심원 레이어로 | 시스템 전체 | 바깥 → 안(도메인 코어) |
| Hexagonal | 도메인 코어와 외부(포트·어댑터) | 시스템 전체 | 어댑터 → 포트(도메인) |
Clean과 Hexagonal은 사실상 같은 정신을 다른 그림으로 말한다. 의존성은
바깥(프레임워크·DB·UI)에서 안(도메인)으로만 흐른다. Clean은 이를
동심원으로, Hexagonal은 포트와 어댑터로 표현할 뿐이다. 둘 다 목표는
하나 — 도메인이 프레임워크와 DB를 모르게 만들어, 바깥이 바뀌어도 안이
흔들리지 않게 하는 것이다.
6. EDA·MSA = 앞의 아키텍처를 ‘서비스별로’ 나눈 것
지금까지의 이야기는 전부 하나의 프로세스(모놀리식) 안에서 레이어와
경계를 긋는 일이었다.
EDA와
MSA는 그 경계를 프로세스·서비스 단위로 물리적으로 쪼갠 것으로 읽으면
깔끔하다.
- MSA — Clean/Hexagonal로 잘 감싼 도메인 한 덩어리가 그대로
하나의 서비스가 된다. 각 서비스는 자기 도메인과 자기 DB를
소유한다. 앞서 레이어 사이에 그었던 경계가, 이제 서비스 사이의
경계로 확장되는 셈이다. - EDA — 그렇게 쪼갠 서비스들을 이벤트(브로커)로 느슨하게 잇는다.
직접 호출 대신 이벤트를 발행·구독해 결합을 낮춘다.
그래서
경계를 미리 그어두면 MSA 전환이 매끄럽다는
앞 글의 결론이 여기서 다시 확인된다. 모놀리식 안에서 레이어를 나누던
바로 그 원리를, 서비스 단위로 한 겹 더 밀어 올린 것이 EDA·MSA다. 원리가
같으니, 안에서 경계가 선명한 코드일수록 밖으로 쪼개기도 쉽다.
MSA 서비스의 모델·메시지 계층 — Request·Response·Command·Event
서비스를 이렇게 쪼개면 각 서비스의 모델(메시지) 계층도
통신 방식에 따라 폴더가 갈린다. 나는 이 계층을 네 개로 나눠 둔다. (여기서
‘모델’은 도메인 엔티티가 아니라 경계를 넘나드는 DTO·메시지
정의를 가리킨다.)
model/
├─ request/ # 동기 API 요청 DTO (HTTP로 들어오는 것)
├─ response/ # 동기 API 응답 DTO (HTTP로 나가는 것)
├─ command/ # 비동기 '명령' 메시지 — MQ 전용 DTO
└─ event/ # 비동기 '사건' 메시지 — MQ 전용 DTO
request/·response/— HTTP 같은 동기
호출의 입출력 DTO. 앞서
DTO 편에서 다룬
요청·응답 DTO가 그대로 여기 놓인다.command/·event/— 둘 다 MessageQueue를
쓰기 위한 DTO를 모아 두는 공간이다. 다만 성격이 갈린다.
Command는 "이것을 하라"는 명령(예:CancelOrder)으로
보통 특정 소비자 하나가 처리하고, Event는 "이런 일이
일어났다"는 사실(예:OrderCancelled)로 관심 있는 여러 서비스가
구독한다.
이 Command·Event가 곧 Hexagonal 편의 Consumer·Publisher가 주고받는
메시지이고, 메시지큐 위를 흐르는 DTO의 정체다.
7. 한 줄로 남기며
이 모든 패턴을 한 문장으로 눌러 담으면, 처음의 그 질문으로 돌아온다.
"이 코드는 어디에 두어야 하는가."
거칠게 말하면 아키텍처란 결국 파일을 어느 폴더에 둘지 정하는 일이다.
다만 그 소박한 문장을 조금 더 정직하게 풀어 쓰면 이렇게 된다.
아키텍처란, 변화가 찾아왔을 때 어디를 열어야 할지 헤매지 않도록
코드가 머무를 자리를 미리 약속해 두는 일이다. 화려한 다이어그램의
끝에는 언제나 "이건 어디에 두지?"라는 조용한 질문이 남고, 좋은 설계는
그 질문에 모두가 같은 답을 내놓게 만든다.
폴더 이름을 정하는 일이 사소해 보인다면, 반년 뒤 낯선 요구사항을 들고
그 폴더를 다시 여는 사람이 바로 나 자신이라는 사실을 떠올리면 된다.
아키텍처의 값어치는 그 순간에 치러진다.
관련 글 · 출처
- 아키텍처 시리즈 전체 보기 (Architecture 카테고리)
- 아키텍처 #1 — Go로 이해하는 Clean Architecture
- 아키텍처 #4 — MSA: 서비스 경계와 DB 소유
- Martin Fowler, Patterns of Enterprise Application Architecture —
Active Record ·
Repository - Robert C. Martin,
The Clean Architecture
답글 남기기