카프카

[Kafka] Parallel-Consumer을 통한 알림 성능 개선 과정(3(완))

27200 2026. 3. 21. 21:49
‼️💡 최종 개선 결과 💡‼️

- 초당 처리량 362.12건 → 6,767.27건
- 약 18.7배 성능 향상

총 3편의 내용으로 구성된 개선 과정이다.

더보기

 

5차 구현(최종)

Kafka를 도입한 이후, 조회는 단일 서버에서 수행하고 실제 처리는 여러 consumer가 분산해서 처리하는 구조까지는 만들 수 있었다.

하지만 이 구조에도 위에서 살펴본 것 같은 한계가 남아 있었다.

정리하자면 다음과 같다.

  • partition 수가 곧 병렬성의 상한이 된다.
  • partition을 늘리지 않으면 같은 partition 내부에서는 기본적으로 순차 처리된다.
  • partition을 무작정 늘리는 것은 ordering, 운영 복잡도, 리밸런싱 비용, 브로커 부담 등 추가적인 문제를 만든다.

즉 단순히 “더 빠르게 처리하고 싶다”는 이유만으로 파티션을 계속 늘리는 것은 적절한 해결책이 아니었다.

이 지점에서 도입한 것이 Kafka Parallel Consumer이다.

도입 이유

1. 메시지 순서 보장 문제와 무관하다.

기본 Kafka consumer 모델에서는 보통 partition 단위로 병렬성이 결정된다.

즉, partition이 n개라면 consumer group 안에서 의미 있는 병렬 처리도 사실상 n개 흐름에 가깝다.

 

하지만 목표 구현점은 아래와 같았다.

  • 토픽 partition을 함부로 조절하고 싶지 않다.
  • 알림 처리를 일정한 순서로 제공해야 하는 기능 요구 사항이 없다.
  • 그럼에도 처리 자체는 더 병렬적으로 수행하고 싶다.

Kafka Parallel Consumer가 이런 요구사항에 맞았다.

이 라이브러리는 poll은 consumer가 하되, 실제 record 처리는 내부적으로 더 병렬화할 수 있게 해준다.

 partition 수를 늘리지 않고도 처리 동시성을 높일 수 있다.

2. partition을 늘릴 때 생기는 ordering/운영 부담을 줄이고 싶었다

앞서 확인했듯이 partition 증설은 단순한 설정 변경이 아니다.

  • key ordering 보장이 달라질 수 있고
  • consumer rebalance 비용이 생기며
  • broker 메타데이터 및 운영 비용도 커질 수 있다.
  • Apache Kafka 공식 문서: Ordering Guarantees

반면 Kafka Parallel Consumer는 같은 partition을 유지한 채,
처리 로직만 더 병렬적으로 실행할 수 있도록 도와준다.

특히 이번 구현에서는 기농 요구 사항에 맞춰 UNORDERED 모드를 사용해,

순서 보장보다 처리량을 극대화하는 방식으로 병렬성을 확보했다.

즉 5차 구현은 “파티션을 늘리기 전에, 현재 partition 구조 안에서 처리량을 더 높여보자”는 시도였다.

3. 단순 worker pool보다 offset commit을 더 안전하게 다루고 싶었다

직접 poll thread와 worker thread를 분리해서 구현하는 것도 가능하다.

실제로 이전 단계에서는 수동 ack와 worker pool을 이용해 비슷한 실험을 했다.

 

하지만 이 방식에는 어려움이 있었다.

  • poll과 워커 스레드를 분리해야 한다.
  • 어떤 offset까지 안전하게 commit 가능한지 직접 고려해야 한다.
  • 선행 메시지가 끝나지 않았는데 후행 메시지가 끝난 경우 commit 전략이 복잡해진다.
  • 구현 실수 시 메시지 유실이나 중복 처리 위험이 커진다.

Kafka Parallel Consumer는 이런 부분을 라이브러리 차원에서 추상화해준다.

즉 5차 구현에서 이 라이브러리를 도입한 이유는,
단순히 “병렬로 처리하고 싶어서”가 아니라

partition 내부 병렬 처리와 offset commit 관리를 더 안전하고 일관되게 가져가기 위해서였다.

실제 구현

// 구현 예시
@Service
@RequiredArgsConstructor
public class GreenroomNotificationKafkaParallelConsumerRunner {

    private static final String GROUP_ID = "greenroom-notification-parallel-consumer-group-v1";

    private final KafkaProperties kafkaProperties;
    private final ObjectMapper objectMapper;
    private final GreenroomNotificationKafkaBenchmarkConsumerService consumerService;

