본문으로 건너뛰기

© 2026 Molayo

Dev.to헤드라인2026. 05. 30. 15:18

이벤트 기반 파이프라인이 지연 시간의 함정이 되는 이유 (그리고 우리가 이를 해결한 방법)

요약

Kafka 기반 이벤트 버스 시스템에서 높은 데이터 안정성 설정(acks=all, Idempotent producer)이 초래하는 심각한 지연 시간 문제를 다룹니다. 부하 증가 시 발생하는 99th percentile 지연 시간 급증 원인을 분석하고, 기존 튜닝 방식의 한계를 설명합니다.

핵심 포인트

  • acks=all 및 멱등성 프로듀서 설정이 지연 시간의 주요 원인
  • 스레드 수 증가가 오히려 OS 스케줄러 부하와 지연 변동성 유발
  • Tiered Storage 도입 시 S3 업로드로 인한 지터 발생 가능성
  • 안정성과 성능 사이의 트레이드오프(Trade-off) 관리의 중요성

우리가 실제로 해결하려 했던 문제

우리가 물려받은 보물찾기 (treasure-hunt) 시스템은 Exactly-once semantics (정확히 한 번 의미론)가 활성화되고, Idempotent producers (멱등성 프로듀서)가 사용되며, Replication factor (복제 계수)가 3으로 설정된 Kafka 기반의 Event bus (이벤트 버스)를 사용하고 있었습니다. 매우 견고해 보이지 않나요? 우리의 Latency SLO (지연 시간 서비스 수준 목표)에 따르면 모든 이벤트에 대해 엔드 투 엔드 (end-to-end)로 200ms 이내에 응답해야 했습니다. 첫 번째 프로토타입은 초당 1,000개의 이벤트 부하 상황에서 중앙값(median) 지연 시간 192ms를 달성했습니다. 그것은 데모였습니다.

운영 환경은 다른 이야기를 들려주었습니다. 부하가 초당 6,000개의 이벤트에 도달했을 때, 중앙값은 180ms였지만 평균은 1.2초였고, 99th percentile (99 백분위수)은 2초를 넘어섰습니다. 보물찾기 리더보드에서 1위 사용자가 누락된 것은 잘못된 알고리즘 때문이 아니었습니다. 이벤트 버스가 쓰기 작업을 확인(acknowledge)하기 전에 커밋을 버퍼링하고 있었기 때문이었습니다. min.insync.replicas=2와 함께 설정된 acks=all 설정은, 팔로워(follower)들이 리더(leader)와 동일한 랙(rack)에 있을 때조차도 모든 쓰기 작업이 두 개의 브로커(broker)가 커밋할 때까지 기다려야 함을 의미했습니다. 이는 홉(hop)당 6080ms의 추가 지연 시간을 발생시켰습니다. 더 나쁜 것은, 모든 이벤트가 발행되기 전에 중복 제거(deduplication)되어야 했기 때문에 Idempotent producer (멱등성 프로듀서) 계층에서 3040ms의 추가적인 Serialization (직렬화) 오버헤드가 발생했다는 점입니다. 우리 SRE는 이를 '안전의 벽'이라 불렀고, 저는 이를 '지연 시간의 벽'이라 불렀습니다.

우리가 먼저 시도했던 것 (그리고 실패한 이유)

첫 번째 해결책은 명확했습니다. acks=1로 다운그레이드하는 것이었습니다. 스테이징 환경에서 중앙값 지연 시간을 70ms 단축했지만, 팔로워 브로커가 OOM-killed (메모리 부족으로 인한 프로세스 종료)되고 컨트롤러(controller)가 개입할 때 99th percentile (99 백분위수)은 여전히 1.8초까지 치솟았습니다. 부하 상황에서 Kafka의 Unclean leader election (부정확한 리더 선출)이 45초마다 발생했고, 새로운 리더는 처음부터 상태를 다시 구축해야 했습니다. 이는 모든 컨슈머 (consumer)에게 1.2초의 Head-of-line blocking (헤드 오브 라인 블로킹)을 유발했습니다.

우리는 num.io.threads를 8에서 16으로 늘리고, num.network.threads를 6으로 높여보았습니다. 처리량 (Throughput)은 증가했지만, OS 스케줄러 (scheduler)가 급증한 스레드 (thread)를 감당하지 못하면서 지연 시간 변동성 (latency variance)이 두 배로 늘어났습니다. 그 다음으로 원격 로그 세그먼트 (remote log segments)를 사용하는 계층형 스토리지 (Tiered Storage)로 전환을 시도했습니다. 오래된 세그먼트를 S3로 오프로드 (offload)하고, 최신 세그먼트 (hot segments)만 디스크에 유지하는 것이 아이디어였습니다. 실제로 일어난 일은 다음과 같았습니다: 세그먼트가 롤링 (rolling)될 때마다 S3 멀티파트 업로드 (multipart uploads)가 300ms의 지터 (jitter)를 유발했고, 컨슈머 랙 (consumer lag) 그래프는 마치 톱날 체인소처럼 보였습니다.

아키텍처 결정 (The Architecture Decision)

우리는 Kafka를 튜닝하려는 시도를 중단하고 이벤트 버스 (event bus)를 완전히 제거했습니다. 새로운 파이프라인은 2계층 시스템을 사용합니다:

