728x90

카프카를 사용하다 보면 예상치 못한 시점에서 중복 메시지가 발생할 수 있다. 카프카는 파티션의 각 레코드에 대한 위치를 숫자로 관리하고 위치에 대한 숫자를 Offset이라고 한다. 그리고 그 숫자는 PrimaryKey 역할을 한다.

 

중복 메시지를 해결을 하기 위해서는 우선적으로 Consumed Offset 과 Committed Offset에 대해 명확하게 알아야 해결이 가능하다.

Consumed Offset은 컨슈머가 메시지를 어디 지점까지 읽었는지를 나타낸다.Consumed Offset은 컨슈머가 poll을 받을 때마다 자동으로 업데이트 되기 때문에 컨슈머가 읽어야 할 다음의 메시지 위치를 식별이 가능하다.

 

Committed Offset은 컨슈머가 메시지를 읽고 해당 지점까지 오프셋을 처리했다라고 알려준다. 만약 프로세스가 실패하고 재시작을 하게 된다면 Committed Offset을 기준으로 재시작을 하게 된다. 고로 Commited Offset은 컨슈머가 다시 메시지를 읽게 될 시작점이 되는 오프셋이기도 하다. Committed Offset은 카프카 내부 __consumer_offsets 토픽에서 관리한다.

 

그럼 이제 이 오프셋들을 컨슈머가 메시지를 읽어들이는 과정을 예시로 어떻게 동작 하는지 한번 보자

  1. 특정 파티션을 컨슈머가 poll() 메서드를 호출하면 아직 읽지 않은 메시지를 반환한다. 그리고 메시지 읽어서 가져 갈 때 마다 Consumed Offset이 업데이트 된다.
  2. 읽어들인 메시지를 정상적으로 처리하고, 이후 Offset Commit 을 실행하고 카프카에게 정상 처리된 메시지의 최종 위치를 알린다. 그 최종 위치가 Committed Offset이다.

이 동작은 정상적으로 처리 됐을 때 예시다. 모든 동작이 100 프로 정상이면 좋겠지만 개발에 100프로는 없다. 그러나 만약 컨슈머에서 장애가 발생하고 컨슈머 그룹에 새로운 컨슈머가 추가 될 경우 리벨런싱이 발생한다. 리벨런싱이 발생 할 경우에는 각 컨슈머에 할당되는 파티션이 바뀔수도 있다. 그 다음 리벨런싱 된 컨슈머에서는 Commited Offset을 기준으로 재시작하게 된다. 하지만 컨슈머에서 발생한 장애 시점이 Offset Commit이 되기 직전이라면? 이 상황에서 중복 메시지 이슈가 발생 할 수 있다.

  1. 컨슈머 A에서 읽어들인 메시지를 모두 읽어서 정상처리 했다고 가정을 했을 때 Commited Offset이 4라고 가정하자
  2. 그리고 그 다음 다시 poll()메서드를 실행하여 Commit Offset 4 이후 부터 실행을 해서 8까지 처리했다고 가정을 하자
  3. 그 다음 8까지 실행 후 Commited Offset 실행 중 오류가 발생해서 리벨런싱이 실행되고 메시지를 가지고 있던 파티션의 소유주가 컨슈머 B라고 생각하자. 여기서 중복 메시지가 발생한다. 소유권을 가진 컨슈머 B는 Committed Offset이 4인줄 알게된다. 위에서 8까지 실행 후 offset commit에 실패 했기 때문이다. 이러면 이미 읽어들인 메시지를 또 읽어 중복으로 메시지를 읽는 현상이 발생한다.
Kafka: The Definitive Guide

 

이러한 문제점이 발생한다고 가정을 하고 컨슈머 쪽에서 대응이 필요하다.

중복된 메시지를 해결 할 만한 방법 중 하나도 중복 메시지를 걸러내는 로직을 구현 하는 것이다. 비즈니스 로직 실행과 처리한 메시지 정보를 insert 하는 것을 하나의 트랜잭션으로 묶어서 메시지가 중복 실행 되지 않도록 구현 해준다. 우선 처리되는 메시지 프로세스 내용을 저장하는 테이블을 만들고 메시지의 식별자를 unique index로 걸어두게 되면 만약 중복으로 실행된다면 해당 테이블에 입력이 되지 않고 전체 트랜잭션이 롤백이 된다.

 

그 다음으로는 transactional outbox pattern이 있다. 이 패턴은 비즈니스 로직과 메시지 발행 로직을 하나의 트랜잭션으로 묶어서 동작하는 방식이다. 메시지 발행 로직에서 message_outbox라는 테이블을 생성하여 외부에 전달할 메시지 정보를 해당 테이블에 insert하는 방식으로 구현한다. 그 다음 구현한 메시지 발행 로직과 비즈니스 로직을 하나의 트랜잭션 단위로 묶어서 구현하게 되면 원자성을 보장하는 트랜잭션 내부에서 비즈니스 로직과 메시지 발행 로직은 항상 함께 성공,실패를 하게 된다. 이후 message_outbox 테이블을 바라보면서 외부로 메시지를 발행하는 로직을 별도로 구현하면, 비즈니스 로직이 실행 완료되면 반드시 외부로 메시지가 발행되는 것이 보장된다.

https://www.eliasbrange.dev/posts/transactional-outbox-pattern-dynamodb/

 

이 방법 이외에도 있지만 아직 필자가 정확하게 다뤄본적 없는 내용이라 명쾌하게 설명이 힘들것 같아 조금 더 공부하고 별도로 써볼려고 한다.

카프카 자체가 우리에게 가져다주는 이점도 분명 있지만 정확하게 알지 못하고 사용하면 오히려 더 독이 될 수도 있다.

필자의 카프카 시리즈는 앞으로 계속될것이다.

728x90