MSA 전체 아키텍처 지도
주문 수명주기를 중심으로 로컬 트랜잭션·Kafka 이벤트·Outbox·SAGA·CQRS를 한 장에 묶어 운영 가능한 MSA 지도를 제시
이 글의 결론: 이 시스템은 “주문 수명주기”를 이벤트로 고정하고, 쓰기/읽기/재처리를 분리해 운영 가능한 상태로 만드는 구조다.
이 글은 4개 서비스(주문/결제/레스토랑/고객)가 각자 로컬 트랜잭션만 책임지고, 서비스 간 일관성은 이벤트 + SAGA로 맞추며, 신뢰성은 Outbox + Idempotency + 재처리로 확보하고, 조회는 CQRS 읽기 모델로 떼어내는 큰 그림을 고정한다.
전체 시스템 구성 결론: “각 서비스는 자기 DB만 쓰고, 서비스 간 연결은 Kafka 이벤트로만 단단하게 묶는다.”
서비스 4개와 책임(권한 경계)
Order Service
- 책임: 주문 생성/상태 전이(예:
PENDING → APPROVED/REJECTED), 주문 커맨드 처리 - 소유 데이터: 주문 원장(order aggregate), 주문 상태 이력
- 책임: 주문 생성/상태 전이(예:
Payment Service
- 책임: 결제 승인/거절 판단, 결제 트랜잭션 생성, 결제 결과 이벤트 발행
- 소유 데이터: 결제 원장(payment aggregate), 결제 시도/결과
Restaurant Service
- 책임: 주문 수락/거절(재고/영업상태/조리 가능 여부), 가게 관점의 정책
- 소유 데이터: 레스토랑 상태, 주문 수락 기록
Customer Service
- 책임: 고객 프로필/결제수단/정책(예: 블랙리스트, 한도), 고객 관점의 검증
- 소유 데이터: 고객 정보, 정책/등급
데이터 저장소와 통신 경로(원칙)
- 데이터 저장소: 서비스별 DB 분리(각자 소유, 공유 금지)
통신 경로:
- 동기 호출(HTTP/gRPC): “지금 당장 실패를 알려야 하는 검증”에만 제한적으로 사용(예: 주문 생성 시 고객 존재 확인 등)
- 비동기 이벤트(Kafka): 상태 전이, 후속 처리, 통합을 기본으로 사용
전체 아키텍처(ASCII) 결론: “쓰기 흐름은 커맨드로 시작하고, 상태 전이는 이벤트로 확정된다.”
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
27
28
29
30
31
32
33
34
35
36
+-------------------+
Client -- Command --> | Order Service |
| (Command/Write) |
+----+---------+----+
| |
| tx | publish intent
v v
(Order DB) (Outbox Table)
| |
| CDC/Relay| (same transaction boundary)
v v
+-------------------+
| Kafka Bus |
| topics: |
| OrderCreated |
| PaymentApproved |
| PaymentRejected |
| RestaurantAccepted|
| RestaurantRejected|
+----+----+----+----+
| | |
consume| | |consume
v v v
+-----------+ +-----------+ +-------------+
| Payment | | Restaurant| | Customer |
| Service | | Service | | Service |
+-----+-----+ +-----+-----+ +------+------+
| | |
v v v
(Payment DB) (Rest DB) (Customer DB)
Query Side (CQRS Read Model)
+---------------------------------------------+
| Read DB / Materialized View (Order Query) |
| built by consuming events from Kafka |
+---------------------------------------------+
정상 이벤트 흐름 결론: “주문 생성은 시작 신호일 뿐이고, 최종 승인(Approved)은 여러 서비스의 로컬 판단이 모여 확정된다.”
정상 흐름: 주문 생성 → 결제 → 승인/거절
Order Service:
CreateOrder커맨드 처리- Order DB에
PENDING주문 저장 - 같은 트랜잭션에서 Outbox에
OrderCreated기록
- Order DB에
- Outbox Relay/CDC: Outbox를 읽어 Kafka에
OrderCreated발행 Payment Service:
OrderCreated구독 → 결제 시도- 결제 성공 시
PaymentApproved이벤트 발행 - 결제 실패 시
PaymentRejected이벤트 발행
- 결제 성공 시
Restaurant Service: (설계에 따라)
PaymentApproved또는OrderCreated를 구독 → 주문 수락/거절- 수락:
RestaurantAccepted - 거절:
RestaurantRejected
- 수락:
SAGA 오케스트레이터(주문 or 별도 SAGA 서비스): 이벤트를 모아 주문 상태 전이 결정
APPROVED: 결제 승인 + 레스토랑 수락이 충족REJECTED: 결제 거절 또는 레스토랑 거절 발생
핵심: 각 서비스는 자기 DB 업데이트만 하고, 최종 상태 전이는 SAGA가 이벤트를 근거로 한다.
실패 이벤트 흐름 결론: “실패는 ‘한 번의 에러’가 아니라 ‘중복/재처리/순서 꼬임’까지 포함한 운영 사건으로 설계해야 한다.”
실패 흐름 A: 결제 실패
- Payment가
PaymentRejected발행 - SAGA가 주문을
REJECTED로 전이 - (필요 시) 보상 트랜잭션: 이미 잡아둔 리소스(예: 레스토랑 예약)가 있으면 취소 이벤트 발행
실패 흐름 B: 중복 처리(같은 이벤트가 2번 옴)
- 원인: at-least-once 전달, 컨슈머 재시작, 리밸런싱
대응:
- Idempotency Key(예:
orderId+eventType+version)로 “이미 처리했으면 무시” - 컨슈머 측 처리 로그/인박스 테이블(선택)로 중복 방지
- 상태 전이도 “현재 상태에서 가능한 전이만 허용” (state machine)
- Idempotency Key(예:
실패 흐름 C: 재처리(Replay) 시 과거 이벤트가 다시 적용됨
- 원인: 버그 수정 후 재처리, 읽기 모델 재구축
대응:
- 이벤트에 버전/타임스탬프/시퀀스 포함
- 읽기 모델은 멱등 업서트(upsert)로 구축
- “과거 이벤트 재적용”이 안전한 데이터 모델로 설계(append-only / 상태머신)
패턴 개입 지점 맵 결론: “각 패턴은 ‘복잡해 보이게 만드는 장식’이 아니라, 특정 실패를 제거하는 장치다.”
SAGA: 상태 전이 조율은 어디서 일어나는가
- 위치: 보통 Order Service 내부(오케스트레이션) 또는 별도 SAGA Orchestrator 서비스
하는 일:
OrderCreated이후 필요 이벤트를 기다리고(결제/레스토랑)- 이벤트 결과로 다음 커맨드/보상 커맨드를 트리거
- “최종 상태”를 단일 소스(Order)에 기록
Outbox: 어떤 원자성 문제를 막는가
- 막는 문제: “DB에 주문은 저장됐는데 이벤트 발행은 실패” 또는 그 반대(이벤트만 나가고 DB 반영 실패)
해결 방식:
- DB 트랜잭션 안에서 (1) 상태 변경 + (2) Outbox 레코드 저장을 함께 커밋
- 이후 별도 프로세스가 Outbox를 Kafka로 전달 → 결과: 상태와 이벤트의 불일치 가능성을 구조적으로 제거
CQRS: 읽기 모델을 왜 분리하는가
분리하는 이유:
- 쓰기 모델(DDD aggregate)은 정합성/불변식이 핵심이라 조회 최적화와 충돌
- 운영에서는 “주문 현황/검색/필터/집계”가 더 무겁고 다양
방식:
- Kafka 이벤트를 구독해 Read DB(머티리얼라이즈드 뷰)를 구축
- 조회 API는 Read DB만 본다 → 결과: 쓰기 모델 보호 + 조회 성능/기능 확장
“왜 Kafka를 이벤트 저장소로 보는가” 결론: Kafka는 ‘저장→재생→재처리’가 가능한 로그이기 때문에, 운영에서 재현성과 복구력을 준다.
Kafka를 단순 메시지 큐가 아니라 로그 저장소처럼 바라보는 관점:
- 저장(Store): 토픽에 이벤트가 순서(파티션 단위)대로 축적된다.
- 재생(Replay): 컨슈머 오프셋을 되돌리면 과거 이벤트를 다시 읽어 읽기 모델 재구축 가능.
- 재처리(Reprocess): 버그 수정/정책 변경 후 동일 이벤트를 재적용해 상태/뷰를 재생성 가능.
단, 전제: 이벤트 설계가 재처리를 견디는 형태(멱등/버전/상태머신)여야 한다.
이벤트 타임라인(ASCII) 결론: “토픽 설계의 핵심은 키(파티션 기준)로 순서를 보장하고, 컨슈머는 멱등으로 버틴다.”
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
Time →
Topic: order-events
key=orderId
[OrderCreated(orderId=O1, v1)] ---> consumed by Payment, Saga, ReadModel
[OrderCreated(orderId=O2, v1)]
Topic: payment-events
key=orderId
[PaymentApproved(orderId=O1, v1)] ---> consumed by Saga, ReadModel
[PaymentRejected(orderId=O2, v1)]
Topic: restaurant-events
key=orderId
[RestaurantAccepted(orderId=O1, v1)] ---> consumed by Saga, ReadModel
Consumers:
- SagaConsumer (ensures state transitions are valid + idempotent)
- ReadModelConsumer (upsert by orderId + version)
- PaymentConsumer (idempotent charge by paymentAttemptId)
패턴 비교표 결론: “도입은 ‘문제-비용-대안-금지 조건’으로만 판단해야 한다.”
| 패턴 | 해결하는 문제 | 도입 비용 | 대안 | 언제 쓰지 말아야 하나 |
|---|---|---|---|---|
| 클린/육각형 | 도메인 로직을 프레임워크/IO에서 분리해 변경·테스트 비용 감소 | 구조/추상화 비용, 초기 생산성 저하 | 단순 레이어드 + 모듈 경계 | 팀이 유지할 설계 규율이 없고, 단기 납기만 최우선일 때 |
| DDD(집중 적용) | 복잡한 비즈니스 규칙을 모델로 고정, 경계(바운디드 컨텍스트) 확립 | 모델링 비용, 용어 합의 비용 | CRUD 중심 + 일부 규칙은 서비스 레이어 | 도메인이 단순하고 변경도 적은데 “멋”으로 넣을 때 |
| SAGA | 분산 트랜잭션 없이 서비스 간 상태 일관성(최종) 확보 | 상태머신/보상 설계, 운영 복잡도 | 2PC(현실성 낮음), 동기 오케스트레이션(강결합) | 보상 정의가 불가능(되돌릴 수 없음)한 업무인데 강제로 넣을 때 |
| Outbox | DB 변경과 이벤트 발행의 원자성/신뢰성 | 릴레이/CDC 구성, 운영 비용 | Dual-write(위험), 트랜잭션 메시징(제한적) | 이벤트가 “있어도 그만”인 로깅 수준인데 과도하게 도입할 때 |
| CQRS | 조회 성능/복잡한 검색/집계 분리, 쓰기 모델 보호 | 읽기 모델 운영/동기화 비용 | 읽기 전용 캐시/인덱스, 단일 DB 최적화 | 조회 요구가 단순한데 읽기 모델을 과도하게 늘릴 때 |
| Kafka | 이벤트 로그 기반 비동기 통합, 리플레이/재처리 | 토픽/파티션/운영(모니터링) 비용 | RabbitMQ 등 큐, 동기 API | 이벤트를 “정확히 한 번” 전제로 설계하고 운영 복잡도를 감당 못할 때 |
| K8s/GKE | 배포/스케일링/운영 표준화 | 학습·관측·보안·비용 | VM/Managed PaaS | 트래픽/팀 규모가 작고 운영 역량이 없는데 “표준”이라서 올릴 때 |
다음 글을 읽는 순서 결론: “경계→모델→신뢰성→일관성→조회→운영 순으로 들어가야 길을 잃지 않는다.”
- 클린 & 육각형: “도메인 로직과 외부 IO를 분리하는 경계”를 얻어라.
- DDD: “주문/결제/레스토랑/고객의 언어와 상태머신”을 얻어라.
- Kafka 이벤트 설계: “토픽/키/버전/멱등/오프셋”으로 운영 설계를 얻어라.
- Outbox: “DB와 이벤트의 원자성”을 얻어라.
- SAGA: “상태 전이와 보상 트랜잭션 설계”를 얻어라.
- CQRS: “읽기 모델/머티리얼라이즈드 뷰 구축과 재생성”을 얻어라.
- K8s/GKE 운영: “배포/관측/장애 대응의 현실”을 얻어라.
재학습 체크리스트(10개)
- 주문/결제/레스토랑/고객 각각의 소유 데이터를 한 문장으로 말할 수 있는가
- Order 상태머신에서 허용 전이/금지 전이를 정의했는가
- 이벤트는 “사실”인가 “명령”인가(이벤트-커맨드 구분)
- Kafka 토픽의 키를 orderId로 고정해야 하는 이유를 설명할 수 있는가
- 컨슈머 중복 처리 시나리오에서 멱등 전략(키/저장/전이검증)이 있는가
- Outbox가 막는 Dual-write 실패 케이스를 2가지 이상 말할 수 있는가
- SAGA가 기다리는 조건과 보상 트리거 조건을 명시했는가
- CQRS 읽기 모델이 재구축 가능하도록 설계되었는가
- 재처리(replay) 시 버전/순서/최종성 문제가 생길 지점을 알고 있는가
- 운영 관점에서 “관측해야 할 것”(지연, DLQ, consumer lag, 재시도 폭주)을 적을 수 있는가
흔한 오해 / 실수 3가지
- 이 글을 “요약본”으로만 소비하는 실수
- 문제: 전체 구조만 대충 훑고 바로 개별 패턴 글로 이동
- 결과: 이후 글에서 “왜 이 패턴이 여기서 나오는지” 계속 길을 잃음
- 교정 기준: 0편은 요약이 아니라 좌표계 설정 문서다.
- 패턴을 독립 기술로 분리해서 이해하는 오해
- 문제: DDD, Kafka, SAGA, CQRS를 각각 따로 학습
- 결과: 실제 시스템에서 결합 지점이 불명확
- 교정 기준: 모든 패턴은 같은 실패를 다른 각도에서 막는 장치다.
- 정상 흐름만 보고 설계를 끝내는 사고
- 문제: “주문 → 결제 → 승인”만 머릿속에 그림
- 결과: 장애/중복/재처리 시 설계 붕괴
- 교정 기준: 이 지도는 실패 흐름을 기준으로 읽어야 한다.
대표 실패 시나리오 3가지
- 이벤트가 사라져서 시스템이 멈추는 경우
- 상황: 주문은 생성됐지만 결제 서비스가 영원히 반응하지 않음
- 원인: DB 커밋과 이벤트 발행 불일치
- 결과: 재처리 불가, 수동 데이터 조작
- 대응: Outbox + Kafka를 “저장된 사실”로 취급
- 상태 전이가 중복·역전되는 경우
- 상황: 승인/거절 이벤트가 여러 번 또는 순서 뒤집혀 도착
- 원인: 분산 환경에서 순서·중복을 가정하지 않은 설계
- 결과: 불가능한 상태 조합 발생
- 대응: SAGA + 상태 머신 + 멱등 처리
- 조회 요구가 도메인을 파괴하는 경우
- 상황: 리스트/검색/집계 때문에 Aggregate가 비대해짐
- 원인: 읽기와 쓰기 책임 미분리
- 결과: 변경 비용 폭증
- 대응: CQRS로 “결정 모델” 보호
실무 체크리스트 (전체 아키텍처 지도 점검)
- 이 시스템의 주요 실패 시나리오 3가지를 말할 수 있는가
- 각 서비스(주문/결제/레스토랑/고객)의 결정 책임이 명확한가
- 이벤트를 “메시지”가 아니라 저장 가능한 사실로 취급하는가
- DB 커밋과 이벤트 발행의 원자성을 보장하는가
- 중복·재처리가 “정상 흐름”으로 설계돼 있는가
- 상태 전이가 코드로 검증되는가(상태 머신)
- SAGA가 실패 복구 시나리오를 명시적으로 표현하는가
- 조회 요구가 도메인 모델을 왜곡하지 않는가
- 읽기 모델을 다시 만들 수 있는가(replay 가능성)
- 각 패턴의 도입 이유를 실패 기준으로 설명할 수 있는가
- 이 지도를 보고 신규 팀원이 전체 흐름을 설명할 수 있는가
- “이 패턴을 빼면 어떤 실패가 다시 발생하는가?”에 답할 수 있는가
한 줄 요약
이 아키텍처는 “로컬 트랜잭션 + 이벤트 로그(Kafka) + Outbox 신뢰성 + SAGA 상태조율 + CQRS 조회분리”로, 분산 환경에서 주문 수명주기를 운영 가능한 형태로 고정한다.
