해당 글을 읽기 전 먼저 이전 포스팅(MSA 운영에서 발생하는 이슈들)에 대해서 읽고 오시길 바랍니다!
🌏 인트로
앞선 포스팅에서 MSA를 실제로 운영할 때 자연스럽게 발생하는 대표적인 문제들을 정리했습니다.
✅ MSA 운영 이슈들
- 조인 불가: 단일 DB가 아니기 때문에 서비스 간 조인이 불가능합니다.
- 트랜잭션 불확장: 트랜잭션이 서비스 경계를 넘어서 확장되지 않습니다.
- 장애 전파: 한 서비스의 장애가 네트워크 타고 전체로 전파됩니다.
- 이벤트 불일치: 이벤트 전달이 실패하거나 순서가 바뀔 수 있습니다.
- 조회 성능 저하: 조회가 여러 서비스 호출로 변하면서 성능 문제가 생깁니다.
이번 글에서는 이러한 문제들을 해결하기 위해 현업에서 가장 널리 사용되는 패턴들을 정리해보려고 합니다.
SAGA, CQRS, Outbox, Bulkhead, Circuit Breaker, Event Sourcing은 MSA 아키텍처를 구성할 때 등장하는 개념이며, 이번 포스팅에서 이게 왜 필요하고 어떻게 문제를 해결하는지 정리해보겠습니다.
✅ 상황을 가정해볼게용
쉽게 이해하기 위해서 아래에서는 이 상황에 대입해서 생각해보겠습니다. (지난 포스팅에서의 상황과 동일합니다)
예를 들어 커머스에서 흔히 볼 수 있는 구조를 생각해봅시당

- 주문 서비스
- 재고 서비스
- 매장 서비스 (오프라인 매장 재고 포함)
- 결제 서비스
- 배송 서비스
🌏 SAGA 패턴
✅ 해결하려는 문제: 트랜잭션 적용 불가
MSA에서는 하나의 비즈니스를 처리할 때 여러 서비스가 연쇄적으로 동작합니다.
예를 들어 주문 서비스 → 결제 서비스 → 재고 서비스 → 배송 서비스 같은 흐름이 있는데, 이 과정 전체를 하나의 트랜잭션으로 묶을 수 없습니다.
그 결과 중간 단계에 실패가 발생하면 앞 단계에서 이미 처리된 성공 작업을 되돌릴 방법이 없는 문제가 생깁니다.
✅ 해결 방법: 실패하면 앞단 성공 작업을 취소하는 보상 API를 실행한다
SAGA 패턴은 비즈니스 트랜잭션을 여러 개의 로컬 트랜잭션으로 나누고, 실패 시 보상 트랜잭션을 수행하여 상태를 되돌리는 방식입니다.
실패하면 앞단 성공 작업을 취소하는 API를 순서대로 호출하는 구조입니다.

