Engineering Note

Kafka가 끼어도 Trace는 끊기지 않아야 한다

MSA 흐름 중간에 Kafka가 들어오면 동기 호출처럼 trace가 자연스럽게 이어지지 않습니다. Kafka header로 trace context를 전파하고 producer와 consumer instrumentation을 설계하는 관점을 정리합니다.

2026년 6월 29일 · Pletor Engineering kafkatracingopentelemetryobservabilitymsa

MSA나 분산 시스템에서 하나의 요청은 여러 서비스를 지나갑니다.

API Gateway
-> Order Service
-> Kafka
-> Payment Worker
-> External Payment API
-> Kafka
-> Notification Worker

HTTP 호출만 이어지는 구간이라면 APM이나 tracing agent가 비교적 자연스럽게 흐름을 이어줍니다. inbound HTTP request에서 span을 만들고, outbound HTTP request에 trace context를 넣고, 다음 서비스가 그 context를 이어받습니다.

그런데 중간에 Kafka가 들어오면 상황이 달라집니다.

Kafka는 요청을 바로 다음 서비스로 호출하는 것이 아니라 message를 topic에 남기고, consumer가 나중에 읽어 처리합니다. 이 순간 call stack은 끊기고 시간도 벌어집니다. producer 입장에서는 send가 끝났고, consumer 입장에서는 poll로 record를 읽었을 뿐입니다.

그래서 Kafka가 중간에 낀 흐름을 추적하려면 “어떤 tracing backend를 쓸 것인가”보다 먼저 정해야 할 것이 있습니다.

message boundary를 넘어 trace context를 어떻게 보존할 것인가

Kafka tracing의 본질은 특정 APM 제품을 붙이는 일이 아니라, message boundary를 넘어 trace context를 보존하는 일입니다.

투명한 메시지 경계 장치를 통과하면서도 끊기지 않고 이어지는 황금색과 푸른색 trace 흐름 일러스트
Kafka는 producer와 consumer 사이에 비동기 message boundary를 만듭니다. Trace를 이어가려면 context가 그 boundary를 함께 통과해야 합니다.

Jaeger가 아니라 trace context가 핵심이다

Jaeger는 좋은 tracing backend입니다. Grafana Tempo, Zipkin, Datadog APM, New Relic, Honeycomb 같은 도구도 비슷한 자리에 놓을 수 있습니다.

하지만 Kafka를 통과하는 trace를 설계할 때 Jaeger 자체가 핵심은 아닙니다. 핵심은 producer가 가진 현재 trace context를 Kafka record에 실어 보내고, consumer가 그 context를 꺼내 다음 span의 parent나 link로 사용할 수 있게 만드는 것입니다.

OpenTelemetry의 context propagation 문서는 기본 propagator가 W3C Trace Context header를 사용한다고 설명합니다. HTTP에서는 이 값이 보통 traceparent, tracestate header로 이동합니다.

Kafka에서는 HTTP header가 없습니다. 대신 Kafka record header가 있습니다.

Kafka record
  key
  value
  headers
    traceparent
    tracestate

이 구조만 제대로 지키면, trace data를 어디로 보낼지는 나중 문제에 가깝습니다. Jaeger를 쓰든, Tempo를 쓰든, vendor APM을 쓰든, trace context가 record와 함께 이동해야 consumer 이후 흐름을 같은 trace로 이어볼 수 있습니다.

Jaeger도 이제 이 흐름 안에서는 OpenTelemetry와 함께 생각하는 편이 자연스럽습니다. Jaeger의 OpenTelemetry SDK migration 문서는 native Jaeger client가 retired 되었고 새 애플리케이션은 OpenTelemetry API, SDK, instrumentation 사용을 권장한다고 설명합니다. 또한 Jaeger backend는 OpenTelemetry Protocol, 즉 OTLP로 trace를 받을 수 있습니다.

즉, 애플리케이션 쪽에서는 OpenTelemetry로 context를 전파하고 span을 만들고, backend는 Jaeger나 다른 시스템을 선택하는 식으로 나누는 편이 더 오래 갑니다.

Kafka는 transaction boundary가 아니라 message boundary다

여기서 “transaction tracing”이라는 말을 조심해야 합니다.

Kafka에는 이미 transaction이라는 기능이 있습니다. transactional.id, idempotent producer, exactly-once processing과 연결되는 그 transaction입니다. 이 글에서 말하는 trace는 그 의미의 Kafka transaction이 아닙니다.

우리가 보고 싶은 것은 이런 흐름입니다.

사용자 결제 요청 하나가
어떤 HTTP 서비스와 Kafka topic과 consumer를 거쳐
어디에서 느려졌고 어디에서 실패했는가

이것은 데이터 정합성을 위한 Kafka transaction과는 다른 문제입니다. 더 정확히는 distributed trace 또는 end-to-end request trace에 가깝습니다.

Kafka는 이 흐름에서 동기 호출 boundary가 아니라 message boundary를 만듭니다. producer와 consumer가 같은 시간에 실행되지 않을 수 있고, 하나의 message가 retry되거나 DLQ로 이동하거나 나중에 재처리될 수도 있습니다.

