실무 관점에서의 Apache Kafka 활용

Greg Lee
16 min readDec 17, 2023

--

이 글은 Apache Kafka 간략하게 살펴보기 에 이어서 “실무 관점에서 생각하는 카프카의 주요한 장점”과 “카프카를 활용하여 서비스를 개발할 때 주의해야 할 것들” 을 정리한 글이다.

실무 관점에서 생각하는 카프카의 주요한 장점

필자가 생각하는 카프카의 주요한 장점은 카프카가 다중 컨슈머(Multiple Consumer) 기능을 지원한다는 것이다. 다중 컨슈머 기능이란 여러 개의 컨슈머 그룹이 서로간의 상호 간섭 없이 각자의 오프셋으로 각자의 순서에 맞게 메시지를 읽고 처리할 수 있는 것을 말한다. 이러한 방식은 컨슈머가 메시지를 읽으면 해당 메시지가 소비되어 다른 컨슈머에서는 읽을 수 없게 되는 메시지 큐(Message Queue) 와는 다른 방식이다.

여기서 중요한 개념은 컨슈머 그룹(Consumer Group) 인데, 카프카에서 동일한 group.id 속성을 공유하는 컨슈머들을 하나의 컨슈머 그룹으로 볼 수 있다. 그리고 토픽의 메시지를 읽을 때에는 컨슈머 그룹 각각 동일한 메시지를 개별적으로 읽어간다고 보면 된다.

하나의 토픽에 여러 개의 컨슈머 그룹이 메시지를 읽을 수 있음 (Kafka : The Definitive Guide 89p 참고)

카프카에서 다중 컨슈머가 가능한 이유는 각각의 컨슈머 그룹들은 하나의 토픽 내의 메시지를 읽을 때마다 메시지를 어디까지 읽었는지를 표시하는 오프셋을 개별적으로 관리하고 있기 때문이다. 그렇기 때문에 여러 컨슈머 그룹이 하나의 토픽에 붙어서 메시지를 읽더라도 각자의 메시지 처리 속도와 서비스 상황에 따라 개별적으로 동작할 수 있게 된다. 그리고 카프카는 메시지를 디스크에 기록하고 저장한다. 이는 컨슈머가 메시지 읽기를 시도했다가 실패하더라도 메시지가 유실되지 않는 장점과 새로운 컨슈머 그룹이 토픽에 붙어서 메시지를 읽을 때 과거에 기록된 메시지를 컨슈머가 읽을 수 있는 장점을 제공한다.

같은 토픽 내의 메시지를 다수의 애플리케이션이 개별적으로 읽을 수 있게 될 때 서비스 개발 과정에서 얻을 수 있는 장점은 상당히 크다고 본다.
상품 서비스에서 상품 등록 이벤트를 메시지로 발행했다고 가정해보자. 카프카 이전의 서비스 구조에서는 해당 메시지에 관심을 가지는 여러 개의 구독 서비스에게 상품 서비스가 push 방식으로 메시지를 일일이 전달하거나, 구독 서비스마다 상품 서비스가 제공하는 API 를 폴링하거나, 또는 상품 서비스의 데이터베이스를 직접 붙어서 확인하는 것이 보편적인 구현 방식이었다. 그러나 카프카를 활용하면, 상품 서비스에서 상품 등록 이벤트를 카프카의 토픽에 메시지로 발행하고, 이후에는 해당 메시지에 관심을 가지는 여러 컨슈머 서비스가 해당 메시지를 각자 읽고 본인의 목적에 맞는 로직을 실행하면 된다.

이러한 구조는 개발과 운영 관점에서 높은 수준의 확장성과 유연성을 제공한다. 메시지를 발행하는 쪽에서는 카프카에 메시지를 정확하게 발행하는 것에만 집중하고, 메시지를 수신하는 쪽에서는 메시지를 읽고 본인의 목적에 맞는 로직 처리만 실행하면 되기 때문이다. 이는 마이크로서비스가 추구하는 독립적인 개발과 배포, 운영을 가능하게 해준다. 또한 현재는 구현되지 않은 컨슈머도 추후에 새롭게 구현하여 붙일 수 있고, 그러면서도 기존의 다른 컨슈머의 동작에는 영향을 주지 않으면서 서비스 로직의 확장을 가져갈 수 있다.

