Post

MSA 전체 아키텍처 지도

주문 수명주기를 중심으로 로컬 트랜잭션·Kafka 이벤트·Outbox·SAGA·CQRS를 한 장에 묶어 운영 가능한 MSA 지도를 제시

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)은 여러 서비스의 로컬 판단이 모여 확정된다.”

정상 흐름: 주문 생성 → 결제 → 승인/거절

  1. Order Service: CreateOrder 커맨드 처리

    • Order DB에 PENDING 주문 저장
    • 같은 트랜잭션에서 OutboxOrderCreated 기록
  2. Outbox Relay/CDC: Outbox를 읽어 Kafka에 OrderCreated 발행
  3. Payment Service: OrderCreated 구독 → 결제 시도

    • 결제 성공 시 PaymentApproved 이벤트 발행
    • 결제 실패 시 PaymentRejected 이벤트 발행
  4. Restaurant Service: (설계에 따라) PaymentApproved 또는 OrderCreated를 구독 → 주문 수락/거절

    • 수락: RestaurantAccepted
    • 거절: RestaurantRejected
  5. SAGA 오케스트레이터(주문 or 별도 SAGA 서비스): 이벤트를 모아 주문 상태 전이 결정

    • APPROVED: 결제 승인 + 레스토랑 수락이 충족
    • REJECTED: 결제 거절 또는 레스토랑 거절 발생

핵심: 각 서비스는 자기 DB 업데이트만 하고, 최종 상태 전이는 SAGA가 이벤트를 근거로 한다.


실패 이벤트 흐름 결론: “실패는 ‘한 번의 에러’가 아니라 ‘중복/재처리/순서 꼬임’까지 포함한 운영 사건으로 설계해야 한다.”

실패 흐름 A: 결제 실패

  • Payment가 PaymentRejected 발행
  • SAGA가 주문을 REJECTED로 전이
  • (필요 시) 보상 트랜잭션: 이미 잡아둔 리소스(예: 레스토랑 예약)가 있으면 취소 이벤트 발행

실패 흐름 B: 중복 처리(같은 이벤트가 2번 옴)

  • 원인: at-least-once 전달, 컨슈머 재시작, 리밸런싱
  • 대응:

    • Idempotency Key(예: orderId + eventType + version)로 “이미 처리했으면 무시”
    • 컨슈머 측 처리 로그/인박스 테이블(선택)로 중복 방지
    • 상태 전이도 “현재 상태에서 가능한 전이만 허용” (state machine)

실패 흐름 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(현실성 낮음), 동기 오케스트레이션(강결합)보상 정의가 불가능(되돌릴 수 없음)한 업무인데 강제로 넣을 때
OutboxDB 변경과 이벤트 발행의 원자성/신뢰성릴레이/CDC 구성, 운영 비용Dual-write(위험), 트랜잭션 메시징(제한적)이벤트가 “있어도 그만”인 로깅 수준인데 과도하게 도입할 때
CQRS조회 성능/복잡한 검색/집계 분리, 쓰기 모델 보호읽기 모델 운영/동기화 비용읽기 전용 캐시/인덱스, 단일 DB 최적화조회 요구가 단순한데 읽기 모델을 과도하게 늘릴 때
Kafka이벤트 로그 기반 비동기 통합, 리플레이/재처리토픽/파티션/운영(모니터링) 비용RabbitMQ 등 큐, 동기 API이벤트를 “정확히 한 번” 전제로 설계하고 운영 복잡도를 감당 못할 때
K8s/GKE배포/스케일링/운영 표준화학습·관측·보안·비용VM/Managed PaaS트래픽/팀 규모가 작고 운영 역량이 없는데 “표준”이라서 올릴 때

다음 글을 읽는 순서 결론: “경계→모델→신뢰성→일관성→조회→운영 순으로 들어가야 길을 잃지 않는다.”

  1. 클린 & 육각형: “도메인 로직과 외부 IO를 분리하는 경계”를 얻어라.
  2. DDD: “주문/결제/레스토랑/고객의 언어와 상태머신”을 얻어라.
  3. Kafka 이벤트 설계: “토픽/키/버전/멱등/오프셋”으로 운영 설계를 얻어라.
  4. Outbox: “DB와 이벤트의 원자성”을 얻어라.
  5. SAGA: “상태 전이와 보상 트랜잭션 설계”를 얻어라.
  6. CQRS: “읽기 모델/머티리얼라이즈드 뷰 구축과 재생성”을 얻어라.
  7. K8s/GKE 운영: “배포/관측/장애 대응의 현실”을 얻어라.

