[아키텍처 최종장] MVC·MVP·MVVM에서 Clean·Hexagonal, 그리고 MSA까지 — 결국 ‘무엇을 어디에 둘까’의 문제

부제: 표현 계층부터 서비스 경계까지, 다섯 패턴을 한 줄에 꿰는 지도


Clean Architecture부터
MSA까지
시리즈를 이어 오며 반복해서 마주친 사실이 하나 있다. 이름과 다이어그램은
저마다 다르지만, 아키텍처 패턴이 실제로 다루는 질문은 거의 언제나
같았다는 것이다 — "이 코드는 어디에 두어야 하는가."

이 마지막 장에서는 표현 계층의 세 패턴(MVC·MVP·MVVM)부터 시스템 전체의
두 패턴(Clean·Hexagonal), 그리고 그것을 서비스 단위로 쪼갠 EDA·MSA까지
한 줄에 꿰어 본다.

한눈에 요약

  1. MVC·MVP·MVVM은 중재자(C·P·VM)만 다르다.
  2. 그 아래에는 늘 Service(Business) + Repository가 세트로 붙는다.
  3. Repository와 Active Record는 쿼리 실행이 어디서 일어나는가로 갈린다.
  4. MVC엔 Scheduler·Consumer를 둘 자리가 없다 — Clean·Hexagonal이 이를 일급 진입점으로 해결한다.
  5. Clean·Hexagonal은 UI 패턴과 층위가 다르다 — 함께 쓴다.
  6. 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. 한 줄로 남기며

이 모든 패턴을 한 문장으로 눌러 담으면, 처음의 그 질문으로 돌아온다.
"이 코드는 어디에 두어야 하는가."

거칠게 말하면 아키텍처란 결국 파일을 어느 폴더에 둘지 정하는 일이다.
다만 그 소박한 문장을 조금 더 정직하게 풀어 쓰면 이렇게 된다.

아키텍처란, 변화가 찾아왔을 때 어디를 열어야 할지 헤매지 않도록
코드가 머무를 자리를 미리 약속해 두는 일
이다. 화려한 다이어그램의
끝에는 언제나 "이건 어디에 두지?"라는 조용한 질문이 남고, 좋은 설계는
그 질문에 모두가 같은 답을 내놓게 만든다.

폴더 이름을 정하는 일이 사소해 보인다면, 반년 뒤 낯선 요구사항을 들고
그 폴더를 다시 여는 사람이 바로 나 자신이라는 사실을 떠올리면 된다.
아키텍처의 값어치는 그 순간에 치러진다.


관련 글 · 출처

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다