    public RunningConsumer start() {
        Map<String, Object> props = new HashMap<>(kafkaProperties.buildConsumerProperties());
        props.put(ConsumerConfig.GROUP_ID_CONFIG, GROUP_ID);
        props.put(ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG, false);
        props.put(ConsumerConfig.AUTO_OFFSET_RESET_CONFIG, "latest");
        props.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class);
        props.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class);

        Consumer<String, String> consumer = new KafkaConsumer<>(props);

        var options = ParallelConsumerOptions.<String, String>builder()
            .consumer(consumer)
            .ordering(ParallelConsumerOptions.ProcessingOrder.UNORDERED)
            .maxConcurrency(10)
            .commitMode(ParallelConsumerOptions.CommitMode.PERIODIC_CONSUMER_ASYNCHRONOUS)
            .ignoreReflectiveAccessExceptionsForAutoCommitDisabledCheck(true)
            .build();

        ParallelStreamProcessor<String, String> processor =
            ParallelStreamProcessor.createEosStreamProcessor(options);

        processor.subscribe(singleton(GreenroomNotificationKafkaParallelConsumerBenchmarkService.TOPIC));

        ExecutorService pollExecutor = Executors.newSingleThreadExecutor(runnable -> {
            Thread thread = new Thread(runnable);
            thread.setName("kafka-parallel-consumer-poller");
            return thread;
        });

        Future<?> pollFuture = pollExecutor.submit(() -> processor.poll(context -> {
            try {
                consumerService.consume(
                    objectMapper.readValue(
                        context.getSingleConsumerRecord().value(),
                        GreenroomNotificationOutboxSaveEvent.class
                    )
                );
            } catch (Exception exception) {
                throw new IllegalStateException("Parallel consumer processing failed", exception);
            }
        }));

        return new RunningConsumer(processor, pollExecutor, pollFuture);
    }
}

테스트

# 5 Thread
[GREENROOM KAFKA PARALLEL-CONSUMER BATCH BENCHMARK] 
eligible=100000, 
outboxCreated=100000, 
eventPublishSec=1.277, 
totalSec=19.868, 
msgsPerSecond=5033.22
thread=pc-pool-4-thread-1, accepted=20938
thread=pc-pool-4-thread-2, accepted=20237
thread=pc-pool-4-thread-3, accepted=19929
thread=pc-pool-4-thread-4, accepted=20102
thread=pc-pool-4-thread-5, accepted=18794

# 10 Thread
[GREENROOM KAFKA PARALLEL-CONSUMER BATCH 10 BENCHMARK] 
eligible=100000, 
outboxCreated=100000, 
eventPublishSec=1.297, 
totalSec=14.777, 
msgsPerSecond=6767.27
thread=pc-pool-4-thread-1, accepted=20938
thread=pc-pool-4-thread-2, accepted=20237
thread=pc-pool-4-thread-3, accepted=19929
thread=pc-pool-4-thread-4, accepted=20102
thread=pc-pool-4-thread-5, accepted=18794
thread=pc-pool-7-thread-1, accepted=10246
thread=pc-pool-7-thread-10, accepted=9936
thread=pc-pool-7-thread-2, accepted=10413
thread=pc-pool-7-thread-3, accepted=10276
thread=pc-pool-7-thread-4, accepted=10161
thread=pc-pool-7-thread-5, accepted=9780
thread=pc-pool-7-thread-6, accepted=9872
thread=pc-pool-7-thread-7, accepted=10015
thread=pc-pool-7-thread-8, accepted=8721
thread=pc-pool-7-thread-9, accepted=10580

테스트에서는 쓰레드가 10개인 경우가 더 속도가 빨랐지만, 이는 실제 서버에 다양한 상황에 대한 테스트를 진행한 후 적합한 값을 찾아서 적용하면 좋을 것 같다. 필자의 경우 10개로 적용했다.

5차 구현의 한계와 문제점

5차 구현에서는 Kafka parallel-consumer를 도입해, partition 수를 늘리지 않고도 같은 partition 내부 처리 병렬성을 높이려 했다.

이전 단계보다 더 높은 처리량을 기대할 수 있었고, 직접 worker pool과 manual ack를 관리하는 것보다 구현 부담도 줄일 수 있었다.

하지만 이 방식도 완전한 해결책은 아니며, 여전히 여러 한계가 존재한다.

1. 병목이 Kafka가 아니라 DB일 수 있다