재학습 체크리스트(10개)

  1. 주문/결제/레스토랑/고객 각각의 소유 데이터를 한 문장으로 말할 수 있는가
  2. Order 상태머신에서 허용 전이/금지 전이를 정의했는가
  3. 이벤트는 “사실”인가 “명령”인가(이벤트-커맨드 구분)
  4. Kafka 토픽의 키를 orderId로 고정해야 하는 이유를 설명할 수 있는가
  5. 컨슈머 중복 처리 시나리오에서 멱등 전략(키/저장/전이검증)이 있는가
  6. Outbox가 막는 Dual-write 실패 케이스를 2가지 이상 말할 수 있는가
  7. SAGA가 기다리는 조건과 보상 트리거 조건을 명시했는가
  8. CQRS 읽기 모델이 재구축 가능하도록 설계되었는가
  9. 재처리(replay) 시 버전/순서/최종성 문제가 생길 지점을 알고 있는가
  10. 운영 관점에서 “관측해야 할 것”(지연, DLQ, consumer lag, 재시도 폭주)을 적을 수 있는가

흔한 오해 / 실수 3가지

  1. 이 글을 “요약본”으로만 소비하는 실수
    • 문제: 전체 구조만 대충 훑고 바로 개별 패턴 글로 이동
    • 결과: 이후 글에서 “왜 이 패턴이 여기서 나오는지” 계속 길을 잃음
    • 교정 기준: 0편은 요약이 아니라 좌표계 설정 문서다.
  2. 패턴을 독립 기술로 분리해서 이해하는 오해
    • 문제: DDD, Kafka, SAGA, CQRS를 각각 따로 학습
    • 결과: 실제 시스템에서 결합 지점이 불명확
    • 교정 기준: 모든 패턴은 같은 실패를 다른 각도에서 막는 장치다.
  3. 정상 흐름만 보고 설계를 끝내는 사고
    • 문제: “주문 → 결제 → 승인”만 머릿속에 그림
    • 결과: 장애/중복/재처리 시 설계 붕괴
    • 교정 기준: 이 지도는 실패 흐름을 기준으로 읽어야 한다.

대표 실패 시나리오 3가지

  1. 이벤트가 사라져서 시스템이 멈추는 경우
    • 상황: 주문은 생성됐지만 결제 서비스가 영원히 반응하지 않음
    • 원인: DB 커밋과 이벤트 발행 불일치
    • 결과: 재처리 불가, 수동 데이터 조작
    • 대응: Outbox + Kafka를 “저장된 사실”로 취급
  2. 상태 전이가 중복·역전되는 경우
    • 상황: 승인/거절 이벤트가 여러 번 또는 순서 뒤집혀 도착
    • 원인: 분산 환경에서 순서·중복을 가정하지 않은 설계
    • 결과: 불가능한 상태 조합 발생
    • 대응: SAGA + 상태 머신 + 멱등 처리
  3. 조회 요구가 도메인을 파괴하는 경우
    • 상황: 리스트/검색/집계 때문에 Aggregate가 비대해짐
    • 원인: 읽기와 쓰기 책임 미분리
    • 결과: 변경 비용 폭증
    • 대응: CQRS로 “결정 모델” 보호

실무 체크리스트 (전체 아키텍처 지도 점검)

  1. 이 시스템의 주요 실패 시나리오 3가지를 말할 수 있는가
  2. 각 서비스(주문/결제/레스토랑/고객)의 결정 책임이 명확한가
  3. 이벤트를 “메시지”가 아니라 저장 가능한 사실로 취급하는가
  4. DB 커밋과 이벤트 발행의 원자성을 보장하는가
  5. 중복·재처리가 “정상 흐름”으로 설계돼 있는가
  6. 상태 전이가 코드로 검증되는가(상태 머신)
  7. SAGA가 실패 복구 시나리오를 명시적으로 표현하는가
  8. 조회 요구가 도메인 모델을 왜곡하지 않는가
  9. 읽기 모델을 다시 만들 수 있는가(replay 가능성)
  10. 각 패턴의 도입 이유를 실패 기준으로 설명할 수 있는가
  11. 이 지도를 보고 신규 팀원이 전체 흐름을 설명할 수 있는가
  12. “이 패턴을 빼면 어떤 실패가 다시 발생하는가?”에 답할 수 있는가

한 줄 요약

이 아키텍처는 “로컬 트랜잭션 + 이벤트 로그(Kafka) + Outbox 신뢰성 + SAGA 상태조율 + CQRS 조회분리”로, 분산 환경에서 주문 수명주기를 운영 가능한 형태로 고정한다.


This post is licensed under CC BY 4.0 by the author.