Tier 1은 RedisJSON과 RedisTimeSeries를 사용하는 메모리 우선 (memory-first) 방식의 단일 리더 (single-leader) Redis 클러스터입니다. 모든 이벤트는 wait=1로 리더에 기록됩니다. 즉, 리더의 인메모리 커밋 (in-memory commit)만을 기다린다는 의미입니다. 우리는 지속적인 하트비트 (heartbeat) 노이즈 없이 팔로워 (follower)들을 동기화하기 위해 repl-ping-replica-period 50을 설정했습니다. 리더의 복제본 (replica) 수는 2로 설정했지만, 디스크 플러시 (disk flush)를 기다리지는 않습니다. 대신 리더의 AOF 리라이트 (rewrite)가 비동기적으로 디스크에 데이터를 배출 (drain)하는 것에 의존합니다. 중앙값 지연 시간 (Median latency)은 45ms로 떨어졌습니다. 초당 12,000개의 이벤트가 발생하는 상황에서도 99퍼센타일 (99th percentile)은 110ms를 유지했습니다.

Tier 2는 ClickHouse에 저장되는 추가 전용 로그 (append-only log)입니다. 모든 Redis 이벤트는 중복 제거 키 (dedup key)와 함께 ReplacingMergeTree 테이블에 행 (row)을 기록합니다. 우리는 7일이 지난 이벤트를 삭제하기 위해 매일 밤 TTL을 실행합니다. ClickHouse는 리더보드 (leaderboard) 및 부정 탐지 (fraud detection)를 위한 분석 쿼리 (analytical queries)를 처리하고, Redis는 실시간 업데이트를 처리합니다. 트레이드오프 (tradeoff)로는 계층 간의 정확히 한 번 (exactly-once) 의미론 (semantics)을 상실했다는 점입니다. 따라서 우리는 단조 증가하는 이벤트 ID (monotonically increasing event ID)와 Postgres 어드바이저리 락 (advisory lock) 테이블을 사용하여 애플리케이션 내에 멱등적 컨슈머 (idempotent consumer) 계층을 구현했습니다. 이로 인해 15ms의 지연 시간이 추가되었지만, 결정론적인 (deterministic) 리더보드 업데이트를 확보할 수 있었습니다.

이후의 수치 결과

2주간의 운영 트래픽을 거친 후, 수치는 냉혹했지만 정직했습니다:

  • 중앙값 지연 시간 (Median latency): 43 ms (180 ms에서 감소)
  • 99백분위수 지연 시간 (99th-percentile latency): 112 ms (2.8초에서 감소)
  • 엔드 투 엔드 (End-to-end) 보물찾기 리더보드 업데이트: 85 ms (280초에서 감소)
  • 이벤트 100만 건당 비용: $0.04 (Redis 메모리 사용량이 2.3배 증가함에 따라 $0.03에서 상승)

진정한 승리는 지연 시간 수치가 아니라 SLO (서비스 수준 목표)였습니다. 우리는 99.9%의 가용성 SLO에서 99.95%로 이동했습니다. 더 이상 Kafka 컨트롤러 로그를 확인하기 위해 새벽 3시에 깨어날 필요가 없게 되었습니다. 마케팅 팀은 여전히 wait=1 설정으로 Redis 클러스터를 데모하지만, 엔지니어링 팀 외부의 그 누구도 더 이상 Redis 커밋 (commit)과 Kafka 커밋 사이의 차이를 알아차리지 못합니다.

내가 다르게 했을 것들

단 한 번의 스프린트(sprint)라도 Kafka의 데모 지표를 신뢰하지 않았을 것입니다. Kafka 자체 문서에서도 acks=allmin.insync.replicas=2 조합은 지연 시간의 주범(latency killer)이라고 명시하고 있지만, 저는 안전해 보인다는 이유로 이를 무시했습니다. 둘째로, Redis 클러스터의 규모를 피크 부하의 1.5배가 아닌 2.5배로 산정했을 것입니다. 초당 11,000개의 이벤트가 발생하는 상황에서 jemalloc의 아레나(arenas)가 TLS churn을 따라가지 못해 메모리 파편화(memory fragmentation) 현상이 발생했기 때문입니다. 셋째로, 장애가 발생한 후가 아니라 첫 번째 부하 테스트를 수행하기 전에 멱등성 소비자 (idempotent consumer) 계층을 구축했을 것입니다. 소비자 포드(consumer pods)를 50개로 확장했을 때 어드바이저리 락(advisory lock) 테이블이 병목 현상이 되었고, 결국 Redis 스트림 기반의 중복 제거 (deduplication) 계층을 사용하도록 재작성해야 했습니다.

마지막으로, 초당 1,000개의 이벤트가 발생하는 합성(synthetic) 버스트(burst) 대신 실제 사용자 행동을 모방한 스테이징 트래픽을 고집했을 것입니다. 우리의 스테이징 클러스터는 운영 환경과 동일한 이벤트 크기 분포를 경험하지 못했기에, Kafka 커밋 지연 시간의 급증(spikes)은 실제 서비스(go live)를 시작하기 전까지는 보이지 않았습니다. 실제 사용자 이벤트는

AI 자동 생성 콘텐츠

본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.

원문 바로가기
0

댓글

0