parallel-consumer를 도입하면 Kafka 소비 병렬성은 높일 수 있다.

하지만 실제 처리 로직이 다음과 같이 DB 작업 중심이라면:

  • outbox insert
  • target select
  • target update
  • flush

최종 병목은 Kafka가 아니라 DB가 될 가능성이 크다.

 maxConcurrency를 높여도 DB가 감당하지 못하면 기대만큼 성능이 오르지 않을 수 있다.

실제로 병렬성이 높아질수록 DB write 경쟁, flush 비용, connection 사용량이 더 커질 수 있다.

2. UNORDERED는 순서 보장을 포기하는 방식이다

5차 구현에서는 처리량을 우선하기 위해 UNORDERED 모드를 사용했다.

이 방식은 가장 높은 동시성을 제공하지만, 반대로 순서 보장은 하지 않는다.

즉 다음과 같은 상황에서는 주의가 필요하다.

  • 같은 사용자에 대한 알림 순서가 중요한 경우
  • 같은 ticket에 대한 sequence 처리 순서가 중요한 경우
  • 앞선 이벤트보다 뒤 이벤트가 먼저 반영되면 안 되는 경우

현재 실험에서는 처리량 측정 목적상 괜찮았지만,

실제 운영 환경에서는 순서 보장이 필요한지 여부를 먼저 판단해야 한다.

3. partition 수를 늘리지 않는 대신, 내부 처리 복잡도가 증가한다

5차 구현의 장점은 partition 수를 늘리지 않고도 병렬성을 확보하는 것이다.

하지만 그 대가로 consumer 처리 모델 자체가 더 복잡해진다.

  • 기본 Kafka consumer보다 이해 비용이 크다
  • 디버깅 포인트가 늘어난다
  • 실패/재처리/offset commit 동작을 라이브러리 동작까지 이해해야 한다

즉 단순 consumer group 구조보다 운영과 디버깅 난이도가 올라갈 수 있다.

4. 라이브러리 의존성이 생긴다

5차 구현은 Kafka 기본 기능만으로 만든 구조가 아니라,

parallel-consumer라는 외부 라이브러리에 의존한다.

이 말은 곧:

  • 라이브러리 버전 호환성 문제
  • Spring Kafka / Kafka Client / Java 버전과의 충돌 가능성
  • 장애 발생 시 공식 Kafka 문서만으로는 해결되지 않는 문제

등을 함께 감수해야 한다는 뜻이다.

실제로 구현 중에도 enable.auto.commit reflection 검사 문제처럼

기본 Kafka consumer만 쓸 때보다 추가적인 호환성 이슈가 발생할 수 있었다.

정리

5차 구현은 분명 의미 있는 개선이었다.

  • partition 수를 늘리지 않고 병렬성을 높일 수 있었다
  • manual ack + worker pool보다 구조가 정리되었다
  • offset 관리 부담을 일부 라이브러리에 위임할 수 있었다

하지만 여전히 다음 문제는 남아 있다.

  • DB 병목 가능성
  • 순서 보장 포기
  • 운영 복잡도 증가
  • 외부 라이브러리 의존성
  • 조회/발행 비용은 별도
  • 더 큰 규모에서는 여전히 partition 전략 필요

즉 5차 구현은 최종 종착점이라기보다,

기존 Kafka 기반 구조 위에서 partition 내부 병렬성을 높이기 위한 현실적인 개선 단계고 보는 것이 맞다.

 

1차 ~ 5차 구현 흐름 정리

1차 구현: 스케줄러가 직접 조회하고 직접 처리

가장 처음에는 스케줄러가 직접 due target을 조회하고,

각 target에 대해 바로 sendNotification 또는 DB 저장을 수행한 뒤, target 상태를 갱신하는 구조였다.

특징

  • 구조가 단순함
  • 구현이 빠름
  • 모든 로직이 한 메서드 안에 들어감

문제점

  • 한 트랜잭션에 너무 많은 작업이 묶임
  • 배치/청크 처리 없음
  • 실패 처리 부족
  • 서버가 여러 대면 중복 처리 가능

2차 구현: target별 Redis 분산 락 적용

중복 알림을 막기 위해 target 단위 Redis 락을 적용했다.

각 target마다 개별 lock key를 잡고, lock 획득에 성공한 서버만 해당 target을 처리하도록 만들었다.

특징

  • 같은 target의 중복 처리는 줄일 수 있음
  • 서로 다른 target은 병렬 처리 가능

