카프카 Producer 와 Consumer에 대해 학습하고 스터디하면서
메세지 중복과 유실 가능성에 대해 많이 얘기 나누었다.
서비스의 목적, 특징에 따라 어느 정도의 중복과 유실을 허용하기도 한다는 말도 있었지만,
문득 "이러한 중복과 유실을 방지할 수 있는 대안이 없을까?" 하는 궁금증이 생겼다.
그래서 이에 대해 지난번에 간략하게 언급했던 Outbox Pattern와 함께 정리해보고,
실제 사례에 대해서도 알아보려고 한다.
🧑💻 다양한 블로그들을 보고 정리한 글이며, 틀린 부분이 있다면 댓글 부탁드립니다.
1️⃣ 중복과 유실을 방지하는 방법
1. 멱등한 메세지 처리 로직 구현하기
첫번째 수행을 한 뒤, 여러 차례 반복적으로 동일한 수행을 하여도 결과가 바뀌지 않는 속성을 '멱등'라고 한다.
- 서비스 로직을 메세지 중복 수신이 발생하여도 처음에 실행한 것과 동일한 결과를 반환하게 설계하면 된다.
- 하지만 실제로 이러한 로직을 구현하는 것은 매우 어렵고, 비즈니스 로직상 멱등성이 불가능한 경우도 존재한다.
- 예를 들면 계죄 송금 및 출금, 콘서트 티켓팅 서비스 등
- 그래서 해당 방식은 매우 제한적이다.
2. 멱등 소비자 패턴 (Idempotent consumer pattern)
메세지의 ID 값을 기준으로 메세지 중복 처리 유무를 확인하는 패턴이다.
- DB에 `PROCESSED_MESSAGE(예시)`테이블을 만든다. 해당 테이블은 처리 완료된 메세지의 ID값을 담고 있다.
- 만약 메세지를 읽어왔다면, 먼저 테이블을 통해 메세지 ID로 중복된 메세지인지 판별한다.
- 핵심은 비즈니스 로직과 메세지를 읽고 처리한 정보를 테이블에 넣는 로직을 하나의 트랜잭션으로 묶는 것이다.
- 만약 중복된 메세지라면, 테이블에 읽을 메세지 ID를 넣는 과정에서 유니크 인덱스로 인해 실패될 것이다.
- 그렇게 되면 하나의 트랜잭션이 롤백되어 메세지를 중복처리하는 것을 방지하는 것이다.
그래서 해당 과정을 토픽 파티션과 함께 그림으로 표현한다면 아래와 같다.
- 메세지를 읽어오면, 먼저 중복된 메세지 ID인지 체크한다.
- 만약 중복되었다면 롤백, 중복되지 않았다면 해당 메세지 정보를 가지고 비즈니스 로직을 수행한다.
- 완료되면, 처리한 메세지 ID를 테이블에 저장하고, Offset을 커밋한다.
3. 트랜잭셔널 아웃 박스 패턴 (Trancational Outbox Pattern)
메세지 시스템과 트랜잭션 관리를 결합하여, 메세지가 정확히 한 번 전달되고 시스템의 상태가 일관성을 유지하도록 보장하는 기술 혹은 패턴을 '트랜잭셔널 메시징'라고 한다.
그 중 아웃 박스 패턴에 대해 알아보려고 한다.
트랜잭셔널 아웃 박스 패턴 (Trancational Outbox Pattern)
클라이언트 요청 → 서비스 로직 수행 → 이벤트 발행 → 해당 이벤트를 구독한 서비스 동작
만약 위와 같은 서비스에서 서비스 로직을 수행한 이후에 이벤트를 발행하는 과정에서 실패가 발생하면 어떻게 될까?
그렇다면 해당 이벤트를 구독한 서비스는 이벤트가 발행된 것을 인지하지 못하게 되어 데이터 정합성이 깨지게된다.
( 데이터 정합성 = 데이터 값들이 서로 일치하는 상태 )
그래서 이를 보완하기 위해 '보낼 편지함'이라는 의미의 outbox 테이블을 활용한 패턴이다.
과정은 아래와 같다.
- `outbox`라는 테이블을 생성하여, 이곳에 발행할 메세지를 저장한다. 외부는 outbox만 바라본다.
- 핵심은 서비스 로직과 outbox 테이블에 메세지를 저장하는 로직을 하나의 트랜잭션으로 묶는 것이다.
- outbox 테이블에서 메세지를 조회하는 로직으로 발행할 메세지가 있는지 확인한다.
- 존재한다면 메세지 브로커로 메세지를 발행하고 이벤트 처리한다.
- 처리 완료 후에는 outbox 테이블에서 해당 메세지를 제거한다.
위와 같은 패턴은 outbox에 발행할 메세지를 저장하기 때문에, 만약 메세지 발행이 실패해도 발행 성공할때까지 outbox에 존재하는 메세지를 발행하기 등의 처리가 가능하다.
즉, 최소 한번의 메세지 발행 및 이벤트 처리를 보장한다.
카프카 메세지 발행에 해당 패턴을 적용하면, 메세지 발행 실패로 인한 메세지 유실을 방지할 수 있다.
💡 트랜잭션 아웃박스 패턴은 최소 한번의 메세지(at least once)를 보장해주지만,
'정확히 한번(exactly once)'은 보장해주지 않는다. 즉, 중복 메세지를 방지해주진 않는다.
그래서 중복방지를 위해 해당 패턴을 사용하면 멱등 소비자 패턴을 주로 같이 사용한다고 한다.
밑에서 볼 29CM의 트랜잭션 아웃박스 패턴 사용 사례에서도 해당 패턴을 같이 사용하는 것을 확인할 수 있다.
💡 변경 데이터 캡쳐 (Change Data Capture)
아웃 박스 패턴은 비즈니스 로직 이후에 OUTBOX 테이블에 메세지를 생성하여 저장하는 로직이 필요하다.
비즈니스 로직마다 각각의 메세지를 생성하는 로직을 구현하는 쉽지 않다.
데이터베이스에서 발생하는 데이터 변경을 인지하고 추적하여 메세지를 발행하거나 로직을 수행하는 패턴을 '변경 데이터 캡쳐 (Change Data Capture)'라고 한다. 그래서 비즈니스 로직(데이터 베이스에 INSERT, UPDATE, DELETE가 발생했을 때) 이후에 메세지 생성을 해당 패턴으로 구현하기도 하며 이를 지원하는 플랫폼인 debezium을 주로 사용한다.
debezium 공식 문서 : https://debezium.io/documentation/reference/2.4/architecture.html
추후에 참고하면 좋은 글 : Kafka 를 찍먹해보자. - feat. Transactional Outbox Pattern
2️⃣ 29CM : 트랜잭셔널 아웃박스 패턴 구현 사례
29CM 에서는 Commerce 주문이 완료된 이후의 이벤트 처리에 아웃박스 패턴을 활용하였다.
초기 설계
- [1번 구간] Commerce 서비스 주문 완료 도메인 로직에 아래와 같이 `@Transactional`을 선언했다.
- 주문 완료 처리 ( 서비스 로직 )
- outbox 테이블에 주문 완료 이벤트 정보 저장 ( published라는 boolean 필드 활용 )
- 테이블에 저장된 기록을 가지고 이벤트 발행 ( Spring의 ApplicationEventPublisher.publishEvent() 활용 )
- [2번 구간] 1번 구간에서 발행한 이벤트를 리스닝하여, 카프카 이벤트를 발행한다.
- `@TransactionalEventListener` 와 `phase = AFTER_COMMIT`을 활용하여 1번 구간의 트랜잭션이 commit이 완료된 시점에서 이벤트를 읽도록 설정했다.
이후 2가지 구간을 아래와 같이 검증했다. (이 부분은 멋있어 보여서 가져왔습니다..ㅎ)
실제 구현
실제 구현 과정에서 여러 의견이 나와 총 2가지의 변경사항이 적용되었다고 한다.
- TransactionalEventListener의 phase = BEFORE_COMMIT 활용
- 초기 설계 1번 구간의 2번 과정을 별도의 Listener로 정의하여고, 해당 Listener는 1번 구간의 3번 이벤트를 바라보게 했다.
- 이 Listener는 phase = BEFORE_COMMIT이다.
- 아래와 같이 변경된 것이다.
- 초기 : 주문 완료 처리 + outbox 테이블에 주문 완료 이벤트 정보 저장 + 테이블에 저장된 기록을 가지고 이벤트 발행
- 변경 : [이벤트](주문완료 처리 + 이벤트 발행) ← [Listener](outbox 테이블에 이벤트 정보 저장)
- 전체적인 과정의 큰 차이는 없지만, 이렇게 수행한 이유는 아래와 같다.
- 도메인 로직과 이벤트 처리 로직 분리
- 이벤트 발행 이후 모든 로직은 TransactionalEventListener로 처리한다는 일관성
- outbox테이블의 메세지 발행 상태 값 상세화
- 기존에는 published = false로 테이블에 저장하고, 메세지가 발행되면 true로 변경하여 상태를 관리했다.
- 하지만 메세지가 발행되지 않는 경우가 1가지가 아닌 2가지였다.
- 카프카 클러스터 혹은 네트워크의 문제로 메세지 발행이 실패하는 경우
- 트랜잭션 commit 이후에 해당 이벤트를 Listener 가 읽지 못하는 경우 ( 로직을 수행하는 프로세스가 shutdown 되는 경우 = graceful shutdown 실패 )
- 그래서 상태를 init, send_succes, send_fail로 상세화 했다.
- init : outbox에 처음 기록되는 상태값으로, Listener가 이벤트를 읽지 못했다면 해당 상태로 기록된다.
- send_succes : 이벤트 발행 성공의 경우
- send_fail : 이벤트 발행 실패의 상태 값으로, 카프카 클러스터 혹은 네트워크의 문제 메세지 발행이 실패했다면 해당 상태로 기록된다.
3️⃣ 올리브영 : 메세지 중복 & 유실 해결 방법
올리브영은 OMS(Order Management System) 프로젝트 특성상 모든 메시지 절대로 유실되지 않으며, consumer 는 반드시 한 번만 메시지를 처리하는 것을 목표로 했다.
올리브영은 설정값 조정으로 1. 프로듀서와 브로커 / 2. 브로커와 컨슈머 구간의 중복과 유실을 해결했다.
( 카프카 Producer 와 Consumer 에서 이미 언급한 부분은 짧게 다루었습니다. )
1. 프로듀서와 브로커 구간
- [유실] 프로듀서와 브로커 구간의 네트워크 장애로, 브로커로 메세지를 못하는 경우
- acks = all (-1) / 재전송 횟수 지정
- [중복] 브로커가 메세지를 잘 받았지만,성공 응답을 프로듀서에게 보내지 못해 다시 메세지를 전달 받는 경우
- acks = all (-1) / enable.idempotence=true
2. 브로커와 컨슈머 구간
- [유실] 컨슈머 서버 재기동시 재기동 사이에 브로커에 메세지가 적재될 경우
- auto.offset.reset = earliest
- auto.offset.reset의 default는 `lastest`이다. ( 가장 최근의 메세지 읽기 )
- lastest의 경우, 컨슈머 재기동 사이에 브로커에 메세지가 적재된다면 기존 메세지는 유실된다.
- spring.kafka.listener.immediate-stop = false ( ⚠️ Spring Kafka 옵션 ⚠️ )
- 컨테이너가 멈췄을 때 현재 레코드를 처리하고 종료할지(`true`), 이전에 poll한 모든 메세지를 처리하고 종료할지(`false`) 결정하는 설정값.
- 기본값 = false
- auto.offset.reset = earliest
- [중복] 컨슈머가 메세지 처리 완료 정보를 브로커에게 정상적으로 전달하지 않는 경우
- AcksMode = MANUAL_IMMEDIATE ( ⚠️ Spring Kafka 옵션 ⚠️ )
- Spring Kafka에서 컨슈머의 커밋 방식을 결정하는 설정값
- 기본값 = BATCH ( poll() 메서드로 호출된 레코드가 모두 처리된 이후 커밋 )
- MANUAL ( Acknowledgement.acknowledge() 메서드가 호출되면 다음번 poll() 때 커밋 )
- MANUAL_IMMEDIATE ( Acknowledgement.acknowledge() 메서드를 호출한 즉시 커밋 )
- spring.kafka.listener.immediate-stop = false
- AcksMode = MANUAL_IMMEDIATE ( ⚠️ Spring Kafka 옵션 ⚠️ )
- session.timeout.ms, heartbeat.interval.ms, max.poll.interval.ms 을 조정하여 리밸런싱 가능성 최소화
📚 Reference
'Kafka' 카테고리의 다른 글
Kafka : 카프카 네트워크 내부 과정 (0) | 2025.01.07 |
---|---|
Kafka : 파티션 개수 설정에 대하여 (2) | 2024.11.17 |