Tempo 2.x의 한계와 Tempo 3.x의 해결: Kafka를 앞세운 이유

Grafana Labs의 Tempo 3.0 릴리즈 블로그를 읽다가 첫머리의 한 문장에서 오랫동안 시선이 멈췄다.

“Replication Factor 3 요구사항을 제거하고, Read/Write 경로를 분리하여 TCO를 낮췄다.”

역으로 말하면 기존 Tempo 2.x에서는 Replication Factor 3(RF=3) 구성이 사실상 필수적이었다는 고백 이기도 했다.

왜 Grafana Labs는 지난 수년간 분산 트레이싱의 핵심을 담당하던 Ingester 아키텍처를 뒤로하고, 시스템의 한가운데에 Apache Kafka라는 스트리밍 계층 을 배치했을까? 과연 이 변화는 우리가 1편부터 3편까지 겪었던 그 복잡하고 아슬아슬한 장애의 고리를 끊어낼 수 있는 해답일까?


2.x가 던졌던 잔인한 딜레마: RF=1과 RF=3

앞선 글들에서 보았듯, Tempo 2.x에서 Replication Factor = 1 (RF=1)을 사용하는 것은 살얼음판을 걷는 것과 같았다.

Ingester 9대 중 단 1대가 메모리 스파이크로 OOM 루프에 빠지자, 배치 요청의 실패 증폭 구조를 타고 전체 시스템의 90%에 달하는 트레이스 쓰기가 실패했다. 그리고 그 여파로 완전히 독립되어 있던 Metrics Generator의 RED 메트릭까지 함께 차단되었다.

누군가는 이렇게 반문할 수 있다. “공식 문서에서도 RF=3을 권장하는데, 왜 진작에 RF=3으로 구성하지 않았는가? 바보 같은 선택이 아니었나?”

하지만 엔지니어링에는 언제나 비용과 비즈니스 맥락이라는 현실적인 트레이드오프 가 존재한다.

flowchart TD
  subgraph RF1["RF=1의 현실"]
    direction TB
    R1_PRO["비용 1/3 절감, 아키텍처 단순성"]
    R1_CON["단 1대 결함에도 전체 파이프라인 마비<br>(거대한 Blast Radius)"]
  end

  subgraph RF3["RF=3의 현실"]
    direction TB
    R3_PRO["1대 OOM이어도 Quorum(2대)으로 요청 성공<br>(장애 전파 차단)"]
    R3_CON["스토리지·네트워크·파드 비용 3배 폭증<br>(OOM 자체를 막지는 못함)"]
  end

  classDef bad fill:#ffebee,stroke:#c62828,stroke-width:1px;
  classDef good fill:#e8f5e9,stroke:#2e7d32,stroke-width:1px;
  class R1_CON,R3_CON bad;
  class R1_PRO,R3_PRO good;

RF=3은 동일한 Trace 데이터를 3곳의 Ingester에 복제 기록한다. 3곳 중 과반수인 2곳 이상만 성공하면 쓰기 성공으로 처리되므로, 1대가 OOM으로 쓰러져도 요청 실패나 RED 메트릭 차단 같은 연쇄 붕괴는 일어나지 않는다. 분명 훌륭한 장애 격리 장치 였다.

하지만 그 대가는 가혹했다.

  1. 막대한 인프라 비용: 초당 수십만 개의 Span이 쏟아지는 대규모 환경에서 데이터를 3중 복제하는 것은, S3 스토리지 비용뿐 아니라 파드 메모리/CPU 자원, 클러스터 내부 네트워크 대역폭 비용을 정확히 3배로 증가시킨다.
  2. OOM의 근본적 미해결: 가장 중요한 점은 RF=3이 Ingester의 OOM 자체를 방지하는 것은 아니라는 사실 이다. 2편에서 다루었던 vParquet 블록 completion 과정의 메모리 팽창은 데이터 자체의 복잡성에 기인하므로, RF=3 환경에서도 동일하게 발생한다.

