DB 저장은 성공했는데 Kafka 전송이 실패하면 어떻게 처리하시겠습니까?
MSA 환경에서는 하나의 작업이 여러 서비스와 연결되는 경우가 많다.
예를 들어 주문 서비스가 있다고 가정해보자.
사용자가 주문을 하면
흐름은 다음과 같다.
주문 요청
↓
DB 저장
↓
Kafka 이벤트 발행
↓
배송 / 쿠폰 / 알림 서비스
다음과 같은 코드가 있다고 가정하자.
@Transactional
public void order(Order order){
orderRepository.save(order);
kafkaTemplate.send("order-topic", order);
}
겉보기에는 문제가 없어 보인다.
하지만 다음과 같은 상황이 발생할 수 있다.
DB 저장 성공
↓
DB Commit 완료
↓
서버 다운
↓
Kafka 전송 실패
이 경우
즉 데이터 정합성이 깨진다.
많은 사람들이 이렇게 생각한다.
try{
orderRepository.save(order);
kafkaTemplate.send(...);
}catch(Exception e){
rollback();
}
하지만 이것으로는 해결되지 않는다.
왜냐하면
DB Commit 완료
↓
서버 종료
가 되면
catch 자체가 실행되지 않는다.
이미 트랜잭션은 끝났기 때문이다.
즉
DB Commit 이후 발생한 장애는 try-catch로 복구할 수 없다.
이 문제를 해결하기 위해 사용하는 패턴이 Outbox Pattern이다.
핵심 아이디어는
Kafka에 바로 보내지 말고, 먼저 이벤트를 DB에 저장하자는 것이다.
기존 구조
주문 저장
↓
Kafka 전송
↓
Outbox Pattern
주문 저장
+
Outbox 저장
↓
같은 트랜잭션으로 Commit
↓
별도 Worker가 Kafka 전송
orders 테이블
| id | product |
|---|---|
| 1 | MacBook |
outbox 테이블
| event_id | event_type | status |
|---|---|---|
| 1001 | ORDER_CREATED | READY |
두 테이블은 같은 DB에 존재한다.
따라서
@Transactional
public void order(){
orderRepository.save(order);
outboxRepository.save(event);
}
처럼 하나의 트랜잭션으로 Commit할 수 있다.
별도의 Worker(또는 Scheduler)가
주기적으로 Outbox를 조회한다.
READY
↓
Kafka 전송
↓
성공
↓
SENT 변경
즉
Kafka 전송은 트랜잭션 밖에서 수행된다.
예를 들어
주문 저장
↓
Outbox 저장
↓
Commit
↓
서버 다운
되었다고 가정해보자.
괜찮다.
왜냐하면
Outbox에는
ORDER_CREATED
READY
가 그대로 남아 있기 때문이다.
서버가 다시 살아나면
READY 상태를 다시 읽어서 Kafka에 전송하면 된다.
즉 이벤트가 유실되지 않는다.
멱등성이란
같은 요청을 여러 번 수행해도 결과는 한 번 수행한 것과 같아야 하는 성질이다.
Kafka는 기본적으로
At-Least-Once Delivery를 사용한다.
즉
최소 한 번은 보낸다.
를 보장한다.
하지만
Kafka 전송 성공
↓
응답 받기 전에 네트워크 끊김
이라면
Producer는
보냈나?
안 보냈나?
를 알 수 없다.
그래서 안전하게 한 번 더 보낸다.
그러면
ORDER-100
ORDER-100
처럼 같은 이벤트가 두 번 전달될 수 있다.
배송 서비스가
ORDER-100
↓
배송 생성
했는데
같은 이벤트가 다시 오면
ORDER-100
↓
배송 생성
배송이 두 개 생성된다.
Consumer는 eventId를 저장한다.
예를 들어
processed_event
| event_id |
|---|
| 1001 |
다음과 같이 구현한다.
if(processedEvent.exists(eventId)){
return;
}
배송 생성
processedEvent.save(eventId);
이미 처리한 event라면
그냥 무시한다.
즉
같은 이벤트가 10번 와도
한 번만 처리된다.
분산 시스템에서는
중복은 제거할 수 있지만, 유실은 복구하기 어렵다.
그래서 대부분의 메시지 시스템은
At-Least-Once Delivery
전략을 사용한다.
그리고 Consumer가
멱등성을 구현하여 중복을 제거한다.
| 기존 방식 | Outbox Pattern |
|---|---|
| DB 저장 후 Kafka 전송 | DB에 이벤트 저장 후 Kafka 전송 |
| 장애 발생 시 이벤트 유실 가능 | 이벤트 유실 방지 |
| try-catch로 해결 불가 | Outbox 재전송 가능 |
| 멱등성 없음 | 멱등성 있음 |
|---|---|
| 같은 이벤트 여러 번 처리 | 한 번만 처리 |
| 중복 데이터 발생 | 중복 방지 |
DB Commit 이후에는 try-catch로 복구할 수 없습니다. 그래서 주문 데이터와 이벤트를 Outbox 테이블에 같은 트랜잭션으로 저장합니다. 이후 별도의 Worker가 Outbox를 읽어 Kafka에 전송합니다. Kafka는 At-Least-Once Delivery를 사용하기 때문에 이벤트가 중복 전달될 수 있으며, Consumer에서는 eventId 등을 이용한 멱등성을 구현하여 동일한 이벤트가 여러 번 와도 한 번만 처리하도록 합니다.