- 성공 흐름: T1 → T2 → T3
- 실패 흐름: T3 실패 → 보상(T2 취소) → 보상(T1 취소)
✅ 상황에 적용
예를 들어 주문 흐름이 이렇게 되어 있다고 해봅시다.
- 주문 생성 (T1)
- 결제 승인 (T2)
- 재고 차감 (T3)
그런데 재고 차감(T3)에서 실패했다면???
- 결제 승인 취소(T2 보상)
- 주문 취소(T1 보상)
이렇게 앞에서 했던 일을 역순으로 취소하는 API를 호출하는 게 바로 SAGA입니다.
SAGA에서는 보상 트랜잭션을 누가 실행하느냐에 따라 두 가지 방식이 있습니다.
- 오케스트레이션: 중앙 orchestrator가 취소 API들을 순차적으로 실행 (재고 서비스는 실패했다는 신호만 보냄)
- 코레오그래피: 서비스끼리 이벤트 기반으로 보상 트랜잭션을 이어나감
두 방식 모두 역방향으로 취소가 일어나지만, 누가 그 흐름을 조율하느냐가 다릅니다.
무튼 이렇게 보상 트랜잭션이 실행되면서 전체 비즈니스 일관성이 유지됩니다.
🌏 CQRS 패턴
✅ 해결하려는 문제: 조회 성능 저하
MSA에서는 서비스마다 DB를 따로 가지고 있기 때문에 조회 시 여러 서비스로부터 데이터를 조합해야 합니다.
커머스 프론트 “주문 상세” 화면을 보려 할 때 필요한 정보는 보통 이렇습니다.
- 주문 서비스: 주문 ID, 주문일, 주문 상태, 총액
- 결제 서비스: 결제 수단, 결제 승인 여부, 승인 시간
- 재고 서비스: 출고 준비 가능 여부
- 매장 서비스: 매장 재고가 있는지 여부
- 배송 서비스: 배송 상태, 배송 예정일
- 추천 서비스: 관련 추천 상품
모놀리식이라면 단일 DB에서 조인 한 번이면 끝날 문제지만, 각 서비스가 자기 DB를 가지고 있기 때문에 조인이 불가능합니다.
그래서 프론트는 이렇게 호출한 결과를 합쳐야만 합니다.
/order/{id}
/payment/{orderId}
/inventory/check/{orderId}
/store-stock/{itemId}
/shipment/{orderId}
/recommendation/order/{id}
즉, 6~7개 서비스의 데이터를 합쳐야 주문 상세 화면 하나가 그려집니다. = 지연폭발(latency explosion)
이를 서비스 버전의 N+1 문제라고 부르며, 조회 응답 속도가 크게 저하됩니다.
✅ 해결 방법: 조회용 테이블을 아예 따로 만들어둔다
CQRS(Command Query Responsibility Segregation)는 쓰기 모델과 조회 모델을 분리하는 방식입니다.
- 쓰기 모델: 정규화, 도메인 규칙 기반 저장
- 조회 모델: 화면에 최적화된 의도적인 비정규화 구조 (Redis, ES, 별도 DB 활용)
CQRS의 조회 모델은 여러 서비스의 상태를 실시간 이벤트 기반으로 미리 합쳐둔 비정규화 데이터 구조이며, 프론트는 이 모델 한 번만 조회하면 화면 전체를 구성할 수 있습니다.
조회 모델은 여러 서비스의 데이터를 합쳐놓은 형태일 수 있어 서비스 간 조인이 필요 없어져 조회 성능이 크게 개선됩니다.
✅ 상황에 적용
CQRS에서는 조회 전용(read model) DB를 별도로 구성합니다. 이 DB는 정규화되어 있지 않고, 화면에 최적화된 형태로 구조가 잡혀 있습니다. 주문 상세에 필요한 데이터를 미리 한 테이블에 담아두는 식입니다.
order_read_model
────────────────────────────────────────────
order_id
order_date
order_status
total_amount
payment_method
payment_approved_at
inventory_available
store_stock_available
shipment_status
estimated_delivery
recommended_items (JSON 배열)
이렇게 의도적으로 비정규화된 테이블에는 화면에 필요한 모든 정보가 한 행(row)에 들어있습니다.
CQRS에서는 이 화면에 필요한 조회 모델을 미리 만들어두고, 프론트는 조회 모델 DB 한 번만 조회하면 됩니다.
✅ 그러면 이 Read Model은 누가 채우나요?
이벤트로 채웁니다.
쓰기 모델(도메인 서비스들)이 각각 이런 이벤트를 발행합니다
- 주문 서비스 →
OrderCreated,OrderUpdated - 결제 서비스 →
PaymentApproved - 재고 서비스 →
InventoryChecked - 배송 서비스 →
ShipmentUpdated - 추천 서비스 →
RecommendationUpdated