결국 2.x 환경에서 우리는 “비용을 3배 지불하고 장애 전파를 막을 것인가”“비용을 아끼는 대신 단 1대의 결함에 시스템 전체가 흔들리는 리스크를 감수할 것인가” 라는 극단적인 양자택일을 강요당하고 있었다.


당시 우리가 RF=1을 유지했던 실무적인 이유

그 위험을 알면서도 우리가 당장 RF=3으로 전환하지 않았던 데에는 우리 팀 나름의 명확한 판단 기준이 있었다.

첫째, 모니터링의 이중화 안전망이 존재했다. 서비스 장애 감지와 긴급 호출(On-call Alerting)에 사용되는 핵심 SLO 메트릭은 이미 Prometheus와 Mimir 에이전트를 통해 완전히 독립적으로 수집되고 있었다. Tempo의 RED 메트릭이 일시적으로 끊기더라도, 실제 고객 서비스 장애를 감지하지 못하는 치명적인 사각지대는 발생하지 않았다.

둘째, 프로덕션 클러스터 전환의 리스크가 너무 컸다. 실시간 트래픽을 처리하는 대규모 클러스터에서 RF=3으로 전환하는 것은, 단순히 설정값 하나를 바꾸는 작업이 아니다. 노드 풀을 대거 증설하고, 파드 리소스를 재설계하며, 대역폭 병목을 검증해야 하는 고난도의 인프라 마이그레이션이었다.

셋째, 근본적인 아키텍처 변화를 기다리기로 했다. 당시 우리는 개발 중이던 Tempo 3.0의 아키텍처 로드맵을 확인하고 있었다. 2.x의 복제 계수에 의존하는 방식이 아니라 완전히 다른 방식으로 신뢰성을 푼다는 소식을 접했기에, 거액의 비용을 들여 2.x 인프라를 3배로 키우기보다는 3.0으로의 도약을 준비하는 것이 맞다고 판단했다.


내가 이해한 Tempo 3.x의 본질: 내구성 경계의 이동

그렇다면 Tempo 3.0(Microservices Mode)은 이 딜레마를 어떻게 풀었을까?

아키텍처 문서를 분석하면서 내가 가장 큰 충격을 받았던 부분은, 지난 수년간 Tempo의 상징과도 같았던 Ingester 컴포넌트의 공식적인 해체 였다.

flowchart LR
  APP["애플리케이션"] --> DIST["Tempo Distributor"]

  subgraph BOUNDARY["Durable Stream 계층 (Kafka)"]
    KAFKA[("Apache Kafka<br>분산 커밋 로그")]
  end

  DIST -->|"1. Kafka 적재 성공 시<br>즉시 200 OK 응답"| KAFKA

  subgraph CONSUMERS["독립된 컨슈머 그룹"]
    BB["Block Builder<br>(S3 블록 압축 생성)"]
    MG["Metrics Generator<br>(RED 메트릭 연산)"]
  end

  KAFKA -->|"2. 독립 소비"| BB
  KAFKA -->|"2. 독립 소비"| MG
  BB --> S3["Object Storage"]
  MG --> MIMIR["Mimir"]

  classDef kafka fill:#fff8e1,stroke:#f57f17,stroke-width:2px;
  classDef consumer fill:#e8f5e9,stroke:#2e7d32,stroke-width:1px;
  class KAFKA kafka;
  class BB,MG consumer;

3.0에서 일어난 혁신의 본질은 “내구성의 경계(Durability Boundary)를 어디에 둘 것인가” 에 대한 패러다임의 전환이었다.

2.x의 내구성 경계: 인메모리 프로세스

2.x에서 쓰기 성공의 기준은 “휘발성 높은 Ingester 프로세스들의 메모리와 로컬 디스크에 데이터가 복제되었는가”였다. 프로세스가 죽으면 데이터도 날아가기 때문에, 내구성을 담보하기 위해 반드시 3중 복제(RF=3)와 Quorum 합의를 거쳐야만 했다.

3.x의 내구성 경계: 검증된 분산 스트림 (Kafka)

3.0은 Distributor 바로 뒤에 Kafka 를 배치했다. Distributor가 수신한 트레이스를 Kafka 토픽에 정상 기록하는 순간, Tempo는 클라이언트에게 즉시 쓰기 성공(200 OK) 을 응답한다.