그래서 Kafka trace 설계는 단순히 “producer span 다음에 consumer span을 parent-child로 붙이면 된다”에서 끝나지 않습니다. 대부분의 정상 흐름에서는 parent-child 관계가 충분하지만, batch, retry, DLQ, reprocessing에서는 span link나 별도 trace로 분리하는 설계도 필요할 수 있습니다.

Producer에서 해야 할 일

Producer 쪽 instrumentation의 목표는 두 가지입니다.

첫째, producer가 Kafka에 message를 보내는 일을 span으로 기록합니다.

둘째, 현재 trace context를 Kafka record header에 주입합니다.

개념적으로는 다음과 같은 코드입니다.

ProducerRecord<String, byte[]> record =
    new ProducerRecord<>("payments.requested", key, payload);

Context current = Context.current();

openTelemetry.getPropagators()
    .getTextMapPropagator()
    .inject(current, record.headers(), KafkaHeaderSetter.INSTANCE);

producer.send(record);

실제 운영 코드에서 이런 로직을 매번 직접 쓰는 것은 좋은 방향이 아닙니다. 서비스마다 header 이름, 실패 처리, span attribute, sampling 정책이 조금씩 달라지면 trace가 금방 지저분해집니다.

그래서 보통은 다음 중 하나를 선택합니다.

  • OpenTelemetry Java Agent 같은 auto-instrumentation을 사용합니다.
  • OpenTelemetry Kafka clients library instrumentation을 명시적으로 붙입니다.
  • 사내 공통 producer wrapper를 만들고 그 안에서 context injection을 표준화합니다.

OpenTelemetry Java agent는 Java 애플리케이션에 agent를 붙여 여러 라이브러리의 telemetry를 자동으로 수집하는 방식입니다. 공식 supported libraries 목록에는 Kafka 관련 instrumentation도 포함됩니다.

좀 더 명시적으로 제어하고 싶다면 opentelemetry-kafka-clients-2.6 같은 library instrumentation을 검토할 수 있습니다. 이 방식은 애플리케이션 코드나 Kafka client 설정과 함께 instrumentation을 구성해야 하지만, 공통 wrapper를 만들 때 더 통제하기 쉽습니다.

Producer span에는 보통 이런 정보를 남깁니다.

  • topic
  • client id
  • partition, 알 수 있는 경우
  • key 존재 여부 또는 key hash
  • record size, 필요한 경우
  • send latency
  • error type

다만 key나 payload 자체를 span attribute로 남기는 것은 조심해야 합니다. trace는 운영자가 넓게 접근하는 경우가 많고, payload에는 개인정보나 민감 정보가 들어갈 수 있습니다. tracing은 payload 저장소가 아닙니다.

Consumer에서 해야 할 일

Consumer 쪽 목표는 producer와 반대입니다.

Kafka record header에서 trace context를 꺼내고, 그 context를 기준으로 consumer processing span을 만듭니다.

개념적으로는 다음과 같습니다.

ConsumerRecord<String, byte[]> record = records.iterator().next();

Context extracted = openTelemetry.getPropagators()
    .getTextMapPropagator()
    .extract(Context.current(), record.headers(), KafkaHeaderGetter.INSTANCE);

Span span = tracer.spanBuilder("process payments.requested")
    .setParent(extracted)
    .startSpan();

try (Scope scope = span.makeCurrent()) {
  handle(record);
} catch (Exception e) {
  span.recordException(e);
  throw e;
} finally {
  span.end();
}

Consumer span에는 producer span보다 더 운영적인 정보가 필요할 때가 많습니다.

  • topic
  • consumer group
  • partition
  • offset
  • poll batch size
  • processing latency
  • commit latency
  • retry count
  • DLQ 여부
  • error type

OpenTelemetry Kafka semantic conventions는 Kafka messaging span에서 사용할 attribute 관점을 제공합니다. 다만 messaging semantic convention은 버전과 안정화 상태에 따라 변화가 있으므로, 현재 사용하는 OpenTelemetry instrumentation이 어떤 convention을 내보내는지 확인해야 합니다.

중요한 것은 span 이름과 attribute를 조직 안에서 일관되게 유지하는 것입니다. 같은 Kafka 흐름인데 어떤 서비스는 topic을 쓰고, 어떤 서비스는 kafka.topic을 쓰고, 어떤 서비스는 payload field를 넣기 시작하면 trace 검색과 dashboard가 금방 어려워집니다.

Batch, retry, DLQ는 단순하지 않다

Kafka consumer는 record 하나씩만 처리하지 않습니다. poll은 보통 여러 record를 가져옵니다. 이때 tracing 설계는 선택지가 생깁니다.

record마다 span을 만든다.
batch 전체를 하나의 span으로 만든다.
batch span을 만들고 record별 event나 link를 남긴다.

정답은 처리 특성에 따라 다릅니다.

record 처리 비용이 크고 record마다 downstream 호출이 있다면 record 단위 span이 유용합니다. 반대로 초당 수천, 수만 건을 처리하는 경량 consumer에서 record마다 span을 만들면 trace volume이 너무 커질 수 있습니다. 이 경우 sampling, aggregation, batch span, span event를 같이 고민해야 합니다.

