-
Transactional Outbox Pattern🌱 spring 2024. 4. 23. 22:19
💡 Transactional Messaging
비동기 메시징을 활용한 서비스 구현에서는 비즈니스 로직이 실행되었을 때, 이를 표현하는 이벤트도 온전하게 발행되는 것이 중요하다.
도메인 로직이 완료된 이후에 이벤트가 발행되지 않는다면, 해당 이벤트를 바라보는 컨슈머는 특정한 로직을 실행할 수 없게 되고, 이로 인해 전체 서비스의 데이터 정합성이 깨지거나 특정한 로직에서 버그가 발생할 수 있기 때문이다.
📖 Transactional Messaging
- 서비스 로직의 실행과 그 이후의 이벤트 발행을 원자적(Atomically)으로 함께 실행하는 것
Transactional Messaging의 구현 방법은 2가지가 존재한다.
1️⃣ 트랜잭셔널 아웃박스 패턴 (Transactional Outbox Pattern)
2️⃣ 변경 데이터 캡쳐 (CDC, Change Data Capture)
https://microservices.io/patterns/data/transactional-outbox.html
⭐ Transactional Outbox Pattern
MSA의 마이크로서비스를 사용하면서 동기식 HTTP API를 이용한 통신 방식으로만 사용하는데 한계 상황이 발생되어 이벤트 드리븐 아키텍처(EDA)를 사용하한다.
Event Driven Architecture를 따르는 서비스에서는 대게 Message Broker를 이용해 다양한 메시지(이벤트)를 발행하고 그에 연관된 작업들을 비동기적으로 처리하여 시스템을 통합한다.
DB 트랜잭션을 실행한 뒤 이에 연관된 메시지를 Message Broker에 publish 하게 되는데, 메시지 publish가 반드시 완료되어야 하는 경우가 있다.
(내 프로젝트에서 예시로는 이체에 성공했을 경우 이체 성공 이벤트를 이체 알림 서비스에 publish해주어 이체 알림을 사용자에게 보내주어야 했다)📖 왜 이체 성공 이벤트가 반드시 publish 되어야 할까?
- 내 뱅킹 서비스가 누군가에게 노출되어 내 계좌의 돈이 이체됐을 경우 빠르게 고객에게 이 정보를 알려주어야 하기 때문에 이체에 대한 알림은 꼭 publish 되어야 한다.
DB 트랜잭션은 DB 차원에서 원자성을 보장해 트랜잭션에 포함된 query들은 원자적으로 실행되지만, DB와 Message Broker는 다른 기종이라 원자적인 처리가 불가능하다.
DB에서 이체 처리가 완료되었더라도 Message Broker에 메시지(이벤트)를 publish 하는데 실패할 수 있고, 이에 따라 DB에서의
Rollback 처리를 하기도 어렵다.
이런 문제를 해결하기 위해서 Transactional Outbox 패턴을 사용하게 된다.
🚀 Transactional Outbox Pattern 사용 이유
Transactional Outbox Pattern을 사용하지 않는 방법
1️⃣ DB 트랜잭션이 완료된 이후 Kafka에 메시지를 publish
2️⃣ publish에 실패했을 경우, 실패한 메시지를 dead_letter_queue DB 테이블에 저장 후 별도의 batch process를 통해 retry
⚠️ 한계
- DB 트랜잭션과 메시지 publish를 원자적으로 처리할 수 없어, DB 트랜잭션에 성공하더라도 메시지 publish를 보장할 수 없다.
- 일정 시간 간격으로 batch process가 실행되면서 retry가 되어 신속하지 않게 처리가 된다.
- 메시지간 publish 순서가 중요한 경우에, retry 되는 메시지가 늦게 publish되면 순서가 바뀔 수 있다.
🤖 Transactional Outbox 구현하는 방식
Transactional Outbox 패턴을 구현하는 방식으로는 대표적으로 **Polling Publisher** 와 **Transaction Log Tailing**이 있다.
- 두 방식의 가장 큰 차이점은 publish 할 메시지를 구성하는데 있다.
1️⃣ Polling Publisher
Outbox DB 테이블에 polling 하는 것으로 publish할 메시지를 가져온다.
DB 트랜잭션이 실행되는 시점부터 publish할 메시지 정보를 각 비즈니스 로직에서 생성해 Outbox DB 테이블에 저장하기 때문이다.
2️⃣ Transaction Log Tailing
publish 할 메시지를 on-demand로 생성한다.
DBMS마다 트랜잭션이 처리가 되면 log를 생성하게 된다 (ex: MySQL - binlog)
- 해당 log에 대한 CDC (Change Data Capture)를 구현
Polling Publisher Transaction Log Tailing
전반적인 구조가 단순해 구현하기 간편하다. CDC는 데이터의 변경점을 확인하여 동작하는 방식으로 서비스의 코드를 변경하지 않더라도 이벤트의 발생시점을 캡쳐해 이를 메시지 브로커에 발행할 수 있다. 비교적 높은 비용의 polling으로 DB 부하가 이어질 수 있다. log에 대한 CDC를 구현하고 그 결과를 바탕으로 메시지 브로커에서 사용하는 메시지 포맷으로 데이터를 생성하는 작업이 필요 CDC는 테이블 구조에 의존적으로, 테이블 스키마가 변경되면 CDC를 통해 메시지 브로커에 전달되는 메시지 형태도 바뀐다. ❗ Polling Publisher 방식을 사용해 message relay를 구현…!
🧑🏻💻 구현
🏃 설계 시나리오
1️⃣ 이체 관련 로직에서 **@Transactional**을 선언
- 이체 완료 처리
- outbox 테이블에 이체 완료 이벤트 정보를 저장하여 이벤트 발행 요청을 기록
- Spring의 ApplicationEventPublisher.publishEvent()를 활용해 이체 완료 이벤트 발행
❗ @Transactional에 묶였기 때문에 해당하는 로직이 모두 성공하거나, 실패하게 된다. (원자성)
2️⃣ 이체 완료 이벤트를 리스닝하는 메서드를 구현한다. (**@TransactionalEventListener**)
- 메서드의 파라미터 : 리스닝하는 이벤트
- 트랜잭션이 commit이 실행된 이후에 리스너가 동작.
- 따라서, 도메인 로직에서 오류없이 실행되었을 때 이벤트가 발행되는 것을 보장한다.
3️⃣ 리스너가 이벤트를 수신해 카프카로 메시지를 발행
- 이때, 카프카 장애 또는 네트워크 장애로 인해 발행이 실패할 수 있다.
- outbox 테이블에 기록된 메시지 발행 정보를 주기적으로 확인하여 재발행을 시도하는 메시지 릴레이 로직을 통해 카프카로 메시지 발행을 다시 시도
- 결국 언젠가는 모든 서비스 간의 데이터 정합성이 맞춰진다.
😎 핵심
“도메인 로직이 실행되고, 이에 관한 이벤트도 반드시 발행되게 한다”
🧑🏻💻 구현
실행되는 로직
- 도메인 로직
- outbox 테이블에 이벤트 기록 (TransactionalEventListener - BEFORE_COMMIT)
- 이벤트를 카프카로 전송 (TransactionalEventListener - AFTER_COMMIT)
이벤트 발행 이후의 로직을 모두 TransactionalEventListener에서 처리하게 해 일관성을 유지
- 이벤트 발행 이후의 처리하는 모든 로직은 TransactionalEventListener에서 구현한다.
❗도메인 로직은 도메인의 요구사항에만 집중, 그 이외의 것들은 해당 이벤트를 바라보는 Listener에 추가한다.
🖥 초기 구현
public TransferResponse transferMoney(Long userId, TransferRequest request) { ... // 비즈니스 로직 // 이벤트 생성 TransferCompletedEvent event = new TransferCompletedEvent(toAccount.getOwnerName(), ...); Events.raise(event); }
public class Events { private static ApplicationEventPublisher publisher; public static void setPublisher(ApplicationEventPublisher publisher) { Events.publisher = publisher; } public static void raise(Object event) { if (publisher != null) { publisher.publishEvent(event); } } }
public class TransferCompletedEventHandler { ... @EventListener(TransferCompletedEvent.class) public void handle(TransferCompletedEvent event) { transferKafkaTemplate.send(TRANSFER_COMPLETE_TOPIC_NAME, event); } }
1️⃣ 비즈니스 로직 구현 (이체)
2️⃣ 이체 완료 이벤트 생성
3️⃣ 이벤트 발행 (eventPublish)
4️⃣ 이벤트 리스너(핸들러)에서 카프카로 메시지 전송
🚀 트랜잭셔널 아웃박스 패턴 적용 코드
@Transactional public TransferResponse transferMoneyWithTransactionalOutBoxPattern(Long userId, TransferRequest request) { // 비즈니스 로직 // 이벤트 생성 TransferCompletedEvent event = new TransferCompletedEvent(..); Events.raise(event); }
@Component @RequiredArgsConstructor public class TransferExternalRecordListener { ... // 1. 이체 관련 이벤트 outbox 테이블에 저장. @TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT) public void recordMessageHandler(TransferCompletedEvent event) { ... eventRecorder.save(transferEventRecordCommand); } }
@Component @RequiredArgsConstructor public class TransferExternalEventMessageListener { ... // 2. 카프카에 메시지 전송 (TransactionPhase.AFTER_COMMIT) @Async() @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) public void sendMessageHandler(TransferCompletedEvent event) { sendService.send(TransferExternalEventMessagePayload.from(event)); } }
- 카프카에 메세지를 전달하고 나서는 상태 값을 변경해준다.
📖 참고자료
트랜잭셔널 아웃박스 패턴의 실제 구현 사례 (29CM)
'🌱 spring' 카테고리의 다른 글
@TransactionalEventListener 적용해보기 😎 (0) 2024.03.17 🍀 MongoDB - QueryDsl with Spring (0) 2023.05.30 🐱 Tomcat (0) 2023.03.12 ThreadLocal (0) 2023.01.18 🌊 Connection Pool (0) 2023.01.13