Kafka 파티션 이해

우리는 흔히 MSA(Microservices Architecture) 환경에서 비동기 메시징 처리를 위해 Kafka를 사용한다. 특히 이메일 발송과 같이 시간이 오래 걸리는 작업을 비동기로 처리할 때 Kafka는 매우 유용하다. 이를 활용하여 Spring Boot 기반의 컨슈머 애플리케이션에서 메시지 처리 성능을 극대화하는 방법에 대해 알아보자.

비효율적인 메시지 처리 현상 분석

먼저, 우리가 겪을 수 있는 비효율적인 메시지 처리 상황부터 살펴보자
이메일 발송 작업을 가정하고, 프로듀서가 다음 API 요청을 통해 Kafka email.send 토픽으로 3개의 메시지를 연속해서 보낸다

POST http://localhost:8080/api/emails
Content-Type: application/json

{
  "from": "sender@test.com",
  "to": "receiver@test.com",
  "subject": "제목",
  "body": "내용"
}

컨슈머 애플리케이션은 이메일 발송 작업을 시뮬레이션하기 위해 Thread.sleep(3_000); 코드를 포함하고 있어, 하나의 이메일 발송에 3초가 소요된다. API 요청을 3번 보냈을 때, 다음과 같은 로그가 3초 간격으로 순차적으로 출력되는 것을 확인할 수 있다

Kafka로부터 받아온 메시지: {"from":"sender@test.com","to":"receiver@test.com","subject":"제목","body":"내용"}
이메일 발송 완료 (3초 후)
Kafka로부터 받아온 메시지: {"from":"sender@test.com","to":"receiver@test.com","subject":"제목","body":"내용"}
이메일 발송 완료 (3초 후)
Kafka로부터 받아온 메시지: {"from":"sender@test.com","to":"receiver@test.com","subject":"제목","body":"내용"}
이메일 발송 완료 (3초 후)

총 3개의 이메일을 발송하는 데 9초가 소요된다. Spring Boot 컨슈머는 멀티쓰레드 기반으로 병렬 처리가 가능함에도 불구하고, 마치 싱글 쓰레드처럼 하나의 메시지만 처리하고 다음 메시지를 처리하는 비효율적인 모습이 보인다. 이러한 현상은 근본적인 원인은 바로 Kafka의 ‘파티션(Partition)’개념과 밀접하게 관련되어 있다

Kafka 파티션의 이해

파티션(Partition)은 Kafka 토픽의 핵심 구성 요소이자 메시지 처리량에 직접적인 영향을 미치는 기본 단위이다. 메시지를 임시로 저장하는 큐(Queue)를 여러 개로 분할하여 병렬 처리를 가능하게 한다

파티션의 주요 특징

  • 토픽 구성: 각 토픽은 하나 이상의 파티션으로 구성될 수 있다. 토픽 생성 시 파티션 수를 명시하지 않으면 기본값인 1개의 파티션이 생성된다. 예를 들어, email,send 토픽은 처음에 email.send-0이라는 하나의 파니션을 가진다. 하지만 –partitions 옵션을 통해 여러 파티션(email.send-0, email.send-1, email.send-2 등)을 생성할 수 있다
  • 메시지 분산: 프로듀서가 토픽으로 메시지를 보내면, Kafka는 내부 정책에 따라 메시지를 여러 파티션에 적절하게 분산 저장한다. 마치 대형마트에 여러 계산대가 있어 손님들이 분산되는 것과 유사하다. 이는 메시지 처리량을 늘리는 데 기여한다
  • 컨슈머 할당 규칙 (동일 컨슈머 그룹 내)
    • 하나의 파티션은 동일한 컨슈머 그룹 내에서 단 하나의 컨슈머에게만 할당된다. 즉, email.send-0 파티션에 있는 메시지는 consumer-group-A의 consumer-0(스프링 부트 컨슈머 서버)과 consumer-1이 동시에 처리할 수 없다. 오직 하나의 컨슈머 인스턴스만이 특정 파티션의 소유권을 가져 메시지를 가져갈 수 있다
    • 하나의 컨슈머는 여러 파티션을 처리할 수 있다. 예를 들어, consumer-0이 email.send-0과 email.send-1 파티션의 메시지를 동시에 처리하는 것은 가능하다
  • 파티션 내 메시지 순서 보장: 하나의 파티션에 할당된 컨슈머는 메시지를 순서대로 처리한다. 즉, 오프셋(Offset)이 0인 메시지를 완전히 처리한 후에 오프셋이 1인 메시지를 처리한다. 이 특징은 메시지의 처리 순서를 보장하기 위함이지만, 동시에 하나의 파티션 내에서는 병렬 처리가 불가능하다는 것을 의미한다

