본문 바로가기
역량 UP!/Architecture

MSA에서 분산 트랜잭션 처리는?(Saga+outbox)

by 태하팍 2025. 10. 24.
반응형

 

내가 몰랐던 MSA의 특징?

마이크로서비스를 하는 목적은?  개발하는 속도가 빠릅니다.

조직으로 보면 하나의 서비스를 가지는 조직은 UI specialists, middleware specialists, DBAs이 모두 모여있는 조직 입니다.
커뮤니케이션이 유연하고 빨라서 개발속도가 빠릅니다.
즉, 기술적인 아키텍처만 변경하는게 중요한게 아니라
조직의 구조나 조직의 의사결정방향, 조직의 개발문화 스타일이 변경되야 성공할 수 있습니다.

그림 출처 : https://martinfowler.com/articles/microservices.html

  • Conway's law(콘웨이의 법칙)
    • 소프트웨어 아키텍처의 구조는 소프트웨어를 만드는 팀의 구조를 따라간다.

 

서비스의 세분화는?

서비스화는 크게(모놀리스) 시작하며 분할(MSA) 시킨다.
stateless가 유리함 → 상태를 유지 X 하면 확장성과 가용성에 좋음

처리 상태를 유지하는 경우엔?
ex) 장바구니 
1) 세션 영구화(DB에 저장)
2) Stickey 세션 : 처리상태가 저장돼 있는 서버에 클라이언트요청을 전송 ex) k8s ingress가 기능제공

  • 사용자가 접속한 서버를 계속 고정시키는 방식
  • 즉, 사용자가 처음 붙은 서버가 그 세션을 계속 처리 함.
    • 사용자 A → 서버 1번에 붙음 (장바구니 있음)
    • 사용자 B → 서버 2번에 붙음
  • DB를 거치지 않아 빠르지만 특정서버가 죽으면 세션 날아감.


3) 장바구니를 서비스로 취급 → 상태를 DB에 저장(요청과 상태는 사용자 ID등으로 연결 함.

서비스별로 모듈화가 되었다면 트랜잭션 처리는?

- global 트랜잭션

  • XA(2PC) - 은행같이 미션 크리티컬할 때 사용하면 좋음

- local 트랜잭션을 권장(MSA)

- 서비스마다 개별 DB를 가지고 있음

MSA에서 각 DB에 대한 정합성(동기화)이 어려운 이유?

단일 모놀리스 구조일 때 : 하나의 DB안에 모든 작업이 동일한 트랜잭션 범위 안에서 수행 및 데이터의 정합성은 DB의 ACID특성을 통해 자동으로 보장 됩니다.
예를 들어 주문생성 + 결제완료 = 하나의 트랜잭션 안에서 commit/rollback 됨.

항목 의미 예시
A (Atomicity) 원자성 — “모두 실행되거나, 전혀 실행되지 않거나” 주문 저장 중 결제 실패 → 전체 롤백
C (Consistency) 일관성 — 트랜잭션 전후 DB 상태는 항상 유효 재고가 음수가 되면 안 됨
I (Isolation) 고립성 — 동시에 실행돼도 각각 독립적으로 작동 동시에 결제해도 서로 간섭 없음
D (Durability) 지속성 — 커밋된 데이터는 시스템 장애에도 유지 결제 완료 후 DB crash → 데이터는 남아 있음

MSA 구조일 때 : 서비스별로 DB가 분리 됨(Order, Payment, Inventory 등)
각 서비스는 자기 DB만 책임 짐 → 하나의 트랜잭션으로 묶을 수 없음.

분산DB를 하나의 트랜잭션으로 묶으려면? 즉, 동기화 하려면? → 그래서 나온 개념이 Saga Pattern!

1)코레오그래피(Chreography) - 이벤트 중심 구조    
   - 서비스 간 이벤트로 구현(완정 비동기) : trace가 어려움.
   - 각 서비스가 자신의 DB 트랜잭션을 처리하고 완료되면 이벤트를 발행해서 다음 서비스가 이를 구독 함.
   - 중앙통제자가 없는 완전한 비동기 이벤트 흐름.