Retry와 DLQ도 중요합니다.

실패한 record가 재시도될 때 원래 trace를 그대로 parent로 이어야 할까요? 아니면 새 trace를 만들고 원래 trace를 link로 남기는 편이 나을까요?

즉시 retry라면 같은 trace 안에서 이어 보는 것이 자연스러울 수 있습니다. 하지만 몇 시간 뒤 재처리되거나 운영자가 DLQ에서 수동으로 복구한 message라면, 원래 trace를 parent로 이어 붙이는 것이 오히려 이상할 수 있습니다. 오래된 요청의 자식 span이 나중에 갑자기 생기는 모양이 되기 때문입니다.

이럴 때는 parent-child 관계보다 span link가 더 적절할 수 있습니다. “같은 원인에서 왔지만 같은 실행 흐름은 아니다”라는 의미를 표현할 수 있기 때문입니다.

라이브러리보다 중요한 것은 표준화다

Kafka tracing을 붙이는 방법은 여러 가지입니다.

OpenTelemetry Java Agent를 쓰면 시작이 빠릅니다. Spring Kafka, Kafka clients 등 표준 라이브러리를 쓰는 서비스라면 code change 없이 상당 부분을 볼 수 있습니다. Kubernetes 환경에서는 OpenTelemetry Operator를 이용해 auto-instrumentation을 주입하는 방식도 선택할 수 있습니다.

하지만 운영이 커질수록 auto-instrumentation만으로는 부족한 지점이 생깁니다.

  • 어떤 topic은 payload가 민감해서 attribute 제한이 필요합니다.
  • 어떤 consumer는 batch span이 더 적절합니다.
  • 어떤 retry는 원 trace를 parent로 이어야 하고, 어떤 reprocessing은 link로 남겨야 합니다.
  • business key는 남기고 싶지만 원문 key는 남기면 안 됩니다.
  • DLQ로 보낸 이유를 span event로 표준화하고 싶습니다.

이 지점부터는 공통 producer/consumer wrapper나 interceptor가 필요해집니다. 핵심은 “tracing library를 붙였다”가 아니라 “Kafka를 쓰는 모든 서비스가 같은 trace context 규칙을 따른다”입니다.

제 경험상 이 표준화가 없으면 tracing은 빠르게 부분 최적화가 됩니다. 어떤 서비스는 trace가 이어지고, 어떤 서비스는 끊기고, 어떤 서비스는 header를 덮어쓰고, 어떤 서비스는 retry에서 새 trace를 시작합니다. 그러면 장애 분석 때 가장 필요한 순간에 trace가 믿을 수 없는 자료가 됩니다.

Metrics와 trace는 서로 대체하지 않는다

Kafka가 들어간 시스템에서 trace는 매우 유용합니다. 하지만 trace만으로 Kafka 운영 상태를 설명할 수는 없습니다.

예를 들어 trace가 payment consumer의 처리 지연을 보여줄 수는 있습니다. 하지만 그 지연이 consumer application의 CPU 때문인지, broker fetch latency 때문인지, partition imbalance 때문인지, storage I/O 때문인지는 metric과 log를 함께 봐야 합니다.

Konduo처럼 Kafka, JVM, host, container, alert 신호를 함께 보는 도구는 이 지점에서 trace와 잘 맞물립니다. Trace는 “이 요청이 어디를 지났는가”를 보여주고, metric은 “그 시점에 시스템이 어떤 상태였는가”를 설명합니다. Kafka가 message boundary를 만들수록 두 관측 신호를 따로 보지 않고 이어서 해석하는 일이 더 중요해집니다.

어떤 흐름으로 시작하면 좋을까

Kafka tracing을 처음 붙인다면 다음 순서가 현실적입니다.

  1. 조직의 표준 propagation format을 정합니다. 가능하면 W3C Trace Context를 기준으로 둡니다.
  2. Kafka header에 traceparent, tracestate가 보존되는지 확인합니다.
  3. Producer send span과 consumer process span의 naming을 정합니다.
  4. topic, consumer group, partition, offset 같은 attribute 기준을 정합니다.
  5. payload와 key를 trace에 남기지 않는 보안 기준을 둡니다.
  6. retry, DLQ, reprocessing에서 parent-child와 link 중 무엇을 쓸지 정합니다.
  7. sampling 정책을 정합니다.
  8. Jaeger, Tempo, Zipkin, vendor APM 같은 backend는 그 다음에 선택합니다.

도구 선택은 중요합니다. 하지만 Kafka tracing에서는 도구보다 header와 context 규칙이 먼저입니다.

Kafka는 비동기 boundary입니다. 그 boundary를 지나는 순간 trace는 저절로 이어지지 않습니다. Producer가 context를 넣고, consumer가 context를 꺼내고, 조직이 같은 규칙을 반복해서 적용해야 합니다.

그때야 Kafka가 중간에 끼어도 trace가 끊기지 않습니다.

함께 읽기 좋은 글

이 글의 관점과 이어지는 주제를 더 보려면 아래 글도 함께 읽어볼 수 있습니다.