문제점

  • 모든 서버가 여전히 같은 시점에 DB를 조회함
  • 조회 부하가 서버 수만큼 반복될 수 있음
  • Redis lock 시도도 target 수만큼 발생함

즉 처리 중복은 줄었지만, 조회 비용 최적화는 해결하지 못했다.

3차 구현: 조회는 단일 서버, 처리는 Kafka consumer group 분산 처리

이 문제를 해결하기 위해 조회 단계와 처리 단계를 분리했다.

  • 조회는 전역 Redis 락으로 한 서버만 수행
  • 조회된 target은 Kafka 이벤트로 발행
  • 실제 처리는 여러 consumer가 분산 소비

특징

  • 조회 중복 제거
  • consumer group 기반 분산 처리 가능
  • 서버 확장에 따라 처리량 확장 가능

문제점

  • Kafka 도입으로 구조 복잡도 증가
  • 메시지 중복/재처리/idempotency 고려 필요
  • 여전히 partition 수가 병렬성 상한이 됨

4차 구현: Kafka consumer 처리 최적화, 배치 저장 도입

Kafka consumer가 메시지를 하나 받을 때마다 DB에 바로 저장하면 DB 부하가 크기 때문에,

consumer 내부에서 메시지를 모아 200건 단위로 배치 저장하는 방식을 적용했다.

특징

  • 메시지마다 DB write하지 않음
  • saveAll 기반 batch insert/update 가능
  • idle flush로 마지막 남은 건도 저장 가능

문제점

  • 메모리 버퍼 관리 필요
  • flush 전 장애 시 유실 가능성 고려 필요
  • 배치 크기와 idle 시간 튜닝 필요

즉 4차 구현은 Kafka 구조 위에서 DB write 비용을 줄이기 위한 최적화 단계였다.

5차 구현: Kafka Parallel Consumer 도입

이후 partition 수를 늘리지 않고도 더 높은 병렬성을 확보하기 위해

Kafka parallel-consumer를 도입했다.

특징

  • 같은 partition 내부에서도 병렬 처리 가능
  • UNORDERED + maxConcurrency로 처리량 개선 시도
  • manual ack + worker pool보다 offset 관리가 정리됨

문제점

  • 순서 보장 포기
  • DB가 병목이면 성능 향상이 제한적
  • 라이브러리 의존성 증가
  • 운영/디버깅 복잡도 증가

최종 흐름 요약

전체 흐름을 한 문장으로 요약하면 다음과 같다.

  • 1차: 단순 직접 처리
  • 2차: target별 락으로 중복 처리 방지
  • 3차: 조회와 처리를 분리하고 Kafka 도입
  • 4차: Kafka consumer의 DB 저장을 배치화
  • 5차: partition 내부 병렬성을 높이기 위해 parallel-consumer 도입

즉 전체 개선 흐름은 다음 방향으로 진화했다.

  1. 단순 구현
  2. 중복 처리 방지
  3. 조회/처리 분리
  4. DB 쓰기 최적화
  5. Kafka 소비 병렬성 최적화

이 과정을 통해 단순 스케줄러 기반 구현에서 시작해,

점차 분산 처리, 메시지 기반 아키텍처, 배치 처리, 병렬 consumer 최적화까지 확장해 나간 구조라고 정리할 수 있다.


번외

조회 및 이벤트 발행 로직을 Spring Scheduler가 아닌 AWS Lambda로 분리했다.

기존에는 Spring 서버 내부 스케줄러가 주기적으로 실행되면서,

알림 대상 조회와 이벤트 발행까지 모두 담당하는 구조를 사용했다.

하지만 이후 이 역할을 애플리케이션 서버에서 완전히 분리하여,

AWS Lambda가 주기적으로 실행되며 조회 후 이벤트를 발행하는 구조로 전환할 수 있었다.

 

즉 역할은 다음과 같이 분리된다.

  • Spring 서버
    • Kafka consumer
    • 알림 저장 및 상태 갱신
    • 실제 비즈니스 처리 담당
  • AWS Lambda
    • 정해진 시간에 실행
    • due target 조회
    • Kafka 또는 메시지 큐로 이벤트 발행

이 구조의 핵심은

조회 및 enqueue 역할을 애플리케이션 서버와 분리했다는 점이다.

왜 Lambda로 분리했는가

Spring 서버 내부 스케줄러 방식은 구현이 단순하다는 장점이 있었지만, 다음과 같은 운영상의 부담이 있었다.

  • 스케줄러가 서버 인스턴스 수에 영향을 받는다
  • 여러 서버가 떠 있으면 분산 락 같은 추가 제어가 필요하다
  • 조회 로직이 애플리케이션 서버 리소스를 계속 사용한다
  • 서버 배포/재시작 상태에 따라 스케줄 실행 안정성이 흔들릴 수 있다

