MSA에서 트랜잭션이 어려운 이유
1️⃣ 트랜잭션
1) 트랜잭션이란?
- 여러 작업을 하나로 묶어서 처리하는 것
- 전부 성공해야 하고, 중간에 하나라도 실패하면 전부 원래대로 되돌려야 함
2) 예시
- 주문 생성
- 결제 승인
- 재고 차감
➡️ 이 3개가 하나의 작업처럼 움직여야 함
- 3개 다 성공 → 최종 성공
- 중간에 하나 실패 → 전부 취소
2️⃣ 모놀리식에서 트랜잭션이 쉬운 이유
1) 하나의 애플리케이션 + 하나의 DB인 경우가 많음
- 같은 DB 안에서 처리 가능
- DB 트랜잭션으로 묶기 쉬움
- 예시
@Transactional
public void order() {
orderRepository.save(order);
paymentRepository.save(payment);
stockRepository.decrease(itemId, qty);
}➡️ 중간에 예외 발생하면 DB가 rollback 해줌
즉, 하나의 DB 트랜잭션으로 끝남
3️⃣ MSA에서 어려운 이유
1) 서비스가 나뉘어 있음
- 따로 존재하는 서비스
- 주문 서비스
- 결제 서비스
- 재고 서비스
- DB도 따로 가짐
- 주문 DB
- 결제 DB
- 재고 DB
즉, 더 이상 하나의
@Transactional로 전체를 묶을 수 없음2) 네트워크를 거쳐야 함
- 모놀리식은 메서드 호출이지만, MSA는 서비스끼리 HTTP, gRPC, 메시지 큐 등으로 통신함
- 주문 서비스는 성공
- 결제 서비스 호출 중 네트워크 오류
- 재고 서비스는 아직 실행 안 됨
➡️ 문제 발생
➡️ 이렇게 되면 중간 상태가 생김
3) 각 서비스의 로컬 트랜잭션은 성공했는데 전체는 실패할 수 있음
- 주문 서비스 → 주문 생성 성공
- 결제 서비스 → 결제 실패
- 재고 서비스 → 실행 안 됨
➡️ 현재 상태
- 주문은 생김
- 결제는 안 됨
- 재고는 그대로
즉, 사용자 입장에서는 이상한 데이터가 됨
4️⃣ 핵심 문제: 분산 트랜잭션(Distributed Transaction)
1) 분산 트랜잭션이란?
- 여러 서비스, 여러 DB에 걸친 트랜잭션(주문 DB, 결제 DB, 재고 DB 등)을 하나의 트랜잭션처럼 다루고 싶은 것
2) 어려운 이유
- 각 서비스가 서로 다른 서버, DB, 트랜잭션 경계를 가지고 있기 때문
- DB 하나 안에서는 rollback이 쉽지만,
서비스가 나뉘면 이미 다른 서비스에서 commit된 작업을 한 번에 되돌리기 어려움
5️⃣ 대표적인 문제 상황
1) 부분 성공 문제
- 상황
- 주문은 저장됐는데 결제는 실패
- 결과
- 주문만 남음
- 사용자는 주문이 된 건지 안 된 건지 헷갈림
2) 데이터 불일치 문제
- 상황
- 결제는 완료됐는데 재고 차감은 실패
- 결과
- 돈은 받았음
- 재고는 그대로
- 나중에 재고 수량이 꼬일 수 있음
3) 중복 처리 문제
- 상황
- 결제 요청을 보냈는데 응답이 안 옴
- 그래서 재시도했더니 결제가 두 번 됨
- 결과
- 중복 결제
- 중복 주문
- 중복 이벤트 처리
4) 롤백 불가 문제
- 상황
- 이미 다른 서비스가 commit한 작업은 내 서비스에서
rollback()처럼 쉽게 되돌릴 수 없음
- 예시
- 결제 서비스가 카드 승인 완료
- 그 뒤 주문 서비스 실패
➡️ 이때 단순 rollback이 아니라 결제 취소라는 별도 작업이 필요함
즉, MSA에서는 rollback 대신 보상 작업이 자주 필요함
6️⃣ 왜 2PC를 잘 안 쓰는가?
1) 2PC(2-Phase Commit)란?
- 여러 시스템에 “준비됐니?”, “그럼 다 같이 commit 하자”하고 맞추는 방식
2) 이론적으로는 가능하지만 실무에서는 잘 안 씀
- 성능 부담 큼
- coordinator 장애 시 복잡
- 락이 오래 걸릴 수 있음
- MSA의 느슨한 결합과 잘 안 맞음
- 클라우드/NoSQL/메시지 브로커 환경과도 잘 안 맞는 경우 많음
➡️ “전통적인 DB식 강한 트랜잭션”을 MSA 전체에 그대로 적용하기 어렵다
7️⃣ MSA에서 해결 방법
🎯 해결 방식의 핵심 방향
① 강한 일관성보다 최종 일관성(Eventual Consistency)을 많이 사용
- 지금 당장 모든 서비스 데이터가 완벽히 똑같을 필요는 없고 조금 시간이 지나면 맞춰지도록 설계
- 순간적으로는 주문 완료 / 결제 대기 상태일 수 있지만 잠시 후 결제 성공 이벤트가 오면 최종 완료
② 하나의 큰 트랜잭션 대신 각 서비스는 자기 DB에서만 로컬 트랜잭션 처리
- 서비스 간에는 이벤트/보상 처리로 연결
1) 대표 해결 방법 1: Saga 패턴
① Saga란?
- 하나의 큰 트랜잭션 대신 여러 개의 작은 로컬 트랜잭션으로 나누고,
실패하면 이전 작업들을 보상 트랜잭션으로 취소하는 방식
② 예시 흐름
- 주문 서비스 → 주문 생성
- 결제 서비스 → 결제 승인
- 재고 서비스 → 재고 차감
➡️ 그런데 3번에서 실패하면 2번, 1번을 되돌림
- 결제 취소 (2번)
- 주문 취소 (1번)
③ 중요한 점
- 여기서 “되돌림”은 DB rollback이 아니라 비즈니스적으로 반대 작업을 수행하는 것
- 예
- 주문 생성 ↔ 주문 취소
- 결제 승인 ↔ 결제 취소
- 재고 차감 ↔ 재고 복구
④ 장단점
- 장점
- MSA 구조에 잘 맞음
- 서비스 독립성 유지 가능
- 단점
- 설계가 복잡함
- 보상 로직을 직접 만들어야 함
- 모든 작업이 완벽히 되돌릴 수 있는 건 아님
⑤ Saga의 두 가지 방식
- Choreography 방식
- 중앙 관리자 없이 서비스들이 이벤트를 보고 각자 다음 행동 수행
- 예
- 주문 생성됨 이벤트 발행
- 결제 서비스가 듣고 결제 수행
- 결제 완료 이벤트 발행
- 재고 서비스가 듣고 재고 차감
- 장점
- 중앙 제어가 없어 느슨한 결합
- 단점
- 흐름 추적 어려움
- 서비스가 많아지면 복잡해짐
- Orchestration 방식
- 중앙 오케스트레이터가 흐름 제어
- 예
- 오케스트레이터가 주문 서비스 호출
- 성공하면 결제 서비스 호출
- 성공하면 재고 서비스 호출
- 실패하면 보상 작업 호출
- 장점
- 흐름이 명확함
- 관리 쉬움
- 단점
- 중앙 제어 컴포넌트 필요
- 오케스트레이터 의존성 생김
2) 대표 해결 방법 2: Outbox 패턴
① 필요한 이유
- 자주 생기는 문제
- DB 저장은 성공
- 그런데 이벤트 발행은 실패
- 예
- 주문은 DB에 저장됨
- 그런데
OrderCreated이벤트를 메시지 브로커에 못 보냄
➡️ 그러면 다른 서비스는 주문이 생긴 걸 모름
② 해결 아이디어
- 주문 저장과 이벤트 저장을 같은 로컬 DB 트랜잭션으로 처리
- 예
- orders 테이블 저장
- outbox 테이블에 이벤트 저장
➡️ 둘 다 한 트랜잭션으로 commit
➡️ 그 다음 별도 프로세스가 outbox를 읽어서 메시지 브로커로 발행
③ 장점
- DB 저장과 이벤트 유실 문제를 줄일 수 있음
- Saga와 같이 자주 사용됨
3) 대표 해결 방법 3: Idempotency(멱등성)
① 중요한 이유
- MSA에서는 네트워크 문제 때문에 재시도가 자주 발생함
- 응답이 안 와서 다시 요청
- 메시지가 중복 전달
- 소비자가 같은 이벤트를 두 번 처리
② 멱등성이란?
- 같은 요청을 여러 번 처리해도 결과가 한 번 처리한 것과 같아야 함
- 예
- 같은 결제 요청 ID로 두 번 요청해도 결제는 한 번만 처리
- 같은 주문 취소 이벤트를 두 번 받아도 한 번만 취소
③ 방법
- 요청 ID 저장
- 이벤트 ID 저장
- 중복 처리 여부 체크
8️⃣ 상태 설계
1) 상태를 설계해야 하는 이유
① 성공/실패만 두면 부족함
- MSA에서는 중간 상태가 필요함
- 예
ORDER_CREATEDPAYMENT_PENDINGPAYMENT_COMPLETEDSTOCK_RESERVEDCOMPLETEDFAILEDCANCELED
➡️ 이렇게 상태를 세분화해야 함
- 이유
- 모든 작업이 즉시 끝나지 않기 때문
- 지금은 결제 대기 중일 수 있고 잠시 뒤 최종 완료될 수 있음
➡️ 그래서 사용자에게도 “주문 완료”가 아니라 “주문 접수됨 / 결제 확인 중” 같은 상태를 보여줘야 할 때가 많음
2) Spring 기준으로 이해하기
①
@Transactional의 범위@Transactional은 내 서비스의 로컬 DB 작업까지만 안전하게 묶어줌
- 예
@Transactional
public void createOrder() {
orderRepository.save(order); // 여기까지는 로컬 트랜잭션
paymentClient.requestPayment(...); // 다른 서비스 호출
}paymentClient.requestPayment(...)는 다른 서비스 호출이므로 같은 DB 트랜잭션처럼 묶이지 않음➡️ 내 DB 저장, 다른 서비스 처리를 하나의 ACID 트랜잭션으로 보장할 수 없음
② 그래서 보통 이렇게 감
- 내 DB 저장
- 이벤트 발행 or 다음 서비스 호출
- 실패 시 보상 처리
- 상태값 관리
3) 주문 예시로 전체 흐름 보기
⭕ 정상 흐름
- 주문 서비스: 주문 생성 (
PENDING)
- 주문 서비스:
OrderCreated이벤트 발행
- 결제 서비스: 결제 성공
- 결제 서비스:
PaymentCompleted이벤트 발행
- 재고 서비스: 재고 차감 성공
- 재고 서비스:
StockReserved이벤트 발행
- 주문 서비스: 주문 상태
COMPLETED
❌ 실패 흐름
- 주문 서비스: 주문 생성 (
PENDING)
- 결제 서비스: 결제 성공
- 재고 서비스: 재고 차감 실패
- 결제 서비스: 결제 취소
- 주문 서비스: 주문 취소 상태 변경
9️⃣ 한 줄로 이해하기
모놀리식은 하나의 DB 안에서
rollback으로 되돌리기 쉽지만,MSA는 서비스별로 DB와 트랜잭션이 분리되어 있어서 중간 실패가 생기면 단순 rollback이 안 되고,
대신 보상 트랜잭션, 상태 관리, 이벤트 기반 처리, 멱등성 보장으로 문제를 해결해야 한다.
🔟 시험/면접용 핵심 정리
1) 왜 문제인가?
- 서비스마다 DB와 트랜잭션 경계가 다름
- 하나의 ACID 트랜잭션으로 묶기 어려움
2) 무슨 문제가 생기나?
- 부분 성공
- 데이터 불일치
- 중복 처리
- 롤백 어려움
3) 어떻게 해결하나?
- Saga 패턴
- 보상 트랜잭션
- Outbox 패턴
- 멱등성 보장
- 최종 일관성 수용
- 상태값 세분화
Share article