Kafka의 Message Delivery Semantics 그리고 Exactly Once 전송

간단한 Kafka 소개

Kafka는 이벤트 스트리밍 플랫폼으로서 다양한 소스에서 생성되는 이벤트를 수집하고 적절히 처리하여 여러 사용처에 공급할 수 있습니다. 여러 분야에서 사용할 수 있지만 특히 메시지 브로커로서는 ActiveMQRabbitMQ와 등의 솔루션과 차별화되는 장점을 갖습니다. 이 글은 Kafka 공식 문서 4.6절의 Message Delivery Semantics 부분을 읽고 정리한 글입니다.

Kafka의 메시지 전달 보장

Kafka에서 메시지 전달 보장 수준은 3단계로 나눌 수 있습니다.

  1. At most once - 메시지는 단 한 번만 전송하며 유실될 수 있고 재전송하지 않는다.
  2. At least once - 메시지는 절대 유실되지 않고 최소 한 번 전송이 완료된다. 하지만 재전송이 일어날 수 있다.
  3. Exactly once - 메시지는 유실되지 않고 단 한 번 전송이 완료된다. 재전송이 일어나지 않는다. 대부분의 사용자가 기대하는 보장 수준이다.

메시지 전달 보장은 마치 데이터베이스 시스템의 영속성(Durability) 보장 문제와 비슷합니다. 문제 없이 완료된 트랜잭션에 대해서 데이터가 영구적으로 DB에 저장되는 것처럼 전송한 메시지에 대해서 어디까지 보장할지 선택할 수 있습니다.

Kafka 공식 문서에서는 명확하게 프로듀서와 컨슈머를 나눠서 설명하지 않았지만 이 글에서는 프로듀서와 컨슈머 관점에서 각각 어떻게 메시지 전달을 보장하는지 나눠서 설명하였습니다.

프로듀서 관점에서 전송 보장

먼저 프로듀서 입장에서 At most once 보장은 간단합니다. 메시지를 한 번 전송하면 끝이기 때문에 따로 전송 결과에 대한 응답을 기다릴 필요도 없습니다. 따라서 메시지가 다소 유실되어도 괜찮고 대신 더 높은 성능을 요구하는 상황이라면 At most once를 고려해볼만 합니다.

다음은 At least once 입니다. 메시지를 전송한 후 해당 메시지가 타겟 파티션에 저장되었다는 응답을 확인합니다. 만약 실패한 경우 메시지를 다시 전송합니다. 이 때 메시지는 응답을 받는 리더 파티션 뿐만 아니라 이를 복제한 팔로워 파티션에 저장하는 것까지 설정의 acks 값으로 조절할 수 있습니다. acks=all로 설정한 경우 모든 ISR(In Sync Replicas)에 저장되었음을 보장합니다.

마지막으로 Exactly once 입니다. 0.11.0.0 버전 이전에는 전송한 메시지의 저장 실패 응답을 받은 경우 메시지를 재전송하는 것 외 선택지가 없었습니다. 즉, At least once 까지만 보장할 수 있었습니다. 0.11.0.0 버전 이후 메시지 전송을 멱등적(idempotency)으로 수행할 수 있게 되었습니다. 브로커는 프로듀서에 ID를 부여하고 거기서 보내는 메시지에는 시퀀스 번호를 붙여 중복으로 전송된 메시지를 무시할 수 있게 되었습니다. 또한 0.11.0.0 부터 여러 토픽에 메시지를 보내는 과정을 트랜잭션화하여 전부 성공하거나 혹은 모두 실패하는 원자적(atomicity) 실행을 보장할 수 있게 되었습니다. (해당 기능은 아래 메시지 처리 후 다른 토픽에 쓰는 경우에서 다시 언급합니다.)

메시지 전송 보장 수준을 결정할 때 성능과 영속성(Durability) 사이에서 결정을 할 수 있습니다. 메시지가 유실될 위험이 있어도 지연시간이 짧은 것이 더 중요하다면 설정을 통해 정책을 고를 수 있습니다. acks=0으로 설정한 경우 메시지 전송 후 파티션에 잘 저장되는지 따로 기다리지 않습니다. acks=1로 설정하면 리더 파티션에만 저장된 것을 확인합니다. 위에 언급했듯이 acks=all로 설정하면 모든 ISR에 저장되는 것을 확인한 후 응답합니다. 멱등성 또한 enable.idempotenty 설정을 통해 바꿀 수 있습니다.

컨슈머 관점에서 메시지 처리 보장

Kafka에서는 컨슈머가 메시지를 어디까지 읽었는지 오프셋(위치)을 내부 토픽인 __consumer_offsets에 기록합니다. 이 때 오프셋을 기록하는 시점에 따라 2가지 선택지가 존재합니다.

  1. 컨슈머가 메시지를 읽은 후 오프셋을 먼저 기록한다. 그리고 메시지를 처리한다.
  2. 컨슈머가 메시지를 읽고 모든 처리를 마친다. 성공적으로 마쳤다면 오프셋을 기록한다.

