- Published on
모놀리스를 MSA로 쪼갤 때 DB 트랜잭션 깨지는 5가지 패턴
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
레거시 모놀리스를 MSA로 쪼개는 작업은 단순히 코드와 API를 나누는 일이 아닙니다. 모놀리스 시절에는 한 프로세스 안에서 BEGIN 부터 COMMIT 까지 깔끔하게 묶이던 트랜잭션이, 서비스 경계를 넘는 순간 더 이상 “하나의 원자적 작업”으로 보장되지 않습니다. 이때 팀이 무의식적으로 모놀리스 관성을 그대로 가져오면, 장애는 보통 다음 두 가지 형태로 나타납니다.
- 데이터 불일치: 결제는 성공했는데 주문 상태가
PAID가 아니다, 재고는 줄었는데 주문은CANCELLED로 남았다 - 재시도 폭탄: 네트워크 타임아웃 이후 재시도로 인해 중복 결제, 중복 포인트 적립이 발생한다
아래는 실제 마이그레이션에서 반복적으로 등장하는 “DB 트랜잭션이 깨지는 5가지 패턴”과, 각 패턴을 대체하는 설계 옵션입니다.
1) 분산 트랜잭션을 로컬 트랜잭션처럼 착각하는 패턴
증상
모놀리스에서는 다음이 자연스러웠습니다.
- 주문 생성
- 결제 승인
- 재고 차감
- 포인트 적립
이 모든 것을 단일 DB 트랜잭션으로 묶고, 실패하면 ROLLBACK 하면 끝.
MSA에서는 주문 서비스, 결제 서비스, 재고 서비스, 포인트 서비스가 각각 DB를 갖습니다. 그런데도 기존 사고방식을 유지한 채 “이것도 결국 트랜잭션으로 묶으면 되겠지”라고 접근하면, 보통 아래처럼 됩니다.
- 주문 서비스가 결제 서비스 API 호출
- 결제 서비스가 성공 응답
- 주문 서비스가 재고 서비스 API 호출 중 타임아웃
- 주문 서비스는 실패로 판단하고 롤백 처리
- 결제는 이미 승인되어 취소가 필요해짐
왜 깨지나
서비스 간 호출은 네트워크 실패, 타임아웃, 부분 성공이 기본 전제입니다. DB 트랜잭션의 원자성과 격리성은 프로세스 바깥으로 확장되지 않습니다.
대안
- 사가 패턴: 단계별 로컬 트랜잭션과 보상 트랜잭션으로 구성
- 오케스트레이션 또는 코레오그래피 중 하나를 명확히 선택
- “일관성”을 강제하기보다 “수렴”시키는 방향으로 설계
코드 예시: 오케스트레이션 사가(의사 코드)
// Order Orchestrator (pseudo)
async function placeOrder(cmd) {
const orderId = await orderDb.tx(async (tx) => {
const id = await tx.insertOrder({ status: "PENDING" })
await tx.insertSagaState({ orderId: id, step: "ORDER_CREATED" })
return id
})
try {
await paymentApi.authorize({ orderId, amount: cmd.amount })
await inventoryApi.reserve({ orderId, items: cmd.items })
await orderDb.tx(async (tx) => {
await tx.updateOrder(orderId, { status: "CONFIRMED" })
await tx.updateSagaState(orderId, { step: "DONE" })
})
} catch (e) {
// compensation
await paymentApi.cancelIfAuthorized({ orderId })
await inventoryApi.releaseIfReserved({ orderId })
await orderDb.tx(async (tx) => {
await tx.updateOrder(orderId, { status: "FAILED" })
await tx.updateSagaState(orderId, { step: "COMPENSATED" })
})
throw e
}
}
2) 동기 호출 체인으로 “커밋 순서”를 강제하려는 패턴
증상
서비스 A가 서비스 B를 호출하고, B가 서비스 C를 호출하는 방식으로 “순서를 보장”하려고 합니다. 실제로는 순서가 아니라 장애 전파만 강해집니다.
- A가 B 호출
- B가 C 호출
- C가 느려지면 B 타임아웃
- B 타임아웃이 A로 전파
- 전체 장애(연쇄 실패)
이때 트랜잭션 관점에서 더 치명적인 문제는, A는 실패로 간주했지만 B 또는 C는 성공했을 수 있다는 점입니다. 즉 “성공/실패의 관측”이 서비스마다 달라집니다.
왜 깨지나
- 타임아웃은 실패가 아니라 “모름”입니다
- 동기 체인은 부분 성공을 만들고, 롤백을 더 어렵게 만듭니다
대안
- 커맨드와 이벤트를 분리: 커맨드는 로컬 트랜잭션까지만, 나머지는 이벤트로 후속 처리
- 메시지 브로커 기반 비동기화
- 재시도는 호출자가 아니라 “워크플로우”가 소유
코드 예시: 이벤트 기반 후속 처리(아웃박스와 연결)
-- order service
BEGIN;
INSERT INTO orders(id, status, amount)
VALUES (:id, 'PENDING', :amount);
INSERT INTO outbox(id, aggregate_id, type, payload, published)
VALUES (:eventId, :id, 'OrderCreated', :payloadJson, false);
COMMIT;
이후 퍼블리셔가 outbox.published = false 를 읽어 메시지 브로커로 발행합니다.
3) “DB 커밋”과 “메시지 발행”을 따로 처리하는 패턴(이중 쓰기)
증상
가장 흔한 형태는 아래입니다.
- DB에 주문 저장
- Kafka 또는 SQS에
OrderCreated발행
이 둘이 같은 트랜잭션이 아니면, 다음이 언제든 발생합니다.
- DB는 저장됐는데 메시지 발행 실패: 다운스트림이 주문 생성 사실을 모른다
- 메시지는 발행됐는데 DB 롤백: 다운스트림이 존재하지 않는 주문을 처리한다
왜 깨지나
서로 다른 시스템(DB, 메시지 브로커)에 대한 원자적 커밋은 기본적으로 불가능합니다. 2PC를 도입할 수는 있지만 운영 복잡도와 장애 모드가 급격히 증가합니다.
대안
- 트랜잭셔널 아웃박스: DB 트랜잭션 안에 이벤트를 함께 기록
- CDC 기반 발행: Debezium 같은 변경 데이터 캡처로 이벤트 발행
코드 예시: 아웃박스 퍼블리셔(간단 버전)
// polling publisher (simplified)
async function publishOutboxBatch() {
const rows = await db.query(
"SELECT id, type, payload FROM outbox WHERE published = false ORDER BY created_at LIMIT 100"
)
for (const r of rows) {
try {
await broker.publish(r.type, JSON.parse(r.payload))
await db.execute("UPDATE outbox SET published = true WHERE id = ?", [r.id])
} catch (e) {
// leave as unpublished for retry
}
}
}
이때 퍼블리셔 자체도 멱등적이어야 하며, 브로커 발행과 published = true 업데이트 사이의 실패도 고려해야 합니다(예: 메시지 키를 outbox.id 로 고정).
4) 재시도 설계를 멱등성 없이 붙이는 패턴
증상
네트워크 타임아웃이 발생하면 클라이언트나 API 게이트웨이가 재시도합니다. 그런데 서버가 멱등하지 않으면 결과는 보통 아래 중 하나입니다.
- 결제 승인 API가 두 번 호출되어 중복 승인
- 포인트 적립 이벤트가 두 번 소비되어 중복 적립
- 재고 예약이 중복으로 잡혀 품절이 빨리 발생
특히 모놀리스에서는 “같은 트랜잭션 안에서 한 번만 실행”되던 로직이, MSA에서는 “적어도 한 번 실행(at-least-once)”으로 바뀌기 쉬워서 문제가 커집니다.
왜 깨지나
분산 환경에서는 정확히 한 번(exactly-once)을 가정하면 거의 항상 실패합니다. 대신 다음을 기본 전제로 둡니다.
- 요청은 중복될 수 있다
- 이벤트는 중복 소비될 수 있다
- 순서는 뒤바뀔 수 있다
대안
- 모든 커맨드에 멱등 키를 도입:
Idempotency-Key또는request_id - 소비자 측 중복 제거:
processed_events테이블 등으로 디듀프 - 재시도는 지수 백오프와 지터를 적용
재시도/백오프는 API 호출뿐 아니라 메시지 소비에도 필수입니다. 레이트리밋과 재시도 설계 감각은 아래 글의 접근이 그대로 도움이 됩니다.
코드 예시: 멱등 처리(결제 승인)
-- payment service
-- unique constraint on (idempotency_key)
BEGIN;
INSERT INTO payment_requests(idempotency_key, order_id, amount, status)
VALUES (:key, :orderId, :amount, 'RECEIVED')
ON CONFLICT (idempotency_key)
DO NOTHING;
-- if already exists, return stored result
COMMIT;
또는 이벤트 소비 측에서:
BEGIN;
INSERT INTO processed_events(event_id)
VALUES (:eventId)
ON CONFLICT DO NOTHING;
-- if insert affected 0 rows, it is a duplicate; skip side effects
COMMIT;
5) 읽기 모델을 같은 트랜잭션 일관성으로 기대하는 패턴(격리 수준 착각)
증상
모놀리스에서는 주문 목록, 주문 상세, 결제 상태, 배송 상태를 한 DB에서 조인해서 “항상 최신”으로 보여줄 수 있었습니다.
MSA로 분리하면 흔히 다음이 발생합니다.
- 주문 상세 화면에서 결제 상태가 잠깐
PENDING으로 보였다가PAID로 바뀜 - 검색 인덱스(Elastic 등)가 늦게 갱신되어 방금 만든 주문이 검색되지 않음
- 리포팅 쿼리가 여러 서비스의 데이터를 합치다 타임아웃 또는 불일치
이를 버그로만 취급하고 “트랜잭션을 더 강하게” 만들려 하면, 시스템은 점점 더 결합됩니다.
왜 깨지나
서비스 분리는 곧 데이터 분리입니다. 데이터가 분리되면 강한 일관성은 비용이 급격히 커지고, 대부분의 화면과 리포팅은 결과적으로 최종 일관성을 받아들여야 합니다.
대안
- CQRS: 쓰기 모델과 읽기 모델을 분리하고, 읽기 모델은 비동기적으로 갱신
- 화면 요구사항을 “일관성 수준”으로 분해: 어디까지 즉시 반영이 필요한가
- 읽기 전용 통합 저장소(데이터 웨어하우스, Lakehouse)로 리포팅을 분리
또한 읽기 모델을 비동기 갱신하면 DB 내부의 유지보수(autovacuum, bloat) 문제가 더 빨리 드러날 수 있습니다. 이벤트 적재량이 늘어 테이블이 커지고 업데이트 패턴이 바뀌기 때문입니다. PostgreSQL을 쓰는 환경이라면 아래 글의 관점으로 점검 포인트를 잡아두면 좋습니다.
코드 예시: 읽기 모델 갱신(이벤트 소비)
// read model updater (pseudo)
broker.subscribe("PaymentAuthorized", async (evt) => {
// idempotent upsert
await readDb.execute(
"UPDATE order_view SET payment_status = ? WHERE order_id = ?",
["PAID", evt.orderId]
)
})
체크리스트: “트랜잭션이 깨졌다”를 설계 언어로 바꾸기
레거시에서 MSA로 넘어갈 때, 문제를 단순히 “트랜잭션이 안 돼요”라고 표현하면 해결이 어렵습니다. 아래 질문으로 쪼개면 대안이 선명해집니다.
- 이 작업은 강한 원자성이 정말 필요한가, 아니면 보상 가능하면 되는가
- 실패했을 때 사용자가 기대하는 UX는 무엇인가(즉시 실패, 보류 후 알림, 자동 복구)
- 중복 요청/중복 이벤트를 허용할 수 있는가(대부분 허용해야 함)
- 데이터 정합성의 기준은 무엇인가(서비스 DB, 읽기 모델, 리포트)
- 관측 가능성은 충분한가(코릴레이션 ID, 사가 상태, 재처리 도구)
마이그레이션 실전 팁: “쪼개기 순서”가 트랜잭션 문제를 줄인다
마지막으로, 같은 기능 분리라도 순서가 중요합니다.
- 먼저 경계가 명확한 도메인부터 분리(예: 알림, 검색, 파일)
- 코어 트랜잭션 도메인(주문, 결제, 정산)은 사가와 아웃박스를 기본 전제로 설계
- 동기 호출 체인을 금지하고, 꼭 필요한 동기 호출은 “조회”로 제한
- 재시도는 멱등성과 세트로 도입
MSA 전환은 “강한 트랜잭션을 분산으로 확장”하는 프로젝트가 아니라, “일관성 모델을 바꾸고 운영 가능하게 만드는” 프로젝트에 가깝습니다. 위 5가지 패턴을 피하고 사가, 아웃박스, 멱등성, 읽기 모델 분리를 기본값으로 두면, 모놀리스에서 흔히 누리던 안정성을 다른 방식으로 다시 확보할 수 있습니다.