Kafka + Reactive로 3,000만 건 로그 파이프라인 개선

프로젝트 개요

개발 기간
3개월
2026.01 — 2026.03
팀 구성
1명
담당 역할
백엔드 개발
Apache Kafka Project Reactor Spring Boot

들어가며

분산 시스템에서 대량의 이벤트 로그를 안정적으로 처리하는 일은 생각보다 까다롭습니다. 이 글은 3,000만 건 규모의 비즈니스 로그 파이프라인에서 발생한 동기 처리 병목을 Project Reactor를 활용해 구조적으로 해결하고, 운영 가시성까지 확보한 과정을 SBI(Situation → Behavior → Impact) 구조로 정리한 기록입니다.


Situation — 동기 처리 구조가 낳은 10시간의 지연

비즈니스 로그의 구조

시스템은 Kafka를 통해 비즈니스 로그를 관리하고 있었습니다. 로그의 구조는 마치 햄버거 제조 공정과 비슷했습니다. 하나의 작업(예: 제품 생산)이 완료되기까지 여러 세부 이벤트(빵 준비, 패티 굽기, 조립 등)가 순차적으로 발생하고, 각 이벤트가 모두 개별 로그로 기록되는 방식이었습니다.

제품 1,000만 개를 처리할 경우 약 3,000만 건의 이벤트 로그가 생성되었고, 이 로그들을 N개의 저장소에 적재하는 컨슈머가 파이프라인의 핵심이었습니다.

동기 처리의 한계: TPS 800, 처리 시간 10시간 이상

문제는 기존 컨슈머 코드가 동기(Synchronous) 방식으로 설계되어 있다는 점이었습니다. 각 로그를 N개 저장소에 순서대로 하나씩 적재했기 때문에, 저장소가 늘어날수록 Latency가 정비례로 증가하는 구조였습니다.

처리 대상 로그
3,000만 건
기존 처리 속도 (TPS)
~800
처리 소요 시간
10시간+

초당 800건의 처리 속도로는 3,000만 건을 소화하는 데 10시간 이상이 걸렸습니다. 이는 단순한 성능 문제를 넘어 운영 가시성의 결여라는 치명적인 리스크로 이어졌습니다. 운영자가 작업을 실행한 뒤 진행 상황을 실시간으로 확인할 수 없었고, 이상이 발생해도 수 시간이 지나야 감지할 수 있는 상태였습니다.

"속도가 느린 것보다 더 큰 문제는, 지금 무슨 일이 일어나고 있는지 알 수 없다는 것이었다."

Behavior — Reactor를 이용한 구조적 개선과 정교한 튜닝

1단계: 병렬화 — Mono.zip과 스레드 분리

먼저 N개 저장소에 대한 적재 로직의 구조를 분석했습니다. 각 저장소로의 적재는 서로 독립적이고 순서를 보장할 필요가 없다는 점을 확인했습니다. 순서에 의존하는 동기 처리를 고집할 이유가 없었습니다.

Project ReactorMono.zip을 활용해 N개 저장소에 대한 적재 로직을 병렬로 전환했습니다.

// 변경 전: 순차 동기 처리
for (Storage storage : storages) {
    storage.save(log);  // 저장소마다 블로킹
}

// 변경 후: 병렬 비동기 처리
Mono.zip(
    storages.stream()
        .map(storage -> Mono.fromCallable(() -> storage.save(log))
            .subscribeOn(Schedulers.boundedElastic()))
        .collect(Collectors.toList())
)

여기서 핵심은 subscribeOn(Schedulers.boundedElastic())의 적용입니다. Kafka 메시지를 수신하는 컨슈머 스레드와 실제 저장소에 적재하는 워커 스레드를 분리함으로써, 컨슈머가 I/O 대기에 블로킹되지 않고 즉시 다음 메시지를 수신할 수 있게 되었습니다.

아키텍처 변경
[ 변경 전 ]
Kafka Consumer Thread → save(Storage A) → save(Storage B) → save(Storage C) → ...
[ 변경 후 ]
Kafka Consumer Thread → Mono.zip ─┬→ Worker: save(Storage A)
                                         ├→ Worker: save(Storage B)
                                         └→ Worker: save(Storage C)

2단계: 이차 문제 — DB 커넥션 풀 고갈