CQRS의 조회 모델 빌더는 이 이벤트들을 순서대로 소비하면서 read model DB를 채웁니다.
(실제로는 이벤트 순서 보장 문제가 있어 별도 정렬·타임스탬프 전략 필요합니다. )
즉, 각 서비스가 이벤트를 흘려보내고 조회 모델 빌더가 이를 받아 READ 모델을 점진적으로 완성하는 구조입니다.
🌏 Outbox 패턴
✅ 해결하려는 문제: 이벤트 불일치 (누락 방지)
이벤트 기반 아키텍처에서는 DB 저장과 Kafka에 이벤트 발행이 별개 시스템입니다. 따라서 다음과 같은 일관성 깨짐 문제가 발생할 수 있습니다.
- 주문 DB 저장 성공
- Kafka로 OrderCreated 이벤트 발행 실패
이러면 다른 서비스들은 주문 생성 사실을 평생 모르게 됩니다. 이건 데이터 일관성 붕괴, 재고/배송 오류, 커머스 장애까지 이어지는 심각한 문제입니다.
기존에 트랜잭션을 통해서 atomic 하게 운영되던 부분을 이벤트 기반으로 비동기적으로 나누어 놓았는데, 모든 정합성이 깨지면서도 그게 깨지는지도 알기 어려운 상황,,,
✅ 해결 방법: 이벤트 발행할 내용을 먼저 우리 DB에 atomic하게 기록해두고 나중에 Kafka로 보낸다
Kafka에 보냈는지 여부를 우리 DB에 직접 기록해두고, 그걸 기반으로 안전하게 재발행하는 구조
DB에 저장한 사실과 이벤트 발행 여부를 반드시 일치시키기 위해, 이벤트를 발행할 내용을 먼저 우리 DB에 써두고 그것을 나중에 Kafka로 보내는 방식입니다.

