EDA(Event Driven Architecture) 기반의 MSA(Microservice Architecture) 환경에서 서비스간 데이터 정합성은 어떻게 맞추는지 고민한 내용을 정리해본다.
문제상황 (예시)
전제
- 모놀리틱 구조에서 MSA 환경으로의 변경으로 주문 서비스, 배송 서비스를 분리
- MSA 환경에서 서비스간 결합도가 낮은 방식으로 통신하기 위해 비동기 방식인 Kafka 사용
메인 로직
주문완료(주문서비스) -> 주문완료 이벤트 발행(Kafka) -> 배송정보 생성(배송서비스)
위와 같은 상황에서 이런 의문이 든다. 주문서비스와 배송서비스가 분리된 환경에서 주문완료는 성공했는데 배송정보 생성이 실패하면 이전에 성공한 로직은 어떻게 처리해줄까? (분리된 서비스의 트랜잭션은 어떻게 처리해주는게 좋을까)
간단하게 생각해보자. 배송정보 생성이 실패했다? 그럼 그 전에 실행했던 것들도 다 취소해주면 되지 않을까? 그럼 배송정보 생성을 위해 이벤트를 발행했던 것처럼 취소를 위한 이벤트도 발행해주면 되지 않을까? 이처럼 배송정보 생성이 실패했을 때 주문완료 취소 로직(보상 트랜잭션)을 실행해주는 트랜잭션 관리 패턴을 SAGA 패턴이라고 한다.
(Two Phase Commit 등 다른 방식도 있다.)
SAGA Pattern
SAGA 패턴은 연속된 개별 서비스의 로컬 트랜잭션이 이어져 전체 비즈니스 트랜잭션을 구성한다. 각 서비스가 로컬 트랜잭션을 실행하고 이벤트를 게시해서 다음 로컬 트랜잭션 실행을 트리거하며, 연속적으로 로컬 트랜잭션을 실행하다가 중간에 문제가 생기면 이전의 로컬 트랜잭션에 의해 변경했던 사항을 취소하는 보상 트랜잭션을 실행한다. 주문완료 후 배송정보 생성이 실패한 경우 실패 이벤트를 발행해 주문완료를 취소하는 것이다.
SAGA 패턴의 핵심은 트랜잭션 관리의 주체가 DBMS 가 아닌 서비스에 있다는 것이다. 서비스 별로 따로 존재하는 DB는 각각의 로컬 트랜잭션만 담당한다. 즉, DBMS가 해주던 롤백 처리(보상 트랜잭션)를 서비스단에서 구현해준다.
SAGA를 이해하기 위해 어떤 말의 약자일까 찾아봤지만 SAGA는 약자가 아닌, 특정 논문에서 언급되어 시작된 일종의 신조어라고 한다. 추측하기로는 Long Lived Transaction이 마치 중세 아이슬란드 문학의 산문과 '말해진 것, 말로 전하다' 라는 뜻의 '사가'(Saga)에서 온것 같다고 스택오버플로우 답변자가 말한다.
참고: https://krksap.tistory.com/2113
Orchestration과 Choreography 두가지 구현 방식이 있다.
Orchestration 방식은 중앙(manager)에서 트랜잭션을 통제하는 방식으로 manager에 의해 트랜잭션이 생성 및 종료가 되고 manager에 의해 보상 트랜잭션이 실행된다. 이는 다른 서비스들과 manager 서비스간에 의존도가 높다는 단점이 있고 별도로 중앙 서비스를 만들어줘야하기 때문에 구성이 어렵다.
Cheoreography 방식은 비동기 방식 기반으로 각 서비스 별로 트랜잭션을 관리하는 로직이 있다. 서비스 로직이 성공하는 경우 그 서비스에서 다음 트랜잭션 실행을 트리거하고, 실패하는 경우 그 서비스에서 보상 트랜잭션 실행을 트리거한다. 이는 중앙 서비스가 없기 때문에 구성이 쉽고 연계 서비스의 변경이 쉽다는 장점이 있다. 다만, 중심이 없어 trouble shooting이 어렵다는 단점이 있다.
Cheoreography 방식으로 예시는 다음과 같다.
주문이 완료되면 주문서비스에서 주문완료 이벤트를 발행하고, 배송서비스에서는 이 이벤트를 수신해서 배송정보를 생성한다. 만약 배송정보 생성이 실패한다면 배송 서비스에서 실패 이벤트를 발행해서 주문완료를 취소한다.
Transactional Outbox Pattern
위에서 보면 우리가 “주문완료” 와 “주문완료 이벤트 발행”을 원자적으로 실행해줘야하는 것을 알 수 있다. 주문완료가 됐는데 주문완료 이벤트 발행이 실패하게 되면 그 이후 처리에 문제가 생길 것이기 때문이다. (주문은 들어왔는데 배송이 안된다던지..)
이처럼 DB 업데이트가 발생하는 도메인 로직과 이벤트 발행을 원자적으로 실행하는 것을 트랜잭셔널 메시징(Transactional Messaging) 이라고 한다. 트랜잭셔널 메시징을 구현하는 방법은 크게 2가지가 있다.
- 트랜잭셔널 아웃박스 패턴 (Transactional Outbox Pattern)
- 변경 데이터 캡쳐 (Change Data Capture)
그중에서 사용했던 트랜잭셔널 아웃박스 패턴에 대해 알아보자.
트랜잭셔널 아웃박스 패턴은 DB를 업데이트하는 트랜잭션 안에서 발행해야할 메시지를 DB에 저장하고, 별도의 프로세스가 DB에 저장된 이벤트를 읽어서 메시지 브로커에 전송하는 것을 말한다.
좀 더 자세하게 구현방식에 대해 얘기해보자. 여기서는 다음과 같이 구현해줬다.
DB를 업데이트(주문완료)하는 트랜잭션 안에서 발행해야할 메시지(주문완료 이벤트 발행 정보)를 DB(outbox 테이블)에 저장하고, 별도의 프로세스(Spring의 Event - @TransactionalEventListener)가 DB에 저장된 이벤트를 읽어서 메시지 브로커(Kafka)에 전송하는 것
1. 주요한 도메인 로직(ex. 주문완료)
ㄴ @Transactional 선언
ㄴ 주문완료 처리 로직
ㄴ Spring의 ApplicationEventPublisher.publishEvent()를 활용하여 주문완료 이벤트 발행하는 로직
ㄴ Transactional로 묶었기 때문에 위의 2가지 로직들이 모두 성공하거나 모두 실패함
2. outbox 테이블에 주문 완료 이벤트 저장 (이벤트 발행 요청 기록)
ㄴ 별도의 Listener로 분리. 1번에서 발행한 이벤트 구독
ㄴ 이벤트 발행 상태를 init으로 저장
ㄴ @TransactionalEventListener(phase=BEFORE_COMMIT) -> 1번에서 트랜잭션 커밋이 되기 전에 outbox 테이블에 주문완료 이벤트가 저장된다)
ㄴ 별도의 listener로 뺀 이유: 도메인 로직과 별개로 그 이후 로직은 이벤트 구독 처리로 분리해서 일관되게 개발할 수 있음. 도메인 로직은 도메인의 요구사항에만 집중할 수 있음. 확장성과 응집성이 더 높은 방식.
3. 주문완료 이벤트를 리스닝하는 메서드 구현 (Polling Publisher Pattern)
ㄴ 별도의 Listener로 분리. 1번에서 발행한 이벤트 구독
ㄴ @TransactionalEventListener(phase=AFTER_COMMIT) 선언 (1, 2번에서 트랜잭션 커밋이 실행된 이후에만 리스너가 동작하게됨. 즉, ”도메인 로직 + 주문완료 이벤트 발행“이 오류없이 온전히 실행되었을 때만 메서드가 실행됨)
ㄴ Kafka의 주문완료 Topic에 메시지 발행하는 부분 + 완료시 이벤트 발행 상태를 send_success로 업데이트
ㄴ 근데 이 리스너 메서드가 이벤트를 수신해서 카프카로 메시지를 발행할 때 카프카 클러스터 장애 또는 네트워크 상의 장애로 메시지 발행이 실패할 수 있음. 이때 1번의 outbox 테이블에 기록된 메시지 발행 정보를 주기적으로 체크하면서 재발행을 시도하는 (ex. init 상태인 것들만 재처리하는 재처리 배치) 별도의 메시지 릴레이 로직을 통해 카프카로 메시지 발행을 다시 시도할 수 있다.
이렇게 하면 (1번 + 2번)과 3번이 성공 or 실패하는 경우 모두 처리할 수 있다. (=원자성 보장)
1. (1번 + 2번) 성공 3번 성공 -> 정상
2. (1번 + 2번) 실패 3번 성공 -> 1번에서 트랜잭션 commit이 실패하면 TransactionalEventListener에서 이벤트를 리스닝 하지 않기 때문에 발생할 수 없는 케이스이다.
3. (1번 + 2번) 성공 3번 실패 -> TransactionalEventListener에서 카프카에 메시지를 발행하는 과정이 실패한 케이스인데, outbox 테이블에 발행할 이벤트 정보를 기록해놨기 때문에 일정 주기로 동작하는 배치 등을 통해 카프카 메시지 발행을 다시 실행할 수 있음
4. (1번 + 2번) 실패 3번 실패 -> 정상. 또 다른 서비스에서 트리거된 이벤트였을 경우 앞단에서 동일하게 처리할 것임.
구현 예시
// 1번
@Transactional
public void order(TestDto testDto) {
// 1. 도메인 로직
createOrder(testDto);
// 2. 이벤트 발행
eventService.eventPublish(testDto);
}
@EventHandler
@RequiredArgsConstructor
public class OrderEventListener {
private fianl EventRecorder eventRecorder;
private final KafkaService kafkaService;
// 2번 - outbox 테이블에 이벤트 기록
@TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT)
public void eventRecordHandler(TestEvent testEvent) {
eventRecorder.save(testEvent);
}
// 3번 - 카프카에 메시지 전송, outbox 테이블 상태 업데이트
@Async(EVENT_ASYNC_TASK_EXECUTOR)
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void sendKafkaHandler(TestEvent testEvent) {
kafkaService.send(testEvent);
}
}
이렇게 MSA에서 전체 서비스의 데이터 정합성은 어떻게 맞춰주는지 (SAGA 패턴), 좀 더 세부적인 구현으로 들어가서 이벤트 발행의 원자성은 어떻게 보장해주는지(Outbox 패턴) 알아봤다.
Reference
https://techblog.woowahan.com/7835/
https://devocean.sk.com/blog/techBoardDetail.do?ID=165445
'TIL' 카테고리의 다른 글
헥사고날 아키텍처 Hexagonal Architecture 란? (0) | 2025.01.17 |
---|---|
웹 어플리케이션에서의 동시성 제어 (feat. Lock) (5) | 2024.11.28 |
Kafka Consumer의 commit을 제어하는 enable.auto.commit과 AckMode (0) | 2024.08.20 |
Servlet부터 Filter와 Interceptor까지 (0) | 2024.07.24 |
무중단 배포 전략 - 롤링, 블루-그린, 카나리 배포 (0) | 2024.01.21 |