Published on

모놀리스를 MSA로 쪼갤 때 DB 트랜잭션 깨지는 5가지 패턴

Authors

레거시 모놀리스를 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 커밋”과 “메시지 발행”을 따로 처리하는 패턴(이중 쓰기)

증상

가장 흔한 형태는 아래입니다.

  1. DB에 주문 저장
  2. 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로 넘어갈 때, 문제를 단순히 “트랜잭션이 안 돼요”라고 표현하면 해결이 어렵습니다. 아래 질문으로 쪼개면 대안이 선명해집니다.

  1. 이 작업은 강한 원자성이 정말 필요한가, 아니면 보상 가능하면 되는가
  2. 실패했을 때 사용자가 기대하는 UX는 무엇인가(즉시 실패, 보류 후 알림, 자동 복구)
  3. 중복 요청/중복 이벤트를 허용할 수 있는가(대부분 허용해야 함)
  4. 데이터 정합성의 기준은 무엇인가(서비스 DB, 읽기 모델, 리포트)
  5. 관측 가능성은 충분한가(코릴레이션 ID, 사가 상태, 재처리 도구)

마이그레이션 실전 팁: “쪼개기 순서”가 트랜잭션 문제를 줄인다

마지막으로, 같은 기능 분리라도 순서가 중요합니다.

  • 먼저 경계가 명확한 도메인부터 분리(예: 알림, 검색, 파일)
  • 코어 트랜잭션 도메인(주문, 결제, 정산)은 사가와 아웃박스를 기본 전제로 설계
  • 동기 호출 체인을 금지하고, 꼭 필요한 동기 호출은 “조회”로 제한
  • 재시도는 멱등성과 세트로 도입

MSA 전환은 “강한 트랜잭션을 분산으로 확장”하는 프로젝트가 아니라, “일관성 모델을 바꾸고 운영 가능하게 만드는” 프로젝트에 가깝습니다. 위 5가지 패턴을 피하고 사가, 아웃박스, 멱등성, 읽기 모델 분리를 기본값으로 두면, 모놀리스에서 흔히 누리던 안정성을 다른 방식으로 다시 확보할 수 있습니다.