데이터의 영속화 책임을 애플리케이션 프로세스가 아니라, 이미 디스크 영속화와 다중 브로커 복제가 완벽히 검증된 전문 분산 커밋 로그 계층에 위임해 버린 것이다.

그리고 Ingester가 하던 일은 두 개의 독립된 컴포넌트로 쪼개졌다.

  • Block Builder: Kafka에서 트레이스를 읽어 Parquet 블록으로 압축 조립한 뒤 S3에 올린다.
  • Metrics Generator: Kafka에서 동일한 트레이스를 읽어 실시간 RED 메트릭을 계산한다.

두 컴포넌트는 같은 Kafka 토픽을 바라보는 완전히 분리된 컨슈머 그룹(Consumer Group) 으로 동작한다.


장애의 전파 반경이 어떻게 격리되었는가

이 설계가 가져온 실질적인 차이는 무엇일까?

우리가 겪었던 상황을 다시 대입해 보았다. 특정 테넌트의 복잡한 트레이스로 인해 블록 변환 작업 중 메모리 스파이크가 발생해 OOM이 터지는 시나리오다.

“Tempo 3.0을 쓰면 OOM 자체가 사라질까?” 아니다. 3.0에서도 Block Builder 파드는 메모리가 모자라면 똑같이 OOM으로 죽을 수 있다. 3.0은 메모리를 무한대로 만들어주는 마법이 아니기 때문이다.

하지만 그 결과로 이어지는 장애의 전파 반경(Blast Radius) 은 완전히 다르다.

flowchart TD
  subgraph V2["Tempo 2.x: 도미노 연쇄 붕괴"]
    direction TB
    V2_OOM["Ingester 블록 변환 OOM"] --> V2_FAIL["Distributor 쓰기 RPC 실패"]
    V2_FAIL --> V2_REQ["배치 요청 전체 에러"]
    V2_REQ --> V2_DROP["Metrics Generator 포워딩 취소<br>(RED 메트릭 90% 증발)"]
  end

  subgraph V3["Tempo 3.x: 완전한 장애 격리"]
    direction TB
    V3_OOM["Block Builder 블록 조립 OOM"] --> V3_LAG["해당 파티션 Consumer Lag 일시 지연"]
    V3_OOM -.->|"영향 없음"| V3_DIST["Distributor 쓰기 성공 유지 (200 OK)"]
    V3_OOM -.->|"영향 없음"| V3_MG["Metrics Generator 정상 동작<br>(RED 메트릭 100% 무중단 유지)"]
  end

  classDef bad fill:#ffebee,stroke:#c62828,stroke-width:1px;
  classDef good fill:#e8f5e9,stroke:#2e7d32,stroke-width:1px;
  class V2_OOM,V2_FAIL,V2_REQ,V2_DROP bad;
  class V3_OOM,V3_LAG,V3_DIST,V3_MG good;
  • Tempo 2.x에서는: Ingester 1대가 죽으면 배치 요청 실패를 타고 Distributor가 에러를 뿜었고, 그 결과 전혀 상관없는 Metrics Generator까지 데이터가 끊기며 시스템 전체가 마비되었다.
  • Tempo 3.x에서는: Block Builder 1대가 OOM으로 죽더라도, 트레이스는 이미 Kafka에 안전하게 저장되어 있다. Distributor의 수신 성공 응답은 끄떡없고, 독립된 컨슈머인 Metrics Generator는 자기 속도대로 Kafka를 읽으며 RED 메트릭을 100% 온전하게 생성 한다. 죽은 Block Builder가 재시작되는 동안 발생하는 영향은 단지 Kafka의 컨슈머 랙(Consumer Lag)이 몇 초 늘어나는 것 뿐이다.

내가 그토록 원했던 것, 즉 “컴포넌트 하나가 고장 나더라도 그 영향이 다른 파이프라인으로 전파되지 않는 진정한 장애 격리” 가 바로 이 스트리밍 아키텍처를 통해 완성된 것이다.