2) 오케스트레이션(Orchestration) - 중앙 조정자 중심 구조
   - Saga 오케스트레이터(Orchestrator)라는 특별한 서비스가 transaction 처리를 조율 함.
   -  퍼사드 패턴처럼 서비스간 흐름제어+트랜잭션 흐름을 제어 함.

Saga의 핵심은 Event를 기반으로 서비스들이 연동된다는 점인데 문제가 발생 함.

1) DB 트랜잭션은 성공했는데 이벤트 발행이 실패!
2) 이벤트는 발행했는데 DB 커밋이 실패!
이러한 경우 서비스 간의 정합성이 깨집니다.

그래서 Saga에서는 이러한 문제를 해결하기 위해 DB처리를 적용 후 해당 처리가 완료 됐다는 것을
이벤트를 통해 통지하는데 이것을 Transaction Messaing이라고 부릅니다. 
요약하면 DB커밋과 이벤트 발행을 하나의 트랜잭션처럼 보장하자!

이를 보장하기 위해 Outbox Pattern을 적용 시킵니다.

outbox는 실제 DB의 테이블 입니다.
동작은 보통 Debezium 등 CDC도구가 outbox table을 감시하고 kafka 등 메시지 브로커로 이벤트를 발행 합니다.

outbox pattern은 DB와 이벤트 사이의 틈에서 생기는 정합성 깨짐을 막습니다.

CREATE TABLE outbox (
  event_id     UUID PRIMARY KEY,
  aggregate_id VARCHAR(64) NOT NULL,
  type         VARCHAR(64) NOT NULL,
  payload      JSONB       NOT NULL,
  status       VARCHAR(16) NOT NULL, -- PENDING | SENT | FAILED
  created_at   TIMESTAMP   NOT NULL,
  sent_at      TIMESTAMP
);
CREATE INDEX idx_outbox_pending ON outbox(status, created_at);
-- 여러 워커가 동시에 돌 때 충돌 방지
SELECT * FROM outbox
 WHERE status='PENDING'
 ORDER BY created_at
 FOR UPDATE SKIP LOCKED
 LIMIT 100;

-- 발행 성공 후
UPDATE outbox SET status='SENT', sent_at=now() WHERE event_id=:id;

 

1) 1차 설계
재고부터 확인하고 없으면 리턴하고 주문 서비스 모듈을 안타는게 맞지 않나? 라는 관점에서 설계
그런데 kafka에서 중요한 부분이 key가 같은 키가 와야 함. 그래야 같은 파티션으로 들어가서 순서가 보장 됨.
비동기라서 순서가 중요함. 해당 key는 보통 order_id같은 전체 흐름의 키가 좋음
물론 대용량 트래픽일 경우 키를 만들어서 전달받는 구조도 있을수 있음.(사용하는 곳에서는 사용처만 붙여서 사용)
키 만들다 부하가 걸릴수 있기때문.

내가 그린 기린 그림:)

2) 2차 설계로는  주문 서비스가 가장 먼저 와야하는 구조로 설계
재고의 경우 Redis가 원자성을 확보해주기 때문에 Lua스크립트를 통해 처리를 하되
바로 재고를 갱신하는게 아니라 일단 예약처럼 가용성을 확보 한 뒤 결제 모듈까지 완벽하게 끝나면 Redis와 DB 모두 업데이트를 해준다.
Redis가 분산일 경우 RedLock의 개념도 있으니 참고하자.
재고의 경우 캐시랑 연관이 있으니 참고하자!

2025.08.18 - [역량 UP!/Architecture] - 왜 Cache가 필요한가? 실무에서 꼭 알아야 할 캐시 패턴 10가지!



장애 시 조치 방법