병렬화를 적용하자 처리 속도가 급격히 올라갔습니다. 그런데 예상치 못한 이차 문제가 발생했습니다. 처리량이 폭증하면서 DB Connection Pool이 고갈되기 시작한 것입니다. 파이프라인을 빠르게 만든 것이 오히려 DB에 과부하를 주는 상황이 된 것입니다.

이는 앞서 경험한 Redis CPU 100% 장애와 같은 맥락이었습니다. 한 컴포넌트의 최적화가 다음 병목을 드러냅니다.

3단계: 유량 제어 — flatMap concurrency 튜닝

무제한 병렬화는 DB에 재앙입니다. flatMapconcurrency 파라미터를 명시적으로 제어하여 DB가 감당할 수 있는 최적의 처리 유량을 찾아나갔습니다.

// concurrency를 명시하지 않으면 사실상 무제한 병렬 실행
flux.flatMap(log -> processLog(log));

// concurrency를 제어하여 동시 처리 수를 DB 한계 이내로 제한
flux.flatMap(log -> processLog(log), concurrency);

concurrency = 1, 2, 3, ...으로 단계적으로 올려가며 DB 커넥션 풀 사용량과 처리량을 동시에 모니터링했습니다. 커넥션 풀이 안정적으로 유지되면서 처리량이 최대가 되는 값을 최적 지점으로 설정했습니다.

4단계: 배치 최적화 — bufferTimeout → buffer 변경

대량 유입 상황에서는 bufferTimeout이 오히려 독이 되었습니다. bufferTimeout은 지정한 시간이 지나거나 건수가 차면 배치를 내보내는 방식인데, 메시지가 빠르게 쏟아지는 상황에서 타임아웃까지 기다리는 불필요한 대기가 발생했습니다.

구분 bufferTimeout buffer (건수 기반)
배치 트리거 건수 도달 또는 시간 경과 건수 도달 시 즉시
대량 유입 시 타임아웃까지 대기 발생 건수 차면 바로 실행
적합한 상황 유입량이 불규칙한 경우 대량 메시지가 지속 유입되는 경우

건수 기반의 buffer로 전환함으로써 메시지가 꽉 차는 즉시 배치가 실행되어 불필요한 대기 없이 최대 처리량을 유지할 수 있었습니다.


Impact — 10시간에서 수십 분으로, 가시성까지 회복

처리 소요 시간 (개선 전)
10시간+
처리 소요 시간 (개선 후)
수십 분
인프라 증설
없음

추가적인 인프라 증설 없이, 동일한 하드웨어 위에서 처리 시간을 10시간 이상에서 수십 분 이내로 단축했습니다. 운영팀은 작업 실행 직후 로그를 통해 정상 동작 여부를 즉시 확인할 수 있게 되었고, 지연으로 인한 운영 리스크가 사라졌습니다.

또한 flatMap concurrency 제어를 통해 DB 커넥션 풀이 안정적으로 유지되었습니다. 빠른 처리와 시스템 안정성을 동시에 확보한 결과였습니다.

핵심 교훈

  • 병렬화는 만능이 아닙니다. 무제한 병렬화는 빠르지만 다운스트림(DB, 외부 서비스)을 압박합니다. 제어 가능한 속도(Throttling)가 안정적인 속도입니다.
  • 스레드 분리는 반응성의 핵심입니다. subscribeOn(boundedElastic())으로 컨슈머와 워커를 분리하면, I/O 블로킹 없이 컨슈머가 지속적으로 메시지를 소비할 수 있습니다.
  • 버퍼 전략은 트래픽 패턴에 맞춰야 합니다. 균일한 대량 유입에는 bufferTimeout보다 건수 기반 buffer가 불필요한 대기를 줄입니다.
  • 성능보다 중요한 것은 관찰 가능성입니다. 아무리 빠른 시스템도 무슨 일이 일어나는지 볼 수 없다면 운영할 수 없습니다.

정리하며

이 경험은 "단순히 빠른 처리보다 중요한 것은 가용 자원 내에서의 제어 가능한 속도와, 비즈니스 요구사항을 충족하는 아키텍처"라는 점을 체감한 계기가 되었습니다. Project Reactor는 단순한 성능 도구가 아닙니다. 처리 흐름을 선언적으로 설계하고, 동시성과 배압(Backpressure)을 코드 레벨에서 정밀하게 제어할 수 있는 아키텍처 도구입니다.