이러한 파티션 내 순서 보장 특정 때문에, 위에서 겪었던 9초 소요 현상이 발생한 것이다. 컨슈머는 email.send-0 파티션에 할당되어 메시지를 순차적으로 처리했기 때문에, 아무리 Spring Boot가 멀티쓰레드 기반이라 할지라도 하나의 파티션에서 오는 메시지는 하나씩밖에 처리할 수 없었던 것이다

단일 파티션 환경에서의 컨슈머 그룹 동작 확인

우리는 email.send 토픽이 기본값이 1개의 파티션(email.send-0)을 가지고 있다고 가정하고, Spring Boot 컨슈머 서버를 두 대 (consumer-0, consumer-1) 실행하여 테스트를 해본다

컨슈머 서버 두 대를 실행하고 3개의 API 요청을 보낸 후 로그를 확인하면, 오직 하나의 컨슈머(예: consumer-0)에서만 모든 이메일 발송 로그가 찍히고, 다른 컨슈머 서버(consumer-1)에서는 아무런 로그도 찍히지 않는 것을 확인할 수 있다

이는 “하나의 파티션은 동일한 컨슈머 그룹 내에서 단 하나의 컨슈머에게만 할당된다”는 규칙을 명확하게 보여준다. 두 대의 컨슈머가 존재하더라도 파티션이 하나뿐이므로, 그 파티션은 하나의 컨슈머에게만 할당되고 나머지 컨슈머는 유휴 상태가 된다. 이는 명백히 비효율적인 리소스 사용이다

파티션 관리 (조회, 생성, 변경)

Kafka CLI를 사용하여 파티션을 관리하는 방법을 알아보자

특정 토픽의 파티션 수 조회

bin/kafka-topics.sh --bootstrap-server localhost:9092 --describe --topic email.send

명령어 실행 결과에서 PartitionCount:1은 해당 토픽의 총 파티션 개수를 나타내며, PartitionCount:0은 0번 파티션이 존재함을 의미한다. 별다른 옵션 없이 토픽을 생성하면 기본적으로 파티션이 1개만 생성됨을 확인할 수 있다

토픽 생성 시 여러 파티션 설정

bin/kafka-topics.sh --bootstrap-server localhost:9092 --create --topic test.topic --partitions 3

위 명령어는 test.topic이라는 토픽을 3개의 파티션으로 생성한다. describe 명령어로 확인하면 PartitionCount:3 및 Partition:0, Partition:1, Partition:2를 확인할 수 있다

기존 토픽의 파티션 수 늘리기

bin/kafka-topics.sh --bootstrap-server localhost:9092 --alter --topic test.topic --partitions 5

위 명령어는 test.topic의 파티션 수를 3개에서 5개로 늘린다. describe 명령어로 확인하면 PartitionCount:5 및 Partition:0부터 Partition:4까지 생성된 것을 볼 수 있다

파티션 수 줄이기 (불가능)

bin/kafka-topics.sh --bootstrap-server localhost:9092 --alter --topic test.topic --partitions 3

위 명령어를 실행하면 “3 would not be an increase”와 같은 에러가 발생한다. Kafka는 파티션 수를 늘릴 수는 있지만 줄일 수는 없다. 파티션 수를 줄이는 과정에서 데이터 손실 및 복잡한 내부 문제가 발생할 수 있기 때문이다. 만약 파티션 수를 줄여야 한다면, 새로운 토픽을 원하는 파티션 수로 생성하고 기존 토픽의 데이터를 새 토픽으로 마이그레이션해야 한다. 이 과정은 번거로울 수 있으므로, 초기 파티션 수를 신중하게 결정하는 것이 중요하다.

메시지 분배 방식 (파티셔닝 전략)

프로듀서가 메시지를 여러 파티션에 분산시키는 방식은 메시지의 형태에 따라 달라진다

키(Key)가 포함되지 않은 메시지 (Value만 있는 경우)

  • 이 경우 메시지는 스티키 파티셔닝(Sticky Partitioning) 방식으로 분배된다
  • 스티키 파티셔닝은 배치 단위로 메시지를 처리하기 위해, 하나의 파티션에 일정량 메시지가 채워지면 다음 파티션으로 메시지를 지정하는 방식이다. 예를 들어, 10개의 메시지 덩어리를 첫 번째 파티션에 넣고, 다음 10개 덩어리를 두 번째 파티션에 넣는 식이다
  • Kafka 2.4 버전 이전에는 라운드 로빈(Round-Robin) 방식(메시지가 들어오는 대로 파티션에 하나씩 번갈아 분배)을 사용했지만, 대규모 메시지 처리의 성능 효율성을 위해 스티키 파티셔닝으로 변경되었다
  • 문제점: 작은 규모의 메시지를 처리할 때는 메시지가 특정 파티션에만 몰릴 수 있어 비효율적이다