반면 Lambda로 분리하면, 조회 및 발행 로직을 서버와 독립적으로 실행할 수 있다.

Lambda로 분리했을 때의 이점

1. 스케줄 실행 주체가 단일화된다

Spring 서버 여러 대가 떠 있어도, 스케줄러 실행 주체는 Lambda 하나로 분리된다.

즉 애플리케이션 서버에서 “누가 스케줄을 실행할 것인가”를 더 이상 고민하지 않아도 된다.

이로 인해 다음과 같은 이점이 생긴다.

  • 서버 간 스케줄 실행 경쟁 제거
  • 전역 Redis 락 의존도 감소
  • 서버 수 변화와 무관하게 일정한 스케줄 실행 구조 유지

2. 애플리케이션 서버의 책임이 줄어든다

Spring 서버는 이제 스케줄을 돌며 조회하는 역할을 하지 않고,

들어온 이벤트를 소비하고 처리하는 역할에 집중하면 된다.

즉 서버 책임이 더 명확해진다.

  • Lambda: 조회 및 이벤트 발행
  • Spring Consumer: 이벤트 처리 및 저장

이렇게 역할이 분리되면 애플리케이션 서버는 더 단순해지고,

비즈니스 처리 서버로서의 역할에 집중할 수 있다.

3. 서버 배포와 스케줄 실행이 분리된다

기존에는 서버를 배포하거나 재시작하는 타이밍이 스케줄 실행에 영향을 줄 수 있었다.

하지만 Lambda는 별도 실행 주체이기 때문에, 애플리케이션 서버 배포와 무관하게 스케줄을 안정적으로 수행할 수 있다.

즉 다음과 같은 장점이 있다.

  • 서버 롤링 배포 중에도 조회/발행 흐름 유지 가능
  • 서버 다운/재기동과 스케줄 실행이 직접적으로 결합되지 않음
  • 운영 안정성 향상

4. 조회 로직의 확장 및 격리가 쉬워진다

조회 로직은 대량 데이터를 읽고 메시지를 발행하는 별도 성격의 작업이다.

이를 Lambda로 분리하면, 조회 로직에 필요한 실행 환경을 Spring 서버와 독립적으로 조절할 수 있다.

예를 들어:

  • 메모리 설정
  • 실행 시간
  • 재시도 정책
  • 타임아웃
  • 스케줄 주기

를 Spring 애플리케이션과 별도로 관리할 수 있다.

즉 조회 로직을 독립적인 batch/enqueue 계층으로 분리한 효과를 얻을 수 있다.

5. 서버 수 확장과 조회 로직 확장이 서로 얽히지 않는다

기존 구조에서는 서버 수를 늘리면 스케줄러 인스턴스도 함께 늘어난다.

하지만 Lambda로 분리하면:

  • 서버 수 증가는 consumer 처리량과 연결되고
  • 조회/이벤트 발행은 Lambda가 독립적으로 담당한다

즉 조회 계층과 처리 계층의 확장 방향을 분리할 수 있다.

이 점은 특히 대규모 처리 환경에서 중요하다.

 

정리

AWS Lambda로 조회 및 이벤트 발행 로직을 분리한 것은

단순히 구현 위치를 바꾼 것이 아니라, 전체 아키텍처를 더 명확하게 나누기 위함이었다.

 

출처

kafka : https://docs.confluent.io/kafka/overview.html

 

Kafka | Confluent Documentation

Apache Kafka Documentation Apache Kafka® is an open-source distributed data streaming engine that thousands of companies use to build streaming data pipelines and applications, powering mission-critical operational and analytics use cases. Learn More

docs.confluent.io

 

parallel-consumer : https://github.com/confluentinc/parallel-consumer

 

GitHub - confluentinc/parallel-consumer: Parallel Apache Kafka client wrapper with per message ACK, client side queueing, a simp

Parallel Apache Kafka client wrapper with per message ACK, client side queueing, a simpler consumer/producer API with key concurrency and extendable non-blocking IO processing. - confluentinc/paral...

github.com

 

naver : https://d2.naver.com/helloworld/7181840

 

woowacon2025 : https://www.youtube.com/watch?v=UhnERp2AYRo&t=657s

 

toss : https://www.youtube.com/watch?v=v9rcKpUZw4o&t=176s