메시징 큐 시스템을 다룰 때 중요하게 고민해야되는 부분 중 하나는 ‘메시지의 중복과 유실 문제’이다.
이와 관련된 메시징 큐 시스템의 ‘메시지 전달 보장 방식’과 ‘Kafka 에서 각각 어떻게 지원하는지’에 대해 알아보자.
메시지 전달 보장 방식(Message Delivery Semantics)
메시지 전달 보장 방식은 다음과 같은 3가지가 존재한다.
- At most once: 메시지가 최대 한 번 전달됨을 보장 (중복은 발생하지 않지만, 유실이 있을 수 있음)
- At least once: 메시지가 최소 한 번 전달됨을 보장 (유실은 없지만, 중복이 있을 수 있음)
- Exactly once: 메시지가 정확히 한 번만 전달됨을 보장 (중복도 없고, 유실도 없음)
아래로 갈수록 데이터 무결성은 높아지지만, 트랜잭션 관리나 중복 감지 등의 비용이 발생하기에 성능은 떨어질 수 있다. 그렇기 때문에 무조건 높은 수준을 사용해야 하는 것이 아닌, 애플리케이션 혹은 메시지 특성에 따라 적절하게 선택하는 것이 중요하다.
아래에서 각 방식에 대해 사용사례와 함께 살펴보자.
1. At most once
메시지가 최대 한 번 전달됨을 보장하는 방식이다. 최대 한 번 전달됨을 보장하기에 중복은 발생하지 않지만, 유실이 발생할 수는 있다.
메시지가 유실되어도 치명적이지 않으며, 컨슈머의 높은 처리량 or 퍼포먼스가 중요한 요소일때 사용된다. 대표적인 사용 사례는 다음과 같다.
- 시스템 로그 수집, 사용자 활동 로그, 실시간 서버 메트릭 수집 작업에서는 일부 데이터가 유실되어도 전체 추세 분석에 큰 영향이 없음
- 실시간 데이터 스트리밍(예: IoT 센서 데이터, 라이브 분석)에서는 일부 데이터 손실보다 낮은 지연 시간과 높은 처리량이 더 중요할 수 있음
2. At least once
메시지가 최소 한 번 전달됨을 보장하는 방식이다. ‘최소 한 번 전달됨을 보장’ 이라는 부분에서 알 수 있듯이 여러 번 중복되어 발행될 수 있으며, 유실은 없게 된다.
주로 메시지 유실이 발생하지 않아야 하며, exactly-once 방식보다 성능을 우서신하는 경우에 주로 사용된다. 구현 복잡도와 성능 오버헤드가 적으면서도 높은 신뢰성을 제공하기 때문이다. 대표적인 사용 사례는 다음과 같다.
- 데이터 손실을 피하는 것이 최우선일 때 적합함
- 중복이 허용되거나 멱등성 로직 구현이 가능한 시나리오에 적합함. 예를 들어 로그 수집, 분석 이벤트, 메트릭 처리 등에서는 중복된 데이터가 들어와도 크게 문제가 없거나 후에 중복 제거를 통해 정합성을 맞출 수 있다.
Note: 중복으로 인한 영향이 큰 경우(ex. 금전 거래)에는 추가적인 멱등성 로직을 구현하거나, Exactly-Once 방식을 고려해야 한다.
3. Exactly once
메시지가 정확히 딱 한 번만 전달됨을 보장하는 방식이다. 중복도 유실도 허용하지 않는다.
At-least-once 방식 + 컨슈머 멱등성 로직 구현도 하나의 메시지가 정확히 한 번만 비즈니스 로직이 실행되도록 할 수 있다.
그렇다면 ‘Exactly once’ 와 ‘At-least-once 방식에 + 컨슈머 멱등성 로직 구현’의 정확한 차이는 무엇일까? 바로 ‘메시징 시스템’ 레벨에서의 한 번만 전달함을 보장하는지의 관점으로 구분할 수 있다.
주로 정확히 한 번만 발행되어 처리 됨을 보장하기 위해 사용한다. 다만 가장 높은 구현 복잡도와 성능 비용이 수반되는 점을 고려해서 적용해야 한다. 대표적인 사례는 다음과 같다.
- 금융 거래, 결제 시스템, 재고 관리, 은행 계좌 이체, 제품 과금과 직결된 액세스 로그 등 중복이나 손실이 치명적인 케이스
- end-to-end 정합성이 요구되는 파이프라인. 예를 들어 카프카 스트림즈(Kafka Streams) 애플리케이션이나 ETL 파이프라인 등에서는 입력부터 출력까지 한 번씩만 처리하도록 Exactly-Once (처리) 모드를 활용할 수 있음
Kafka 에서의 메시지 전달 보장 방식(Message Delivery Semantics)
Kafka는 링크드인에서 웹사이트의 로그를 활용하여 사용자 활동을 추적하기 위한 용도, 즉 실시간 데이터 파이프라인을 구성하기 위해 만들어졌다.
그러다보니 처리량이 높고 데이터 중복이 약간 발생하더라도 메시지가 유실되지 않는 At least once 방식을 구축하는 것이 중요했다.
Kafka가 오픈 소스로 성장하며, 더 높은 메세지 전달 보장 수준에 대한 필요성이 생겼다. 그래서 현재 Kafka는 세 가지 전달 보장 수준을 모두 구현할 수 있다.
Kafka에서 각 메시지 전달 보장 방식의 구현(설정) 방법을 알아보자. 프로듀서 측면과 컨슈머 측면에서 어떻게 전송 보장을 하는지 각각 살펴봐야한다.
1. Kafka에서 No guarantee 방식(중복 O, 유실 O)
Kafka v2.8 이하 기준의 설명이다. 참고로 3.0 부터는 acks=all, enable.idempotence=true가 디폴트 설정이다. 각 전달 보장 방식들에서 하나씩 설정들을 추가해나가는 것을 표현하고자 해당 버전을 기준으로 설명하였다.
No guarantee 방식은 메시지의 유실과 중복을 보장하지 않는 방식이다. 별도 설정 없이 카프카를 사용할때 메세지가 유실될 수도 있고, 중복이 발생할 수 있다. 왜 그런 현상이 나타나는지 먼저 프로듀서 측면에서 살펴보자.
Kafka 프로듀서 측면의 No guarantee 방식(중복 O, 유실 O)
Kafka 프로듀서의 기본 acks 설정은 1이다. 즉, follower 파티션의 복제 여부와 상관없이 리더 파티션에만 정상적으로 메시지가 수신되면 정상 수신되었다는 acks 응답을 주게된다. 따라서 leader 파티션이 프로듀서의 메세지를 받고 acks 응답을 준 후 follower로 복제하기 전에 다운되면 해당 메세지는 acks 응답을 주었음에도 유실된다.
중복 메세지 발생 여부는 프로듀서의 retry 설정에 따라 다르다. retry를 하도록 설정되어있으면 중복 메세지가 발생할 수 있다. 특히 기본 설정에서는 멱등성 기능이 비활성화되어 있어서, 동일 메시지가 여러 번 발행될 수 있다.
Kafka 컨슈머 측면의 No guarantee 방식(중복 O, 유실 O)
컨슈머는 기본적으로 enable.auto.commit=true로 설정되어 있기 때문에 주기적으로 오프셋 커밋이 자동으로 발생한다. 그러다보니 자동 커밋 주기가 5초인데, 컨슈머가 메세지를 처리하는 과정이 5초를 초과하면 메세지 처리가 완료되기 전에 오프셋 커밋이 일어나게 된다. 만약 커밋 이후 해당 메세지 처리 과정에서 에러가 발생하게 되면 메시지는 유실된다.
컨슈머가 메세지를 처리 후 커밋 전에 Kafka 브로커의 장애가 발생하여 다운되면, 컨슈머가 재시작될 때 이전 오프셋부터 메세지를 다시 읽기 때문에 중복 소비가 발생할 수 있다.
2. Kafka에서 At most once 방식(중복 X, 유실 O)
그러면 어떻게 설정을 바꾸면 메세지가 최대 한 번(0번 혹은 1번만) 처리되도록 보장해줄 수 있을까?
Kafka 프로듀서 측면의 At most once 방식(중복 X, 유실 O)
프로듀서 측면에서는 메세지 중복 발행만 방지하면 된다. 메세지를 한 번만 보내고 브로커가 메세지를 잘 받았는지 안받았는지는 신경쓰지 않아도 된다.
그래서 retries=0으로 설정해주고, acks=all 이 아닌 경우에는 At most once라고 볼 수 있다. 예를 들어, retries=0, acks=1로 지정할 경우 프로듀서는 follower 파티션의 복제에 실패하더라도 무조건 1번만 발행되도록 할 수 있기에 최대 한 번의 전달이 가능하게 되며 중복이 발생하지 않게 된다.
Kafka 컨슈머 측면의 At most once 방식(중복 X, 유실 O)
컨슈머 측면에서는 자동 커밋이 활성화되어 있으면 중복 소비를 막기는 어렵다. 중복 소비를 반드시 막아야하는 상황이라면 수동 커밋(enable.auto.commit=false)
으로 설정 후 소비후 비즈니스 로직이 실행되기 전에 오프셋을 커밋하는 방법이 있다. 이렇게 하면 컨슈머가 재시작되어도 이미 해당 오프셋은 커밋되었기 때문에 유실은 발생하더라도 중복 소비는 발생하지 않는다.
3. Kafka에서 At least once 방식(중복 O, 유실 X)
가장 자주 쓰이는 At least once 방식에 대해 살펴보자.
Kafka 프로듀서 측면의 At least once 방식(중복 O, 유실 X)
프로듀서가 발행하는 메세지가 유실되면 안된다. 그렇기 때문에 acks=all
로 모든 follower 파티션에 복제가 잘 이루어졌음을 보장해야한다.
프로듀서는 메세지가 커밋되었다는 응답(ack)을 받지 못하면 메세지를 재전송(retry)한다. 그렇기에 메세지는 중복이 발생할 수 있다.
Kafka 컨슈머 측면의 At least once 방식(중복 O, 유실 X)
컨슈머가 오토 커밋 설정인 경우에는 유실이나 중복이 발생할 수 있다. 그래서 수동 커밋 설정으로 바꾸되, 메세지를 처리 후 오프셋을 커밋하면 유실 문제를 방지할 수 있다.
하지만 중복 소비는 발생할 수 있다. 예를 들어, 컨슈머가 오프셋을 커밋하지 못하고 죽은 경우(JVM OOM, Container 강제 종료 등)에는 다른 컨슈머 프로세스가 이전 오프셋부터 소비하기 때문에 중복 소비될 수 있다. 중복 소비가 발생하긴 하지만, 유실이 발생하진 않기에 At least once라고 볼 수 있다.
Note: 만약 중복을 방지하고자 한다면, 애플리케이션 레벨에서 단 한 번만 메시지가 처리되도록 보장하거나, 멱등하게 비즈니스 로직을 구현해야 한다. 예를 들어, 주문 애플리케이션을 생각해보자. 컨슈머가 주문 메시지를 읽어서 결제 처리를 완료후 오프셋 커밋전에 서버가 다운되면, 해당 주문 메시지는 처리됐음에도 불구하고 커밋되지 않았기 때문에 재시작된 컨슈머에 의해 중복 처리된다. 이로 인해 동일한 주문에 대해 두 번 결제가 시도되거나, 확인 메일이 중복 발송되는 문제가 생길 수 있다. 참고로 Inbox 패턴을 활용하여 메시지가 단 한 번만 처리되도록 보장하는 것도 가능하기에 참고해두면 좋다.
4. Kafka에서 Exactly once 방식(중복 X, 유실 X)
마지막으로 가장 높은 수준인 Exactly Once를 보장하기 위한 구현 방법을 알아보자.
Kafka 프로듀서 측면의 Exactly once 방식(중복 X, 유실 X)
acks=all 설정을 해주어 유실을 방지해야 한다. 그리고, 메세지의 중복도 없어야하기에 enable.idempotence=true
로 설정하여 멱등성을 보장해야한다. 프로듀서 멱등성 설정이 적용되면 메시지를 발행할때 프로듀서ID와 시퀀스 번호를 함께 전달하게 되고, Kafka 브로커가 이를 활용하여 중복 메시지를 필터링하게 된다.
또한, 프로듀서가 여러 파티션에 메시지를 발행할때 트랜잭션이 필요하기 때문에 transactional.id
를 지정해줘야한다. 이를 통해 여러 파티션간 메시지 발행에 원자성을 보장함으로써 Exactly once를 보장한다. 트랜잭션 처리가 왜 되어야하는지는 아래 컨슈머 측면에서 더 알아보자.
Kafka 컨슈머 측면의 Exactly once 방식(중복 X, 유실 X)
컨슈머는 수동 커밋을 사용해도 유실을 막거나, 애플리케이션 로직 단에서 중복 처리만 막을 수 있지, 무조건 1번만 메시지가 소비되는 것을 보장할 수 없다. 즉, 정확히 한 번만 메시지가 처리되는 것을 보장하기 위해서는 ‘수동 커밋 설정 + 비즈니스 로직 완료 후 커밋 + 멱등한 비즈니스 로직 구현’이 필요하다.
만약 트랜잭션 사용 프로듀서가 있는 경우엔 별도 트랜잭션 설정이 필요하게 된다. 프로듀서가 여러 파티션에 메시지를 발행할때 원자적으로 TX 커밋이 이뤄져야만, 컨슈머가 모든 메시지를 처리하도록 말이다. 그래서 수동 커밋을 하고 isolation.level=read_committed
로 설정해줘야한다. read_committed이어야지 TX 커밋 후에만 메세지가 컨슈머에게 노출이 되어서 중복 메세지나 트랜잭션이 abort 된 메세지를 소비하지 않게 된다. 참고로 isolation.level의 디폴트 값은 read_uncommitted이다.
트랜잭션에 대한 더 자세한 내용은 해당 Confluent 영상을 참고하면 좋다.
실무 활용 사례
올리브영 주문 관리 시스템(WMS)의 Kafka 메시지 중복 및 유실 문제 해결 사례
Kafka 리밸런싱과 컨슈머의 메시지 중복 소비 문제
Kafka 리밸런싱이 발생할때, 컨슈머의 메시지 중복 소비 문제를 주의해야 한다.
리밸런싱이 발생하게 되면, 컨슈머는 오프셋을 커밋하지 못한채 중단되고 새로 할당된 컨슈머에 의해 중복 처리될 수 있다.
이를 해결 혹은 리스크를 줄일 수 있는 방법은 다음과 같다.
1) max.poll.records 조정 (해결X, 리스크 줄임)
- 리밸런싱 시간 단축: 리밸런싱 발생시 컨슈머들의 JoinGroup 요청을 빠르게 할 수 있기에
- Poll 지연에 의한 리밸런싱 발생 가능성 감소: 일정 시간(
max.poll.interval.ms
) 안에 Poll 요청을 보내지 않으면 리밸런싱이 일어나는데, 이러한 가능성을 줄일 수 있음 - 메시지 중복 소비 리스크 감소: 오프셋 커밋 단위가 줄어들기에, 메시지 중복 소비의 갯수를 줄일 수 있음
- 만약 메시지 처리 성능을 높이고자 할 경우, 다중 파티션 + Concurrency 활용
- 대부분의 서비스에서는 1로 지정해도 큰 이슈가 없을거지만, 메시지 처리 성능이 매우 중요한 서비스일 경우 ‘배치 처리’ + ‘애플리케이션단의 중복 방지 로직’의 구현이 반드시 필요하다.
max.poll.records : The maximum number of records returned in a single call to poll(). Note, that max.poll.records does not impact the underlying fetching behavior. The consumer will cache the records from each fetch request and returns them incrementally from each poll. (참고: https://kafka.apache.org/documentation/)
참고로 Kafka 공식 문서를 찾아보면, max.poll.records
는 컨슈머의 fetch 동작에 영향을 미치지 않는다고 한다. 컨슈머는 비동기적으로 메시지를 fetch 하여 메모리에 캐시해두고, 지정된 갯수만큼 poll()을 통해 꺼내와서 처리하는 구조라고 이해할 수 있다.
2) 수동 커밋 사용 (해결X, 리스크 줄임)
- 오토 커밋 방식으로 인해 컨슈머가 메시지 처리를 완료하지 못했으나, 오프셋이 커밋되는 문제 방지
- 예를 들어, 컨슈머가 메시지 500개 처리 도중 400개만 처리 완료한 상태에서
max.poll.interval.ms
시간이 지나 리밸런싱이 발생하여 메시지 중복 소비 문제가 발생할 수 있음 - 만약
max.poll.records
는 1,AckMode
를BATCH
또는MANUAL
로 지정하게 되면, 한 개씩 메시지를 가져와서 처리 후 커밋하게 되므로, 중복 소비 문제에 대한 영향을 줄일 수 있다.
3) 중복 메시지 소비 방어 로직 구현(해결O)
- 카프카 설정만으로 중복 메시지 소비에 대한 리스크를 완벽하게 관리하는 것은 쉽지 않음
- 중복 메시지 소비 문제가 크리티컬한 서비스라면 애플리케이션 레벨에서 방어 로직을 반드시 구현해야 함
이와 관련하여 아래 레퍼런스들을 참고하면 좋다. 잘못된 내용이 있을 수 있으니 주의할 필요가 있다.
- https://junuuu.tistory.com/m/796
- https://techblog.gccompany.co.kr/카프카-컨슈머-그룹-리밸런싱-kafka-consumer-group-rebalancing-5d3e3b916c9e
정리
메시징 큐 시스템의 메시지 전달 보장 방식(Message Delivery Semantics)
보장 방식 | 설명 | 메시지 유실/중복 여부 |
---|---|---|
At most once | 메시지가 최대 한 번 전달됨을 보장 | 중복 X, 유실 O |
At least once | 메시지가 최소 한 번 전달됨을 보장 | 중복 O, 유실 X |
Exactly once | 메시지가 정확히 한 번만 전달됨을 보장 | 중복 X, 유실 X |
프로듀서 측 설정
보장 수준 | 설정 | 설명 |
---|---|---|
At most once | - acks=0 - retries=0 | 메시지의 중복 발행을 방지하며, Kafka 브로커가 메시지를 수신했는지 확인하지 않음 |
At least once | - acks=all | 메시지 유실을 방지하며, 메시지를 한 번 이상 전송할 수 있음 |
Exactly once | - acks=all - enable.idempotence=true - transactional.id | 멱등성 프로듀서 설정과 트랜잭션을 사용하여 메시지의 중복 발행과 유실을 방지함 |
컨슈머 측 설정
보장 수준 | 설정 | 설명 |
---|---|---|
At most once | - enable.auto.commit=false - 수동 커밋 모드에서 메세지를 처리 전 오프셋을 커밋함 | 메시지를 처리하지 못할 경우, 해당 메시지가 유실될 수 있음 |
At least once | - enable.auto.commit=false - 수동 커밋 모드에서 메시지를 처리 후 오프셋을 커밋함 | 오프셋을 커밋하지 못한 경우, 메시지 중복 처리가 발생할 수 있음 |
Exactly once | - enable.auto.commit=false - isolation.level=read_committed - 트랜잭션 커밋된 메시지만 소비 | 메시지의 유실과 중복을 방지하며, TX 설정을 통해 프로듀서의 커밋된 메시지만 소비함 |