키(Key)가 포함된 메시지 (Key-Value가 있는 경우)

  • 메시지의 키 값을 해시(Hash) 계산하여 해당 숫자 값을 기반으로 파티션을 분배한다
  • 동일한 키 값을 가진 메시지는 항상 같은 파티션에 들어가게 된다. 이 특성은 메시지의 순서가 중요한 경우(예: 특정 사용자 ID와 관련된 이벤트는 항상 동일한 순서로 처리되여야 할 때) 매유 유용하다

라운드 로빈 파티셔닝 적용

spring:
  kafka:
    bootstrap-servers: localhost:9092 # 실제 Kafka 서버 주소로 변경
    producer:
      key-serializer: org.apache.kafka.common.serialization.StringSerializer
      value-serializer: org.apache.kafka.common.serialization.StringSerializer
      properties:
        partitioner.class: org.apache.kafka.clients.producer.RoundRobinPartitioner

이휴 프로듀서 서버를 재시작하고 API 요청을 여러 번 보내면, 다음과 같은 메시지가 파티션 0, 1, 2에 번갈아가며 분배되는 것을 확인할 수 있다 (사전에 email.send 토픽을 생성했다면 3개의 파티션으로 다시 생성해야 한다)

bin/kafka-console-consumer.sh --bootstrap-server localhost:9092 --topic email.send --from-beginning --property print.partition=true

Partition:0     {"from":"sender@test.com","to":"receiver@test.com","subject":"제목","body":"내용"}
Partition:1     {"from":"sender@test.com","to":"receiver@test.com","subject":"제목","body":"내용"}
Partition:2     {"from":"sender@test.com","to":"receiver@test.com","subject":"제목","body":"내용"}
Partition:0     {"from":"sender@test.com","to":"receiver@test.com","subject":"제목","body":"내용"}
...

Spring Boot 컨슈머의 병렬 처리 전략

여러 파티션을 활용하여 Spring Boot 컨슈머에서 메시지를 병렬적으로 처리하는 방법을 알아보자

여러 컨슈머 서버를 이용한 병렬 처리

email.send 토픽이 3개의 파티션으로 생성되어 있고, 프로듀서는 라운드 로빈 방식으로 메시지를 분배한다고 가정한다

컨슈머 서버 한 대 실행 (예: consuer-0)
  • consumer-0이 시작되면, Kafka는 해당 컨슈머에게 3개의 파티션(email.send-0, email.send-1, email.send-2)을 모두 할당한다. 로그에 partitions assigned: [email.send-0, email.send-1, email.send-2]와 유사한 메시지가 출력될 것이다. 이 상태에서 API 요청을 3번 보내면, consumer-0은 여전히 메시지를 3초 간격으로 순차적으로 처리한다. 이는 “하나의 파티션 내에서는 순서가 보장되며 병렬 처리가 되지 않는다”는 규칙 때문이다
컨슈머 서버 한 대 추가 실행 (예: consumer-1)
  • consumer-1을 추가로 실행하면, Kafka는 컨슈머 그룹 내의 컨슈머들에게 파티션을 재분배(Rebalance)한다 예를 들어, consumer-1에게는 email.send-2 파티션이 consumer-1 에게는 email.send-0과 email.send-1 파티션이 할당될 수 있다. 이제 API 요청을 3번 보내면, 두 컨슈머 서버에서 동시에 이메일 발송 작업이 처리된느 것을 로그를 통해 확인할 수 있다. consumer-1은 두 파티션을 담당하므로 3초 간격으로 2개의 메시지를 병렬적으로 처리하고, consumer-0은 하나의 파티션을 담당하므로 3초 간격으로 1개의 메시지를 처리한다

consumer 서버 2대 설정하는 방법

나의 인텔리 J는 제공한 이미지와 달랐지만 얼추 비슷해서 서버 2개를 설정하는데 무리가 없었다

이렇게 파티션 수를 늘리고 컨슈머 서버 수를 늘림으로써 메시지를 병렬적으로 처리하여 전체 처리 시간을 단축할 수 있다. 마치 대형마트에 계산대가 많을수록 손님 처리 속도가 빨라지는 것과 같다

