| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 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 |
- docker
- Kotlin
- jre
- 코딩테스트
- redis
- 탐색
- EDA
- bean
- Java
- event
- 연습문제
- Spring
- jdk
- 아키텍처
- Gradle
- 이진트리
- 트리
- 티스토리챌린지
- Unity
- 프로그래머스
- 알고리즘
- 삽입
- 플러스 백엔드
- JPA
- Kafka
- 오블완
- stack
- code blocks
- MSA
- 백준
- Today
- Total
Repository
Platform MSA 분산 트랜잭션, 왜 어렵고 어떻게 풀까 본문
Platform MSA 분산 트랜잭션, 왜 어렵고 어떻게 풀까
대규모 플랫폼에서 서비스가 쪼개질수록(서비스 경계 분리) 데이터 일관성 문제는 정면으로 마주하게 됩니다. 전통적인 단일 DB 트랜잭션은 더 이상 통하지 않습니다. 이 글은 실무자의 관점에서 분산 트랜잭션을 다루며 선택의 근거를 다룹니다.
1. 기본기 정리: ACID vs BASE, CAP 관점에서 보기
ACID vs BASE
- ACID: Strong Consistency 기반. 단일 DB 트랜잭션에 적합
- BASE: Basically Available, Soft state, Eventual consistency. 대규모 분산 시스템에서 현실적인 선택
핵심은 "업무적으로 허용 가능한 지연 일관성의 범위"를 정의하는 것입니다. 어떤 도메인은 강한 일관성이 필요(결제 승인/취소), 어떤 도메인은 최종 일관성으로 충분(알림, 포인트 적립)합니다.
보다 구체히:
- ACID는 트랜잭션 단위로 강한 보장을 제공하되, 분산 환경에서는 전역 락/대기 등으로 성능과 가용성이 떨어질 수 있습니다.
- BASE는 지연 일관성을 전제로 도메인 정책(보상, 재시도, 멱등성)으로 비즈니스 일관성을 달성합니다.
CAP 간단 리마인드
C(일관성) - A(가용성) - P(분할 내성) 중 네트워크 파티션 상황에서 C 또는 A를 선택해야 함
대부분의 인터넷 규모 시스템은 P가 기본 가정 → C와 A의 균형을 도메인 별로 설계
해석 팁:
파티션 발생 시 CP 시스템은 쓰기/읽기를 제한하며 일관성을 보존, AP 시스템은 가용성을 유지하되 최종 일관성으로 수렴합니다.
2. 전통적 2PC/XA와 한계
2PC(2-Phase Commit)
- 장점: 개발 난이도 낮음(이론상)
- 단점: 전역 락과 블로킹, 코디네이터 단일 장애 지점, 성능 저하, 클라우드 네이티브 환경과 상성 나쁨
실무 결론: 대다수 MSA 환경에서는 2PC를 지양합니다.
보충 설명:
- Phase 1(prepare): 각 리소스가 커밋 가능 여부 투표. Phase 2(commit/abort): 코디네이터가 최종 결정 전파.
- XA는 표준 인터페이스로 DB/메시지 리소스를 2PC에 참여시킬 수 있으나, 네트워크 이슈와 블로킹으로 운영 난도가 높습니다.
3. MSA에서 쓰는 패턴들: Saga, TCC, Outbox
3.1 Saga 패턴
- 사고방식: 긴 트랜잭션을 지역 트랜잭션들의 시퀀스로 분해하고, 실패 시 보상(Compensation)으로 롤백
- 두 가지 스타일
- Choreography: 이벤트 기반 자율 흐름. 의존도 낮음, 서비스 수가 늘면 흐름 가시성이 떨어짐
- Orchestration: 중앙 오케스트레이터가 단계 제어. 가시성 좋음, 오케스트레이터에 로직 집중
Choreography 예시(주문 → 결제 → 재고)
// 주문 서비스: OrderCreated 이벤트 발행 (Transactional Outbox 사용 권장)
@Transactional
public Order placeOrder(CreateOrderCommand cmd) {
Order order = orderRepository.save(Order.create(cmd));
outboxRepository.save(OutboxMessage.of("OrderCreated", order.getId(), payload(order)));
return order;
}
// 결제 서비스: OrderCreated 구독 → 결제 승인 후 PaymentApproved 발행
@KafkaListener(topics = "order-created", groupId = "payment")
public void onOrderCreated(OrderCreated event) {
Payment payment = paymentService.authorize(event.orderId(), event.amount());
outboxRepository.save(OutboxMessage.of("PaymentApproved", event.orderId(), payload(payment)));
}
// 재고 서비스: PaymentApproved 구독 → 재고 차감, 실패 시 보상 이벤트 발행
@KafkaListener(topics = "payment-approved", groupId = "inventory")
public void onPaymentApproved(PaymentApproved event) {
try {
inventoryService.reserve(event.orderId(), event.sku(), event.qty());
outboxRepository.save(OutboxMessage.of("InventoryReserved", event.orderId(), payload(event)));
} catch (InsufficientStockException e) {
outboxRepository.save(OutboxMessage.of("InventoryReservationFailed", event.orderId(), payload(event)));
}
}
Orchestration 예시(오케스트레이터 중심)
public class OrderSagaOrchestrator {
public void start(OrderId orderId) {
send(new AuthorizePayment(orderId));
}
public void on(PaymentAuthorized evt) {
send(new ReserveInventory(evt.orderId()));
}
public void on(InventoryReserved evt) {
send(new CompleteOrder(evt.orderId()));
}
public void on(InventoryReservationFailed evt) {
send(new RefundPayment(evt.orderId())); // 보상
send(new CancelOrder(evt.orderId()));
}
}
언제 무엇을? 서비스 간 결합이 느슨하고 단계가 단순하면 Choreography, 업무 플로우가 복잡·가변적이면 Orchestration이 유리합니다.
3.2 TCC(Try-Confirm/Cancel)
- Try: 자원 잠금/임시 예약
- Confirm: 확정
- Cancel: 해제
결제, 좌석 예매처럼 강한 일관성과 잠금이 필요한 도메인에서 적합하지만, 구현 부담이 높습니다.
// 좌석 서비스
public interface SeatReservationApi {
ReservationId tryReserve(SeatId seatId, UserId userId, Duration ttl);
void confirm(ReservationId id);
void cancel(ReservationId id);
}
3.3 Transactional Outbox + Relay(또는 CDC)
동일 DB 트랜잭션에서 비즈니스 데이터와 이벤트를 함께 기록하고, 별도 릴레이가 이벤트 브로커(Kafka 등)로 전달합니다.
-- outbox 테이블 (필수 인덱스 포함)
CREATE TABLE outbox (
id BIGSERIAL PRIMARY KEY,
aggregate_type VARCHAR(100) NOT NULL,
aggregate_id VARCHAR(100) NOT NULL,
event_type VARCHAR(100) NOT NULL,
payload JSONB NOT NULL,
headers JSONB,
occurred_at TIMESTAMP NOT NULL DEFAULT NOW(),
published_at TIMESTAMP,
status VARCHAR(20) NOT NULL DEFAULT 'PENDING'
);
CREATE INDEX idx_outbox_pending ON outbox (status, occurred_at);
// 동일 트랜잭션에서 비즈니스 + outbox 기록
@Transactional
public void changeOrderStatus(OrderId id, OrderStatus next) {
Order order = orderRepository.findByIdForUpdate(id);
order.changeStatus(next);
orderRepository.save(order);
outboxRepository.save(OutboxMessage.of("OrderStatusChanged", id.value(), payload(order)));
}
// 릴레이: PENDING → 브로커 발행 → PUBLISHED 업데이트 (정확히 한 번을 지향)
@Scheduled(fixedDelay = 200)
public void relay() {
List<OutboxMessage> messages = outboxRepository.findPendingBatch(100);
for (OutboxMessage m : messages) {
kafkaTemplate.send(topic(m), key(m), m.payload());
outboxRepository.markPublished(m.id());
}
}
CDC(Debezium)로 binlog를 구독해 브로커로 내보내는 방식도 흔한 대안입니다.
4. 실무 설계 체크리스트
4.1 멱등성(Idempotency)
- 소비자:
messageId/dedupKey기반 처리 이력 저장 → 중복 수신에도 단 1회만 적용 - 생산자: Outbox로 재시도 시 중복 발행 방지
public boolean handleOnce(String messageId, Supplier<Boolean> handler) {
if (processedStore.exists(messageId)) return true;
boolean ok = handler.get();
if (ok) processedStore.save(messageId);
return ok;
}
4.2 재시도와 독성 메시지(Dead Letter)
- 백오프 재시도(지수/선형), 최대 횟수 초과 시 DLQ
- DLQ는 반드시 운영 대시보드와 연결하여 수동 처리 루틴 제공
4.3 순서 보장
- Kafka 파티션 키 전략: 같은 집계 루트(예:
orderId)는 동일 파티션으로 - 멱등 처리와 함께 사용하면 재처리에도 안전
4.4 트랜잭션 경계와 보상 정의
- 각 지역 트랜잭션은 빠르게 커밋, 보상은 "업무적으로 허용 가능한 역연산"을 명시
- 예) 포인트 적립 보상: 적립 취소 이벤트, 재고 보상: 예약 해제
public class CompensationHandlers {
public void onPaymentFailed(OrderId orderId) {
send(new CancelInventoryReservation(orderId));
send(new RevertPoint(orderId));
send(new CancelOrder(orderId));
}
}
4.5 관측성(Observability)
- Trace(분산 트레이싱), Metric(성공/실패율, 보상율), Log(상관관계 ID)
- OpenTelemetry + Kafka/Spring 통합으로
traceId전파 필수
// 메시지 헤더에 trace 컨텍스트 주입/추출
KafkaTemplate<String, String> template;
public void publish(String topic, String key, String payload) {
try (var span = tracer.spanBuilder("publish:")
.setSpanKind(SpanKind.PRODUCER)
.startSpan()) {
var headers = new RecordHeaders();
propagator.inject(Context.current().with(span), headers, HeadersSetter.INSTANCE);
template.send(new ProducerRecord<>(topic, null, key, payload, headers));
}
}
5. 아키텍처 레퍼런스: 결제 중심 시나리오
1) 주문 생성 → 2) 결제 승인 → 3) 재고 예약 → 4) 주문 확정
- 주 데이터 흐름: Saga(Orchestration)
- 보조 데이터: Outbox + CDC로 분석 파이프라인에 전송
- 중요 도메인(결제)은 TCC로 Try-Confirm/Cancel 지원
설계 포인트
- 오케스트레이터는 상태 전이만 담당(업무 로직은 각 도메인)
- 모든 이벤트는 멱등 + 재시도 가능해야 함
- 실패율/보상율/지연시간을 대시보드화(운영 용이성)
6. 테스트 전략(실전)
계약 테스트(Consumer-Driven Contract)
- 이벤트 스키마와 의미론의 계약화(Pact, spring-cloud-contract)
통합 테스트(Testcontainers)
@Testcontainers
class SagaFlowIT {
@Container static KafkaContainer kafka = new KafkaContainer(DockerImageName.parse("confluentinc/cp-kafka:7.5.0"));
@Test
void endToEnd_succeeds_and_is_idempotent() {
// given
var orderId = UUID.randomUUID().toString();
// when: 주문 생성 이벤트 2번 발행(중복)
publish("order-created", orderId, payload(orderId));
publish("order-created", orderId, payload(orderId));
// then: 최종 상태는 1회만 반영되어야 함
await().atMost(Duration.ofSeconds(10)).untilAsserted(() -> {
assertThat(readOrderState(orderId)).isEqualTo("CONFIRMED");
assertThat(processedCount("order-created", orderId)).isEqualTo(1);
});
}
}
7. 선택 가이드(요약)
- 강한 일관성 필요 + 잠금 가능: TCC 고려
- 흐름 복잡/가변 + 가시성 중시: Orchestration Saga
- 느슨한 결합 + 단순 단계: Choreography Saga
- 재시도/멱등/순서 보장 + 운영 용이성: Outbox(+CDC) 기본 채택
8. 운영 팁 모음
- 메시지 버전 관리:
event_type+schemaVersion로 진화형 스키마 유지 - 보상 실패의 보상: 보상도 실패할 수 있으니 재시도와 관리자 경보 설계
- 부분 실패 모니터링: 보상율이 급증하면 상위 플로우 점검
- 핫 파티션 완화: 키 설계 시 샤딩 키 혼합(예:
orderId%N) 고려
마치며
분산 트랜잭션은 하나의 은탄환이 없습니다. 도메인별 일관성 요구를 수치로 정의하고, Saga/TCC/Outbox/CDC를 적절히 조합해 운영 가능한 일관성을 확보하는 것이 핵심입니다. 이 글의 체크리스트와 코드 스니펫을 바탕으로, 여러분의 Platform MSA에서도 예측 가능하고 관측 가능한 데이터 일관성을 구현해 보시길 바랍니다.
'Server' 카테고리의 다른 글
| Routemate: 5%의 복잡도로 80%의 기능을 구현한 경량 DB 라우터 개발기 (1) | 2025.12.22 |
|---|---|
| [gradle] generateMetadataFileForMavenPublication FAILED 오류 해결 가이드 (0) | 2025.12.11 |
| MSA 환경에서의 알림 서비스 설계와 구현 (2) | 2025.07.13 |
| 파티션과 샤딩에 관하여 (2) | 2025.06.30 |
| [Monorepo] 모노레포 그것이 답인가? (2) | 2025.05.22 |