1번의 경우 메시지 처리에 실패하는 경우 오프셋이 이미 기록되어있으므로 실패한 메시지를 넘기고 다음 메시지를 읽게 됩니다. 따라서 메시지가 유실되는 문제가 발생합니다. 즉, At most once에 해당합니다.

2번의 경우 메시지 처리를 완료한 후 로그에 오프셋을 기록하기 직전에 컨슈머가 장애를 일으켜 재시작할 수 있습니다. 오프셋 기록이 일어나지 않았으므로 재시작한 후 이전 오프셋부터 메시지를 중복으로 읽어 처리하게 됩니다다. 이는 At least once에 해당합니다.

그럼 Exactly once는 어떻게 보장할까요? 컨슈머는 1) 메시지 처리와 2) 메시지 오프셋 기록하기 총 2가지 일을 해내야 합니다. Exactly Once를 보장하기 위해서는 이 2개의 작업을 하나의 트랜잭션으로 묶어서 둘 중 하나가 실패한 경우 전부 롤백되어야 하고 두 가지가 모두 성공한 경우에만 성공으로 처리할 수 있습니다. 이 때 메시지 처리 과정이 구체적으로 무엇이냐에 따라 상황이 달라집니다.

메시지 처리 후 다른 토픽에 쓰는 경우

먼저 특수한 상황으로 메시지를 소비한 후 바로 다른 토픽으로 발행하는 상황입니다. 즉 메시지 처리 동작이 곧 메시지 발행인 경우입니다. 이 과정을 처리하기 위해 위에서 언급한 0.11.0.0 버전에서 추가된 여러 토픽에 메시지를 전송을 트랜잭션화하는 기능을 사용합니다. 즉, 오프셋을 기록하기 위해 내부 토픽인 __consumer_offsets에 메시지를 발행하는 작업과 컨슈머의 메시지 처리 결과를 다른 토픽에 쓰는 과정을 하나의 트랜잭션으로 묶습니다. 이 두 가지 작업을 트랜잭션으로 묶으면 둘 중 하나가 실패하여 전부 롤백되거나 둘이 함께 성공하기 때문에 Exactly Once를 보장할 수 있습니다. 공식문서에서는 이 과정에 Kafka Streams를 언급하는데 정확한 해석인지는 모르겠지만 Kafka Streams가 토픽에서 다른 토픽으로 메시지를 처리하고 이동하는 과정을 만드는데 유용한 것 같습니다.

메시지 처리 후 외부 저장소에 쓰는 경우

다음으로 컨슈머의 메시지 처리 과정이 외부 시스템에 데이터를 쓰는 상황입니다. (다르게 말하면 컨슈머가 외부 저장소인 상황) 이 경우 고전적인 방법은 two-phase commit을 사용하는 것입니다. 하지만 two-phase commit을 서로 다른 저장소 (Kafka의 __consumer_offsets 파티션, 외부 저장소)에 대해 구현하는 것이 쉽지 않으므로 공식문서에서는 컨슈머의 오프셋을 외부 저장소에 함께 저장하는 것을 권합니다. 예를 들면 컨슈머가 MySQL이라면 컨슈머 오프셋을 저장하는 테이블을 따로 생성합니다. 그 후 처리한 레코드를 저장하는 쿼리와 컨슈머 오프셋을 업데이트하는 쿼리를 MySQL의 트랜잭션으로 묶어서 처리합니다. 이 경우 two-phase commit을 지원하지 않더라도 오프셋 업데이트와 메시지 처리는 동시에 성공하거나 실패함을 보장하고 외부 저장소에 저장된 컨슈머 오프셋 번호를 보고 메시지를 가져올 수 있고 가져온 메시지가 중복인지 확인할 수도 있습니다. 공식문서 예시로는 HDFS(Hadoop distributed file system)를 위한 Kafka Connect 커넥터를 사용하면 데이터 저장과 오프셋 업데이트를 트랜잭션화 하여 처리할 수 있다고 합니다.

마치며

Kafka에서는 메시지 전송 보장 수준을 3가지 중 하나를 선택할 수 있고 이 글에서는 프로듀서와 컨슈머 측면에서 어떻게 메시지 전송을 보장할 수 있는지 설명하였습니다.

최근 Kafka 공부를 위해 공식 문서를 읽으면서 중요하다고 생각한 부분을 정리했습니다. 실제 공식 문서를 보면 그렇게 길지 않은 글인데 제 나름대로 해석하고 정리하느라 상당히 어려웠네요. 저의 해석이나 정리가 틀린 것을 발견하셨다면 댓글로 달아주시면 감사드리겠습니다!

참고문헌

comments powered by Disqus

Related