주의: 이 방법은 컨슈머 서버 자체의 리소스가 부족하건, 컨슈머의 처리 작업이 매우 무거워 서버를 분리해야 할 필요가 있을 떄 유효하다. 하지만 단순히 하나의 Spring Boot 애플리케이션 내에서 쓰레드 활용을 극대화하고 싶다면 다음 방법을 고려할 수 있다

하나의 컨슈머 서버에서 멀티쓰레드를 이용한 병렬 처리 (concurrency 설정)

하나의 Spring Boot 컨슈머 서버를 사용하되, 여러 파티션의 메시지를 병렬적으로 처리하고 싶다면 @KafkaListener 어노테이션에 concurrency 속성을 활용할 수 있다

@Service
public class EmailSendConsumer {

    @KafkaListener(
            topics = "email.send",
            groupId = "email-send-group",
            concurrency = "3" // 병렬 처리를 위한 쓰레드 개수 설정
    )
    @RetryableTopic(
            attempts = "5",
            backoff = @Backoff(delay = 1000, multiplier = 2),
            dltTopicSuffix = ".dlt"
    )
    public void consume(String message) {
        System.out.println("Kafka로부터 받아온 메시지: " + message);

        EmailSendMessage emailSendMessage = EmailSendMessage.fromJson(message);

        if (emailSendMessage.getTo().equals("fail@test.com")) {
            System.out.println("잘못된 이메일 주소로 인해 발송 실패");
            throw new RuntimeException("잘못된 이메일 주소로 인해 발송 실패");
        }

        // 카프카에 대한 내용이므로 실제 이메일 발송 로직은 생략.
        try {
            Thread.sleep(3_000);
        } catch (InterruptedException e) {
            throw new RuntimeException("이메일 발송 실패");
        }

        System.out.println("이메일 발송 완료");
    }
}

concurrency = “3”은 Spring Boot 컨슈머가 3개의 쓰레드를 활용하여 메시지를 병렬적으로 처리하겠다는 의미이다. 각 쓰레드는 하나의 파티션을 담당하게 된다. 컨슈머 서브를 재실행하고 API 요청을 보내면 다음과 같이 로그가 출력된다

Kafka로부터 받아온 메시지: {"from":"sender@test.com","to":"receiver@test.com","subject":"제목","body":"내용"}
Kafka로부터 받아온 메시지: {"from":"sender@test.com","to":"receiver@test.com","subject":"제목","body":"내용"}
Kafka로부터 받아온 메시지: {"from":"sender@test.com","to":"receiver@test.com","subject":"제목","body":"내용"}
이메일 발송 완료 (3초 후)
이메일 발송 완료 (3초 후)
이메일 발송 완료 (3초 후)

이전과는 다르게, 3개의 메시지가 거의 동시에 Kafka로부터 수신된 후, 3초 뒤에 “이메일 발송 완료” 로그가 동시에 3번 출력된다. 이는 3개의 쓰레드가 각각 다른 파티션의 메시지를 병렬적으로 처리했기 때문이다. 만약 5개의 메시지를 연속으로 보낸다면 다음과 같은 패턴을 보인다

Kafka로부터 받아온 메시지: {"from":"sender@test.com","to":"receiver@test.com","subject":"제목","body":"내용"}
Kafka로부터 받아온 메시지: {"from":"sender@test.com","to":"receiver@test.com","subject":"제목","body":"내용"}
Kafka로부터 받아온 메시지: {"from":"sender@test.com","to":"receiver@test.com","subject":"제목","body":"내용"}
이메일 발송 완료
이메일 발송 완료
이메일 발송 완료
Kafka로부터 받아온 메시지: {"from":"sender@test.com","to":"receiver@test.com","subject":"제목","body":"내용"}
Kafka로부터 받아온 메시지: {"from":"sender@test.com","to":"receiver@test.com","subject":"제목","body":"내용"}
이메일 발송 완료
이메일 발송 완료

첫 3개의 메시지는 동시에 처리되지만, 나머지 2개의 메시지는 이전 3개의 메시지 중 일부가 처리 완료된 후에야 쓰레드에 할당되어 처리된다. 이는 토픽의 파티션 개수가 3개이기 때문에, 동시에 병렬 처리할 수 있는 메시지의 최대 개수 또한 3개로 제한되기 때문이다. concurrency 값을 ㅌ파티션 수보다 크게 설정해도, 실제 병렬 처리되는 쓰레드수는 할당된 파티션 개수를 초과할 수 없다. 즉, 최대 병렬 처리 개수는 파티션 개수에 의해 정해진다

핵심: 파티션 수를 늘리고, 컨슈머의 concurrency를 적절히 설정함으로써 하나의 컨슈머 애플리케이션 내에서도 효율적인 병럴 처리가 가능하다