1) 먼저 “보낼 이벤트”를 Outbox 테이블에 저장
이 단계는 주문 저장과 같은 DB 트랜잭션 안에 포함되어 atomicity가 보장됩니다.
- 주문 저장 성공 = Outbox 이벤트 저장 성공
- 주문 저장 실패 = Outbox 이벤트 저장 rollback
따라서 DB는 항상 완전한 상태를 가지게 됩니다.
2) Outbox Processor가 DB의 이벤트를 읽어서 Kafka로 발행
Kafka가 잠깐 장애여도 상관 없습니다.
3) Kafka 발행 성공하면 Outbox 테이블의 status를 SENT로 업데이트
Kafka가 장애였다면 계속 재시도합니다.
4) Outbox 테이블에서 오래된 이벤트는 삭제하거나 보관 전략 실행
Outbox Processor는 실패하면 재시도 가능하도록 설계하고, 이 방법을 사용한다면 이벤트 누락을 방지할 수 있습니다.
✅ 상황에 적용
주문 서비스가 주문 생성 후 이벤트를 발행하는 경우라면
- 주문 서비스는 주문 DB 저장 + Outbox 테이블에
OrderCreated이벤트 기록 - 동일 트랜잭션이므로 둘 중 하나만 성공할 수 없음
- Outbox Processor가 Outbox 이벤트를 Kafka로 안전하게 전달
- 재고 서비스, 배송 서비스는 Kafka로부터 이벤트를 안전하게 수신
이렇게 하면 주문 정보 저장과 이벤트 발행의 일관성이 깨지지 않습니다.
🌏 Bulkhead 패턴
✅ 해결하려는 문제: 장애 전파
MSA에서는 한 서비스의 장애가 다른 서비스로 쉽게 전파됩니다.
예를 들어 재고 서비스가 느려지면 주문 서비스의 모든 스레드가 재고 응답을 기다리다가 주문 서비스 전체가 멈추는 상황이 발생합니다.
✅ 해결 방법: 서비스마다 리소스 풀을 분리한다
Bulkhead 패턴은 서비스마다 리소스 풀을 분리해 격리하는 구조입니다.
말 그대로 격벽을 만들어서 하나의 기능이 다른 기능을 무너뜨리지 못하게 하는 방식입니다.
하나의 애플리케이션 내부라면 주문 서비스가 재고 서비스 API를 호출하든, 결제 서비스를 호출하든, 매장 서비스를 호출하든 모두 같은 스레드 풀에서 실행됩니다. 그래서 한 요청이 외부 API에서 오래 대기하면 그 요청을 담당하던 스레드가 계속 묶이게 되고, 다른 서비스를 호출할 수도 없게 됩니다.
Bulkhead는 외부 API 호출을 요청 처리 스레드와 분리된 전용 스레드 풀에서 실행해 장애를 격리하는 패턴입니다. 특정 기능이 느려져도 그 전용 스레드 풀만 막히기 때문에 서비스 전체 스레드가 고갈되지 않습니다.
결과적으로 한 기능의 장애가 다른 기능으로 전파되지 않고, 서비스 전체가 멈추는 상황을 예방할 수 있습니다.
✅ 상황에 적용
사용자가 주문 요청을 보낼 때, 다음과 같은 상황을 가정해 보겠습니당
- 결제 API: 정상
- 매장 재고 조회 API: 정상
- 재고 서비스 API: 느림
[Bulkhead 없음 (기본 구조)]
- 주문 서비스 스레드 풀 200개
- 재고 API가 느려서 150개 스레드가 재고 응답 기다리는 중
- 남은 50개도 매장/결제/배송 요청이 들어오면 금방 스레드 고갈
- 결과: 주문 서비스 전체 503 상태(응답 불가)
[Bulkhead 있음]
- 재고 API 스레드 풀: 20개
- 매장 API 스레드 풀: 20개
- 배송 API 스레드 풀: 20개
- 주문 서비스 main 스레드 풀(Tomcat): 200개
재고 API가 느려진다 할지라도, 재고 전용 스레드 20개만 대기하고 나머지 180개의 요청 스레드는 동작하기 때문에 주문 조회, 주문 취소, 결제 취소 등 정상 작동하게 됩니다.
재고 전용 스레드 풀이 고갈되더라도, 주문 서비스의 다른 기능(주문 조회, 결제 취소 등)은 정상 처리되어 전체 장애가 전파되지 않습니다.
🌏 Circuit Breaker 패턴
✅ 해결하려는 문제: 장애 전파
고장난 서비스로 계속 요청을 보내면 호출하는 쪽의 스레드가 모두 묶이면서 전체 장애가 발생합니다.
특히 주문–결제–재고–배송처럼 강하게 연결된 서비스에서는 연쇄 장애가 쉽게 발생합니다.
✅ 해결 방법: 실패율이 높아지면 회로를 강제로 끊어 요청하지 않고 즉시 실패한다
Circuit Breaker는 일정 비율 이상 실패율이 발생하면 회로를 강제로 끊어(Open 상태) 해당 서비스로의 요청을 즉시 실패시키는 방식입니다.
- 실패율이 높다면 → Open → 요청 즉시 실패
- 일정 시간 후 → Half-Open → 테스트 호출 몇 개 시도
- 성공률이 회복되면 → Closed → 정상 호출 복귀
이 구조를 사용하면 장애 전파를 막고 시스템을 보호할 수 있습니다.
✅ 상황에 적용
재고 서비스가 다운된 상황에서 Bulkhead만 있다면 스레드는 일부 보호되지만 끊임없이 재고 API를 재시도하게 되지만, Circuit Breaker가 함께 있으면 일정 실패 이후 재고 호출을 즉시 차단합니다.
주문 서비스는 “재고 시스템 점검 중” 같은 빠른 오류 반환 가능합니다.
(오류가 없다면 베스트겠지만 이건 이미 오류가 났다는 상황 아래에 어떻게 최대한 잘 대처할 수 있는가에 대한 방법입니다)
Bulkhead, Circuit Breaker 두 패턴이 함께 사용될 때 안정성이 크게 올라갑니다.
🌏 Event Sourcing 패턴
✅ 해결하려는 문제: 이벤트 순서/복구 불가 문제
이벤트 기반 구조에서는 최신 상태만 저장하면 다음과 같은 문제가 생깁니다.
- 이벤트 순서 문제
- 상태 복구 불가능
- 감사 로그 부족
- 여러 서비스가 상태를 재구성할 수 없음
이벤트 스트림을 잃으면 전체 상태를 재구성할 수 없는 구조가 되어버립니다.
✅ 해결 방법: 상태 변화의 이벤트를 그대로 기록한다
Event Sourcing은 현재 상태가 아니라 상태 변화의 이벤트를 그대로 기록하는 방식입니다.