Case01) DB단계 : 서비스 테이블은 성공, outbox 테이블 실패(혹은 반대)
서비스 테이블과 outbox 테이블은 하나의 DB 트랜잭션으로 되어있습니다.
ex) 주문이 들어와 주문 테이블에는 주문이 생성되었는데 outbox에는 이벤트가 없음 → 다른 서비스가 변화 사실을 못 받음.
그래서 두 테이블은 하나의 로컬 트랜잭션으로 묶여야 합니다. 둘 다 커밋 아니면 둘 다 롤백!

그런데 여기에서 중요한 포인트는 롤백은 서비스간 이루어져야하기 때문에 보상 이벤트로 롤백을 진행 합니다.

즉, 새로운 보상 이벤트를 발행해서 롤백을 합니다.

Case02) 발행 단계: outbox → broker에서의 장애

장애
  outbox에는 PENDING상태로 남고, 이벤트가 외부로 못나감(지연은 생기지만 정합성은 깨지지 않음)
해결
1) Message Relay 재시도 → 발행 → 성공 시 status='SENT'
                                                          실패 시 백오프 재시도, 브로커 복구되면 밀린 outbox를 순차 발행.
Backoff Retry는 실패 시 서버로 바로 재시도 하면 시스템이 과부하 됨

2) 프로듀서 설정 : kafka 일경우 - acks=all, enable.idempotence=true 설정으로 중복/손실 위험 축소.
3) 모니터링/알람 : PENDING 적제량, 지연, 시간 알람

Case03) 발행 단계: 발행 중복(Message Relay/프로듀서 재시도로 같은 이벤트 여러번 발행)
장애
  네트워크가 순간 끊겼거나 브로커 응답(ack)이 안옴
  프로듀서는 보내기 실패라고 판단 하고 재시도 함 → 컨슈머가 같은 이벤트를 2번 이상 받음
해결
  컨슈머 멱등처리(필수) : 이벤트마다 event_id(UUID같은 유일값) or aggregate_id, version 포함(ex. orderId=19323, version=1)
                                       이벤트 하나하나를 식별 가능한 단위로 만듬.
 
멱등은 같은 일을 여러 번 해도(중복시도) 결과로 한번만 처리 하는 것.

{
  "event_id": "b2f8f3a1-12ab-4b7d-98a7-234fb39d23ef",
  "order_id": "O123",
  "type": "OrderCreated",
  "version": 1,
  "payload": { ... }
}

 
Case04) 소비 단계: 이벤트는 브로커에 있는데  컨슈머 적용 실패/중단
장애
다른 서비스의 상태가 뒤처짐(정합성 지연)
해결
컨슈머 재시도 → 실패 시 DLQ
운영자 확인 후 재주입(retry topic)
컨슈머는 항상 멱등이므로 재주입해도 안전

참고: https://acet.pe.kr/1089

 

Case05) 소비 단계: 이벤트 순서 꼬임(다른 토픽/다른 파티션에서 순서 달리 도착)
장애
선후 관계 의존 로직이 깨질수 있음

해결
같은 aggregate_id(ex. orderId)로 파티션 키 고정 → 파티션 내 순서 보장

Case06) 소비 단계 :  컨슈머가 다른 이벤트를 두번 적용(코드버그/동시 처리)
해결
Unique 제약 + UPSERT로 결과 테이블을 멱등하게 설계
이미 처리됨 : 플래그 or 결과 스냅샷에 맞춰 변경 없는 업데이트만 수행

서비스간 트랜잭션 추적

  • 서비스마다 개별 DB 즉, 분산 DB를 가지고 있고 데이터를 동기화를 하려면? 
  • 여러 서비스가 호출되는 트랜잭션을 Local transaction id를 이용하여 추적(Saga Pattern)
    • Trace Id : No
    • Span Id : No (호출 순서)
    • 자바의 경우 Thread Local을 사용하여 구현
    • 오픈소스
      • Zipkin : 서비스간의 분산트랜젝션을 추적하고 모니터링 하도록 지원

Jaeger : Uber에서 개발 됨, Elasticsearch등 다양한 백엔드 사용 가능, 대용량 스케일 시스템에 보다 적합함.

반응형