적정 파티션 개수 설정 및 모니터링

하나의 컨슈머 쓰레드가 처리할 수 있는 최대 처리량 (Throughput) 측정

  • Spring Boot 컨슈머가 몇 개의 쓰레드를 활용할 때 가장 효율적으로 요청을 처리하는지 측정한다 (예: 100개의 쓰레드를 사용할 때 가장 효율적)
  • 그리고 하나의 컨슈머 서버가 이 적정 쓰레드개수를 기반으로 1초당 처리할 수 있는 최대 처리량을 측정한다 (예: 1초당 평균 100개의 이메일 발송)
  • 이를 통해 1개의 쓰레드가 1초당 처리하는 요청 수를 계산할 수 있다(예: 30 / 100 = 0.3/초)

프로듀셔가 보내는 평균 메시지량 예측

  • 사용자가 1초당 평균적으로 얼마나 많은 API 요청(메시지)을 보내는지 측정하거나 예측한다 (예: 1초당 평균 100개의 이메일 발송 요청)

파티션 개수 계산

  • 처리가 지연되지 않으려면 프로듀서가 보내는 메시지량이 컨슈머가 처리하는 메시지량보다 적거나 같아야 한다. 또한, 예상치 못한 트래픽 증가를 고려하여 여유분을 두는 것이 좋다
  • 프로듀셔가 보내는 메시지량 ≤ (하나의 쓰레드가 처리하는 메시지량 x 파티션 수)
예를 들어, 1초당 평균 100개의 메시지가 들어오고, 최대 120개까지 처리할 수 있도록 목표를 설정한다면
  • 120 (목표 처리량) = 0.3 (쓰레드당 처리량) x 파티션 수
  • 파티션 수 = 120 / 0.3 = 400개

컨슈머 랙(Consumer Lag) 모니터링

Lag (컨슈머 랙)은 컨슈머가 아직 처리하지 못한 메시지의 수를 의미하며, 메시지 처리가 지연되고 있다는 중요한 지표이다. 프로듀서의 메시지 생산량이 컨슈머의 메시지 처리량보다 많을 때 발생한다. 갑작스러운 트래픽 증가나 컨슈머 서버 장애 시 컨슈머 랙이 증가할 수 있다. 컨슈머 랙이 지속적으로 증가하면 사용자 경험에 부정적인 영향을 미치므로, 이를 지속적으로 모니터링 하고 빠르게 조치해야 한다

CLI를 이용한 컨슈머 랙 확인

컨슈머 서버를 종료(장애 상황 가정)하고 프로듀서로 API 요청을 여러 번 보낸 후 다음 명령어로 컨슈머 랙을 확인할 수 있다

bin/kafka-consumer-groups.sh --bootstrap-server localhost:9092 --group email-send-group --describe

출력 결과 중 LAG 항목을 통해 각 파티션에 쌓여있는 처리되지 않은 메시지 수를 확인할 수 있다

GROUP            TOPIC       PARTITION  CURRENT-OFFSET  LOG-END-OFFSET  LAG  CONSUMER-ID  HOST  CLIENT-ID
email-send-group email.send     1           7                8           1        -         -      -
email-send-group email.send     0           10               11          1        -         -      -
email-send-group email.send     2           6                8           2        -         -      -

위 예시에서는 email.send-1과 email.send-0 파티션에 각각 1개, email.send-2 파티션에 2개의 처리되지 않은 메시지가 쌓여있음을 알 수 있다

현업에서의 컨슈머 랙 모니터링

CLI 명령은 일회성 확인에 적합하지만, 24시간 모니터링에는 부적합하다. 현업에서는 주로 다음 방법을 사용한다

외부 모니터링 출 사용
  • DataDog와 같은 유로 모니터링 툴과 Prometheus, Grafana 등과 같은 무료 외부 모니터링 툴을 사용하여 컨슈머 랙을 주기적으로 모니터링하고, 특정 임계값을 초과할 경우 알림(Alert)을 발생시켜 빠르게 대응할 수 있도록 설정한다
매니지드 서비스(Managed Service)의 모니터링 기능 활용
  • AWS MSK, Confluent Cloud와 같은 클라우드 기반의 Kafka 매지니드 서비스를 사용하면, 자체적으로 컨슈머 랙 모니터링 기능을 제공하는 경우가 많다. 이러한 서비스를 활용하면 Kafka 운영의 복잡성을 줄이고 효율적인 모니터링이 가능하다

인프런 강의 중 ‘실전에서 바로 써먹는 Kafka 입문’ 강의 참고