물론 하나의 메시지가 발행될 때, 단 하나의 실행만 이루어져야 하는 요구사항에서는 카프카를 활용하는 것보다는 메시지 큐를 활용하는 것이 목적에 더 맞는 구조라고 볼 수도 있다. 경쟁하는 소비자 패턴(Competing Consumers Pattern) 을 예시로 든다면, 해당 패턴은 여러 개의 메시지를 빠르게 처리해야 할 때 컨슈머의 수를 늘려서 각각의 메시지 소비 속도를 늘리는 것을 말하는데, 이 때에는 특정 컨슈머가 읽은 메시지는 다른 컨슈머가 읽을 수 없도록 명확하게 소비되어야 한다. 그리고 그러한 상황에서는 카프카를 통한 로직 처리보다는 메시지 큐를 활용하는게 맞다.

컨슈머의 갯수가 증가함에 따라 처리 속도가 향상되는 Competing Consumers Patterns

카프카를 활용한 서비스 개발 시 주의할 점

카프카는 다중 컨슈머 기능 지원을 포함하여 서비스를 개발하고 운영하는 관점에서 다양한 선택지를 제공하는 유용한 시스템이지만, 카프카가 가진 특징을 명확히 알고 서비스를 구현해야 의도에 맞는 비즈니스 로직 구현과 운영이 가능하다. 모든 아키텍처가 그렇듯이 장점만 있는 설계와 구현은 존재하지 않는다. 모든 것은 트레이드 오프가 있고 카프카도 우리가 얻을 수 있는 장점만큼, 보완하고 고려해야할 사항도 분명히 존재한다. 이러한 관점을 기반으로 카프카를 활용한 서비스 개발에서 검토해야 할 기술적인 이슈를 정리한다.

1. 메시지 순서 보장

도메인 로직 중에는 논리적인 순서 보장이 중요한 것이 있다.
예를 들어 이커머스 서비스에서 고객이 특정한 상품을 주문하고, 이후에 해당 주문을 취소하는 이벤트가 발생했다고 가정해보자. 이 때 주문 완료와 주문 취소의 메시지 처리 순서가 어긋나서 주문 취소 메시지를 먼저 처리하게 된다면, 우선 주문 취소 메시지에 담긴 주문 식별자는 존재하지 않을 것이기 때문에(아직 발생하지 않은 주문은 식별자가 있을 수 없다) 주문 취소 과정에서는 오류가 발생할 것이다. 그리고 이후에는 앞선 주문 취소와 관계없이 (고객의 의도와는 다르게) 최종적으로는 주문이 완료된 상태로 주문이 생성될 것이다. 이는 메시지의 처리 순서가 어긋나면서 시스템이 고객의 의도와 다르게 동작하는 상황을 설명하는 예시가 된다.

이러한 문제 상황이 발생하지 않으려면 메시지의 발행 순서에 맞게 시스템이 순차적으로 메시지를 읽고 로직을 처리할 수 있어야 한다. 그리고 이를 위해서는 카프카의 토픽에 메시지를 발행할 때 메시지의 키 값을 포함하여 메시지를 발행해야 한다.

카프카의 토픽은 여러 개의 파티션으로 구성되는데 메시지는 파티션에 추가되는 형태로만 기록되고 맨 앞부터 제일 끝까지의 순서로 읽히게 된다. 즉, 카프카에서 메시지가 읽히는 순서는 토픽이 아닌 파티션별로 관리된다고 보면 된다. 그리고 프로듀서에서 카프카의 토픽에 메시지를 발행할 때에는 토픽의 여러 파티션 중에서 어느 파티션에 기록할지 결정하는 로직이 있는데, 이것이 카프카의 파티셔너(partitioner) 로직이다. 파티셔너는 메시지에 키가 없을 때에는 라운드 로빈(round-robin) 방식으로 메시지를 파티션에 분배하여 저장하고, 키가 있을 때에는 해당 키 값의 해시(hash) 를 구한 후에 그 값에 맞는 특정한 파티션에 메시지를 저장한다. 그렇기 때문에 메시지의 키 값이 동일하면 항상 같은 파티션에 메시지가 저장되게 된다.

동일한 키를 가진 이벤트는 동일한 파티션에 기록된다.

이렇기 때문에 메시지의 순서가 중요한 도메인 로직에서 카프카에 메시지를 발행할 때에는 여러 도메인 이벤트를 대표하는 도메인 식별자와 같은 값을 메시지의 키 값으로 선언하여 메시지를 발행하는 것이 중요하다.

2. 중복 메시지 처리