세상에 공짜는 없다: 3.0이 내게 던진 새로운 고민

그렇다면 Tempo 3.0은 아무런 단점이 없는 완벽한 선택일까? 엔지니어로서 나는 그렇게 생각하지 않는다. 시스템에서 한쪽의 복잡성을 덜어내면, 그 복잡성은 반드시 다른 어딘가로 이동하게 마련이다.

비교 영역Tempo 2.x (전통적 구조)Tempo 3.x (스트리밍 구조)내가 바라본 트레이드오프
내구성 보장Ingester 복제 (RF=3)Kafka 분산 커밋 로그애플리케이션의 짐을 Kafka가 대신 짊어짐
장애 격리취약 (컴포넌트 간 강한 결합)완벽한 물리적 격리단일 노드 장애의 전파가 원천 차단됨
인프라 복잡도상대적으로 단순 (자체 완결)대규모 Kafka 클러스터 필수Kafka라는 거대한 미들웨어를 운영해야 하는 새로운 부담
핵심 모니터링Ingester 메모리, Ring 상태Consumer Lag, 파티션 균형모니터링의 관점이 프로세스에서 큐(Queue)로 이동함

Tempo 3.0을 도입한다는 것은, Ingester의 메모리 줄타기와 RF 복제 딜레마에서 벗어나는 대신 대규모 Kafka 클러스터를 무중단으로 운영하고 파티션과 Consumer Lag을 관리해야 하는 새로운 책임 을 떠안는 것을 의미한다.

초당 수십만 개의 트레이스가 쏟아지는 환경에서 Kafka 브로커의 디스크 I/O와 네트워크 대역폭을 관리하는 것은 결코 만만한 일이 아니다.

하지만 나는 이 트레이드오프가 훨씬 더 건강하다고 생각한다. 원인을 알 수 없는 메모리 스파이크 하나 때문에 시스템 전체가 도미노처럼 무너지는 ‘예측 불가능한 불안’보다는, 이미 업계에서 수없이 많은 모범 사례와 튜닝 기법이 정립된 Kafka를 관리하는 ‘예측 가능한 운영’이 엔지니어링 관점에서 훨씬 더 감당할 만한 비용이기 때문이다.


4부작을 마무리하며: 생각을 정리하다

이번 인시던트와 아키텍처 탐구를 거치며 나는 분산 시스템을 바라보는 세 가지 중요한 기준을 세울 수 있었다.

  1. 원인과 전파는 분리해서 보아야 한다: 장애의 ‘발단’은 vParquet 블록 completion의 메모리 스파이크였지만, 지표를 90% 날려버린 ‘전파’는 OTLP 배치 요청의 병렬 실패 증폭 모델과 Distributor의 에러 핸들링 순서였다. 발단만 보고 전파 경로를 보지 못하면 장애의 본질을 결코 이해할 수 없다.
  2. 쿠버네티스의 추상화를 맹신하지 않는다: 컨테이너의 재시작이 로컬 볼륨의 오염된 상태까지 리셋해주지는 않는다. emptyDir의 수명 주기를 이해하지 못했다면 우리는 28번이 아니라 수백 번의 재시작을 바라보며 시간만 허비했을 것이다.
  3. 아키텍처의 발전은 문제를 없애는 것이 아니라, 경계를 다시 긋는 것이다: Tempo 3.0은 OOM이라는 현상 자체를 마법처럼 지워버리지 않았다. 대신 내구성의 경계를 Kafka로 옮김으로써, 한쪽 파이프라인의 실패가 다른 파이프라인으로 번지지 않도록 장애 전파의 경계를 우아하게 재설계 했다.

단순히 “장애가 났으니 메모리를 늘리고 설정을 고쳤다”로 끝냈다면 이 깊은 배움들을 결코 얻지 못했을 것이다.

현상을 의심하고, 로그의 타임스탬프를 쪼개보고, 프로파일러로 증명하며, 아키텍처의 트레이드오프를 고민했던 이 모든 과정이야말로 내가 엔지니어로서 한 단계 더 단단해질 수 있었던 가장 소중한 자산이었다.