예를 들어 주문의 상태를 하나의 필드로 저장하는 것이 아니라,
- OrderCreated
- PaymentApproved
- ShipmentStarted
- ShipmentDelivered
이런 이벤트를 순서대로 append-only 형태로 기록합니다. 시스템이 재시작되거나 새로운 서비스가 합류해도 이 이벤트 스트림을 replay하면 최신 상태를 재구성할 수 있습니다.
✅ 상황에 적용
배송 서비스가 장애로 일시 중단되었다고 가정해봅시다.
- 주문 서비스는
OrderCreated이벤트를 기록 - 결제 서비스는
PaymentApproved이벤트를 기록 - 배송 서비스가 재시작되면 Kafka의 이벤트 스트림을 순서대로 읽고 상태를 다시 정확하게 복원할 수 있습니다.
Event Sourcing은 CQRS의 조회 모델 구성과 함께 조합이 좋슴니당
🌏 결론
오늘 포스팅에 등장한 패턴들은 MSA 환경에서 반복적으로 등장하는 이슈들에 대한 해결 방법입니다.
- SAGA: 트랜잭션이 서비스 간 확장되지 않는 문제를 해결합니다.
- CQRS: 조회가 여러 서비스 호출로 변해 성능이 저하되는 문제를 해결합니다.
- Outbox: 이벤트 전달 실패, 중복, 순서 꼬임 등 이벤트 불일치 문제를 해결합니다.
- Bulkhead: 한 서비스의 장애가 전체로 전파되는 문제를 방지합니다.
- Circuit Breaker: 장애난 서비스로 계속 요청을 보내 발생하는 연쇄 장애를 차단합니다.
- Event Sourcing: 이벤트 순서 문제, 상태 복구 불가 문제를 해결합니다.
각 패턴은 단독으로도 좋지만, 실제 운영에서는 보통 여러 패턴이 결합됩니다.


도움이 되었다면, 공감/댓글을 달아주면 깃짱에게 큰 힘이 됩니다!🌟
비밀댓글과 메일을 통해 오는 개인적인 질문은 받지 않고 있습니다. 꼭 공개댓글로 남겨주세요!
'아키텍처+MSA' 카테고리의 다른 글
| [MSA] MSA 운영에서 발생하는 이슈들: 데이터 일관성/트랜잭션 적용 불가/장애 전파/이벤트 불일치/조회 성능 (0) | 2025.11.27 |
|---|---|
| [아키텍처/캐시] CDN 완전 정복: 개념·헤더·보안, CloudFront vs Cloudflare 비교까지 (0) | 2025.11.21 |
| [아키텍처/캐시] Redis 캐시 완전 정복: 개념·전략·TTL·일관성·모니터링 총정리 (0) | 2025.11.20 |
| [아키텍처] 로드밸런싱 완전 정복: L4/L7 구조부터 SPOF 해결까지 (0) | 2025.11.20 |
| [아키텍처/Kafka] Kafka로 강한 결합 탈출하기: 회원가입 비동기 처리 미니 프로젝트 (0) | 2025.11.05 |