중복 메시지 이슈를 설명하려면 카프카가 관리하는 오프셋의 개념을 이해해야 한다.
카프카는 파티션의 각 레코드에 대한 위치를 숫자로 관리하는데 이를 오프셋이라고 한다. 오프셋은 해당 파티션 내에서 레코드의 고유한 식별자 역할을 한다. 오프셋에서 컨슈머와 관련된 개념은 크게 2가지가 있다.

  1. Consumed Offset (Current Offset): 컨슈머가 메시지를 어디까지 읽었는가를 나타낸다. 해당 오프셋을 통해 컨슈머가 읽어야 할 다음의 메시지 위치를 식별할 수 있다. 해당 오프셋은 컨슈머가 poll( )을 받을 때마다 자동으로 업데이트 된다. 해당 오프셋은 각각의 컨슈머가 관리한다.
  2. Committed Offset : 컨슈머가 메시지를 읽고 카프카에게 ‘여기까지의 오프셋을 처리했다’ 는 것을 알리는 Offset Commit 을 통해 업데이트되는 오프셋이다. 컨슈머의 프로세스가 실패하고 다시 시작되면 컨슈머가 다시 메시지를 읽게 될 시작점이 되는 오프셋이기도 하다. 해당 오프셋은 __consumer_offsets 라고 하는 카프카의 내부 토픽에서 관리한다.

컨슈머가 파티션에서 메시지를 읽어들이는 과정을 Consumed Offset 과 Committed Offset 을 연관지어 이야기해보면 다음과 같다.

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

이러한 흐름은 컨슈머가 정상적으로 동작할 때의 흐름이다. 그러나 만일 컨슈머에서 장애가 발생하거나 새로운 컨슈머가 컨슈머 그룹에 추가될 때에는 리밸런싱이 발생하고, 리밸런싱 이후에는 각 컨슈머에게 할당되는 파티션이 바뀔 수도 있게 된다. 이 때 각각의 컨슈머는 각 파티션의 Committed Offset 부터 메시지를 읽어들이게 된다. (Consumed Offset 이 아니라 Committed Offset 이다) 바로 이 구간에서 중복 메시지 이슈가 발생할 수 있다. 이러한 상황을 구체적인 예시로 이야기해보면 다음과 같다.

  1. 컨슈머에서 읽어들인 메시지를 모두 정상 처리하고 이후 Offset Commit 을 실행했다고 가정해보자. 그리고 이 때의 Committed Offset 을 숫자 2라고 해보자.
  2. 이후 다시 poll( ) 메서드를 실행하여 일정한 수량만큼의 메시지를 읽고, 이 때의 Consumed Offset 을 숫자 11 이라고 해보자.
  3. 이후 다시 Offset Commit 을 실행하기 직전에 일련의 사유로 리밸런싱이 시작되었다고 가정해보자. 그렇게 되면 리밸런싱이 완료된 이후에 해당 파티션의 소유권을 가진 컨슈머는 오프셋 11 이후의 메시지를 읽는 것이 아니라, (과거에 이미 읽고 처리를 완료한) Committed Offset 2 이후의 메시지를 다시 읽게 된다. 이렇게 되면 컨슈머는 과거에 읽고 로직을 처리했을 수도 있는 메시지를 중복으로 다시 읽고 처리할 가능성이 생기게 된다.
committed offset 과 consumed offset 의 차이만큼 중복 메시징 가능성 있음 (Kafka : The Definitive Guide 76p)

카프카 입장에서는 Offset Commit 을 실행하기 전에 컨슈머가 읽어간 메시지가 정상적으로 처리 되었는지를 알 방법이 없기 때문에, 리밸런싱 이후에는 Committed Offset 을 기반으로 메시지를 전달할 수 밖에 없다.
즉, 카프카를 활용한 비즈니스 구현에서는 Committed Offset 이후의 메시지 구간에서 중복 메시징 이슈가 발생할 수 있다는 것을 전제로 두고, 컨슈머가 이러한 상황을 스스로 해결해야 함을 인지하는 것이 중요하다.

이와 같이 카프카를 통한 메시지 수신 과정에서는 메시지 중복이 발생할 수 있는데 애플리케이션 개발에서 중복 메시지를 처리할 수 있는 방안은 크게 2가지가 있다.

  1. 멱등한(idempotent) 메시지 처리 로직을 구현하기
  2. 중복 메시지를 걸러내는 로직을 구현하기

1) 멱등한 메시지 처리 로직 구현하기
동일한 입력 값으로 로직을 반복적으로 실행해도 결과가 달라지지 않고 처음에 처리한 것과 동일한 결과를 가지는 성질을 멱등하다(idempotent) 고 말한다. 멱등성을 활용한다는 것은 카프카를 통한 메시지 수신은 중복으로 발생할 수 있지만, 설령 그렇더라도 처음에 실행한 것과 동일한 결과를 유지하게끔 로직을 구현하는 것을 말한다. 예를 들어 한번 취소한 주문을 다시 취소하는 것은 멱등성이 유지되는 로직이라고 볼 수 있다.
하지만 실제로 멱등성을 유지하면서 로직을 구현하는 것은 대부분 쉽지 않고, 비즈니스 의미상 멱등성이 불가능한 로직이 있을 수도 있다. 따라서 멱등성을 고려한 중복 메시징 처리는 제한적인 옵션으로 생각하는게 좋다.

2) 중복 메시지를 걸러내는 로직 구현하기
1) 비즈니스 로직을 실행하는 것과 2) 읽고 처리한 메시지 정보를 insert 하는 것을 하나의 트랜잭션으로 묶어서 중복된 메시지가 실행되지 않도록 로직을 구현한다. 가령 PROCESSED_MESSAGE 라는 테이블을 정의하고 메시지의 식별자를 해당 테이블의 유니크 인덱스(unique index) 로 걸어놓는다면, 이미 처리된 메시지를 컨슈머가 다시 읽게 될 때에는 PROCESSED_MESSAGE 에 insert 를 하는 과정이 실패하고 전체 트랜잭션이 롤백되어 동일 메시지가 중복으로 실행되는 경우를 방지할 수 있게 된다.

중복 메시징 제거 로직 구현 (https://microservices.io/post/microservices/patterns/2020/10/16/idempotent-consumer.html 참고)

3. 트랜잭셔널 메시징

비동기 메시징을 활용한 서비스 구현에서는 비즈니스 로직이 실행되었을 때, 이를 표현하는 이벤트도 온전하게 발행되는 것이 중요하다. 도메인 로직이 완료된 이후에 이벤트가 발행되지 않는다면, 해당 이벤트를 바라보는 컨슈머는 특정한 로직을 실행할 수 없게 되고, 이로 인해 전체 서비스의 데이터 정합성이 깨지거나 특정한 로직에서 버그가 발생할 수 있기 때문이다. 예를 들어 상품 등록이 완료되었으나 그에 맞는 이벤트가 발행되지 않는다면, 상품 등록 이후에 로직을 실행해야 하는 여러 서비스에서는 ‘상품이 등록되었다’ 는 사실을 알 방법이 없게 되고, 이는 서비스 전체적으로 데이터 정합성이 깨지는 상황으로 연결된다.

이와 같이 서비스 로직의 실행과 그 이후의 이벤트 발행을 원자적으로(atomically) 함께 실행하는 것을 트랜잭셔널 메시징(Transactional Messaging) 이라고 한다. 그리고 이러한 트랜잭셔널 메시징을 구현하는 방법은 크게 2가지가 있다.

  1. 트랜잭셔널 아웃박스 패턴 (Transactional Outbox Pattern)
  2. 변경 데이터 캡쳐 (Change Data Capture)

1) 트랜잭셔널 아웃박스 패턴 (Trancational Outbox Pattern)
비즈니스 로직과 메시지 발행 로직을 하나의 트랜잭션으로 묶어서 실행하는 방식이다.
메시지 발행 로직은 OUTBOX 라는 테이블을 생성하여 외부에 전달할 메시지 정보를 해당 테이블에 담는 방식으로 구현한다. 그리고 이렇게 구현한 메시지 발행 로직을 비즈니스 로직과 하나의 트랜잭션으로 묶어서 구현하면, 원자성을 보장하는 트랜잭션 안에서 비즈니스 로직과 메시지 발행 로직은 항상 함께 성공하거나 실패하게 된다. 이후 OUTBOX 를 바라보면서 외부로 메시지를 발행하는 로직을 별도로 구현하면, 비즈니스 로직이 실행되면 반드시 외부로 메시지가 발행되는 것을 보장하는 구현이 완성된다.
(아웃박스 패턴의 실제 구현 사례는 추후 별도의 글로 소개할 예정이다)

트랜잭셔널 아웃박스 패턴 (https://microservices.io/patterns/data/transactional-outbox.html 참고)

2) 변경 데이터 캡쳐 (Change Data Capture)
변경 데이터 캡쳐(Change Data Capture) 는 데이터베이스에서 데이터 변경이 발생할 때마다 이를 읽어서 메시지를 발행하거나 로직을 수행하기 위해 사용되는 다양한 디자인 패턴을 가리키는 용어이다.
트랜잭셔널 아웃박스 패턴은 비즈니스 로직 개발과 별개로 메시지를 발행하기 위한 추가적인 로직 구현이 필요한데, 메시지 발행 시마다 이러한 구현을 별도로 진행해야 하는 것은 다소 부담이 되는 것이 사실이다. 이 때 CDC 를 지원하는 라이브러리 또는 플랫폼을 사용하면, 추가 구현에 대한 부담 없이 비즈니스 로직 실행 이후의 메시지 발행을 비교적 쉽게 할 수 있다. Debezium(https://debezium.io/) 은 CDC 를 지원하는 대표적인 플랫폼인데, MySQL 의 binlog 와 같은 트랜잭션 로그를 활용하여 변경된 데이터를 확인하고, 이후 이를 메시지로 변환하여 타겟으로 설정한 카프카에 발행해준다.

디비지움 아키텍처 (https://debezium.io/documentation/reference/2.4/architecture.html 참고)

Debizium 을 활용하면 트랜잭셔널 아웃박스 패턴보다는 메시지 발행을 위한 추가적인 개발 공수를 적게 넣을 수 있지만, Debizium 을 학습하고 운영하는 팀 비용이 필요하고, 동작 원리상 테이블의 스키마가 변경될 때마다 발행되는 메시지의 형태가 바뀔 수 있기 때문에 목표하는 비즈니스 로직에 따라서 트랜잭셔널 아웃박스 패턴과 CDC 를 적절히 혼용하여 사용하는 것이 필요하다.

4. 리밸런싱 문제

한 컨슈머로부터 다른 컨슈머로 파티션 소유권이 이전되는 것을 리밸런싱(Rebalancing) 이라고 한다. (리밸런싱 관련한 자세한 사항은 Apache Kafka 간략하게 살펴보기 에서 확인 가능하다)

리밸런싱이 발생하면 토픽에 연결된 모든 Consumer의 동작이 일시적으로 중지되는데, 이 때문에 카프카를 활용하는 애플리케이션은 리밸런싱으로 인한 서비스 처리 성능 저하를 겪게 된다.
가령 마이크로서비스에서는 독립된 서비스들이 수시로 배포를 진행할 수 있는데, 그 때마다 기존 컨테이너가 내려가고 신규 컨테이너가 올라가는 과정에서 파티션의 소유권이 변경되는 리밸런싱이 수시로 발생하게 되고, 그 때마다 카프카와 관련한 모든 동작이 일시적으로 멈추게 되면(stop the world) 해당 서비스의 처리 성능에 대한 고민을 할 수 밖에 없게 된다.

사실 해당 이슈는 카프카 진영에서 과거부터 인지된 문제이고, 카프카 2.3 버전 이후에는 Incremental Cooperative Rebalancing 이라는 디자인이 적용되어 문제가 해결된 상태이다. 해당 이슈에 대한 자세한 사항은 필자도 참고하면서 도움을 많이 얻었던 블로그를 통해 확인 가능하다. (https://devidea.tistory.com/100)

카프카는 대용량의 실시간 스트리밍 데이터를 처리하는 데 있어서 대체할 플랫폼이 없을만큼 수많은 기업에서 사용되고 있는 검증된 오픈소스이다. 그만큼 카프카가 가진 장점과 활용 범위는 상당히 크고 넓은데, 모든 아키텍처가 그렇듯이 카프카를 선택하고 활용하는데 있어서 얻을 수 있는 장점만큼, 팀이 지불해야 할 비용과 주의할 점도 충분히 체크해봐야 한다.

카프카를 활용하여 비즈니스 로직을 개발할 때 발생할 수 있는 중복 메시징 이슈, 메시지 순서 보장을 위해 고려해야할 점, 트랜잭셔널 메시징을 지킬 수 있는 개발 방안 등을 검토하여, 카프카를 채택했을 때 얻을 수 있는 장점은 충분히 누리면서 비즈니스 목적에 맞는 개발이 이루어질 수 있기를 바란다.

다음 아티클은 29CM 에서 실제로 구현하여 운영중인 트랜잭셔널 아웃박스 패턴의 실제 사례를 소개하고자 한다.
(트랜잭셔널 아웃박스 패턴의 실제 구현 사례 (29CM))

참고 서적
1. 카프카 핵심 가이드
2. 실전 아파치 카프카

--

--