목록으로

결제 API가 느린데, 어디가 느린지 모르겠다 — OpenTelemetry 도입기

7

결제 API가 느린데, 어디가 느린지 모르겠다 — OpenTelemetry 도입기

2026년 2월 27일 — OTel 자동 계측 및 트레이스 연동 (Phase 0~2) 2026년 2월 27일 — OTLP exporter 프로토콜을 gRPC로 명시하여 Collector 연결 오류 해결 2026년 2월 27일 — 커스텀 Span 적용 (Phase 3) 2026년 3월 3일 — Tail Sampling 설정 후 제거 (불필요 판단)

문제: "결제가 느려요" — 근데 어디가?

결제 API 호출 한 번에 내부적으로 일어나는 일이 많다.

API 요청 → 잔액 조회 → 블록체인 전송 → 블록 확정 대기 → DB 저장 → SaaS 보고 → 응답

"결제가 3초 걸려요"라는 제보가 오면, 블록체인이 느린 건지, DB가 느린 건지, 외부 API가 느린 건지 알 수가 없었다. 로그에 시간을 찍어봐도 API 단위 시간만 보이지, 내부 각 단계별 소요 시간은 보이지 않았다.

OpenTelemetry란?

→ OpenTelemetry(줄여서 OTel)는 애플리케이션의 성능과 동작을 관찰(Observability)하기 위한 오픈소스 표준 도구다. 로그, 메트릭, 트레이스를 통합해서 수집할 수 있다.

분산 추적(Distributed Tracing)을 위한 표준이다. 하나의 요청이 여러 서비스/컴포넌트를 거칠 때, 각 단계를 Span이라는 단위로 기록하고, 이걸 하나의 Trace로 묶어서 시각화해 준다. → Trace(트레이스)는 하나의 요청이 시스템을 통과하는 전체 여정이다. Span(스팬)은 그 여정의 각 구간을 나타내는 단위다. 예를 들어 "결제 API 호출" 트레이스 안에 "DB 조회", "블록체인 전송" 같은 스팬들이 있다.

Trace (전체 요청)
├── Span: HTTP POST /api/payments (1200ms)
│   ├── Span: TokenContractService.transferWithAuthorization (800ms)
│   │   ├── Span: 사전 검증 RPC 호출 (200ms)
│   │   └── Span: 블록체인 전송 + 대기 (600ms)
│   ├── Span: DB 트랜잭션 저장 (50ms)
│   └── Span: SaaS API 보고 (350ms)

이런 식으로 "800ms는 블록체인, 350ms는 SaaS"처럼 병목이 한눈에 보인다.

Phase 0: ADOT Agent 설정

AWS 환경이라 ADOT(AWS Distro for OpenTelemetry) Java Agent를 선택했다. 이 에이전트는 JVM에 붙어서 HTTP, DB, 외부 호출을 자동으로 계측해 준다. 코드 수정 없이 기본적인 트레이싱이 가능하다. → Java Agent는 JVM이 시작할 때 코드에 자동으로 끼어들어 동작하는 프로그램이다. 원본 코드를 건드리지 않고도 메서드 호출 시간 측정 같은 기능을 추가할 수 있다. → 계측(Instrumentation)이란 코드 실행 시 성능 데이터를 자동으로 수집하도록 장치를 심는 것이다.

FROM amazoncorretto:21

# ADOT Java Agent 다운로드
ADD https://github.com/aws-observability/aws-otel-java-instrumentation/releases/latest/download/aws-opentelemetry-agent.jar /opt/aws-opentelemetry-agent.jar

COPY build/libs/app.jar app.jar

ENV OTEL_ENABLED=true
ENV OTEL_RESOURCE_ATTRIBUTES="service.name=poc-backend"
ENV OTEL_TRACES_EXPORTER=otlp
ENV OTEL_METRICS_EXPORTER=otlp
ENV OTEL_LOGS_EXPORTER=none
ENV OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317
ENV OTEL_PROPAGATORS=xray,tracecontext,baggage
ENV OTEL_EXPORTER_OTLP_PROTOCOL=grpc

ENTRYPOINT ["sh", "-c", "\
  if [ \"$OTEL_ENABLED\" = \"true\" ]; then \
    JAVA_TOOL_OPTIONS=\"-javaagent:/opt/aws-opentelemetry-agent.jar\"; \
  fi; \
  java $JAVA_TOOL_OPTIONS \
    -Duser.timezone=Asia/Seoul \
    -Dspring.profiles.active=${SPRING_PROFILE} \
    -jar /app.jar"]

핵심 포인트:

  • OTEL_ENABLED 환경변수로 켜고 끌 수 있게 했다. 로컬 개발할 때는 끄고, 배포 환경에서만 켠다.
  • -javaagent 옵션을 조건부로 적용하는 게 ENTRYPOINT의 if문이다.

Phase 1: 삽질 — OTLP 프로토콜 문제

ADOT Agent를 붙였는데, Collector에 트레이스가 안 갔다. 로그를 보니:

Failed to export spans. The request could not be executed.

원인은 OTLP 프로토콜 불일치였다. ADOT Agent 기본값이 http/protobuf인데, 우리 Collector는 gRPC를 기대하고 있었다. → OTLP(OpenTelemetry Protocol)는 트레이스 데이터를 전송하는 프로토콜이다. http/protobufgRPC는 같은 데이터를 보내지만 전송 방식이 다르다. 보내는 쪽과 받는 쪽이 같은 방식을 써야 통신이 된다. → Collector는 여러 서비스에서 보낸 트레이스 데이터를 받아서 저장소로 전달해주는 중간 수집기다.

# 이걸 명시해야 했다
OTEL_EXPORTER_OTLP_PROTOCOL=grpc

프로토콜 하나 명시 안 해서 반나절을 날렸다. OpenTelemetry에서 http/protobufgrpc는 같은 OTLP지만 전송 방식이 완전 다르다. 디폴트 값을 맹신하면 안 된다.

Phase 2: MDC에 trace_id 심기

→ MDC(Mapped Diagnostic Context)는 로그에 추가 정보를 자동으로 넣어주는 저장소다. 스레드별로 관리되며, 한번 값을 넣어두면 그 스레드에서 찍히는 모든 로그에 해당 값이 자동 포함된다.

자동 계측만으로는 부족했다. 기존 로그 시스템과 트레이스를 연결하고 싶었다. 에러 로그가 뜨면 해당 trace_id로 전체 호출 흐름을 추적할 수 있도록.

ADOT Agent가 자동으로 MDC에 trace_id, span_id를 주입해 준다. 이걸 에러 응답에도 포함시켰다.

@ExceptionHandler(Exception::class)
fun handleGlobalException(ex: Exception): ResponseEntity<ErrorResponse> {
    val errorResponse = ErrorResponse(
        status = HttpStatus.INTERNAL_SERVER_ERROR.value(),
        message = "서버 오류가 발생했습니다: ${ex.message}",
        timestamp = LocalDateTime.now(),
        traceId = MDC.get("trace_id")  // 에러 응답에 trace_id 포함
    )
    return ResponseEntity(errorResponse, HttpStatus.INTERNAL_SERVER_ERROR)
}

이제 프론트엔드에서 "에러가 났어요"라고 할 때, 응답에 포함된 traceId로 Collector에서 해당 요청의 전체 흐름을 추적할 수 있다.

Phase 3: 커스텀 Span — 블록체인 호출 세부 추적

자동 계측은 HTTP/DB 호출만 잡는다. 블록체인 RPC 호출이나 비즈니스 로직의 세부 단계는 직접 Span을 만들어야 했다.

@WithSpan("TokenContract.transferWithAuthorization")
fun transferWithAuthorization(
    @SpanAttribute("blockchain.from_address") fromAddress: String,
    @SpanAttribute("blockchain.to_address") toAddress: String,
    @SpanAttribute("payment.amount") amount: BigDecimal,
    encryptedPrivateKey: String
): String {
    // ... 블록체인 전송 로직
}

@WithSpan 어노테이션 하나로 해당 메서드 실행이 별도 Span으로 기록된다. @SpanAttribute로 주소, 금액 같은 비즈니스 컨텍스트도 함께 기록했다. → @WithSpan은 이 메서드가 실행되는 동안 자동으로 Span을 생성하고 종료해주는 어노테이션이다. @SpanAttribute는 해당 Span에 파라미터 값을 태그처럼 붙여주는 어노테이션이다.

이런 커스텀 Span을 주요 서비스 메서드에 적용했다:

  • TokenContractService: mint, burn, transfer 등 블록체인 호출
  • PaymentManagerService: batchPayment, batchRefund
  • TransactionService: 결제, 환불, 인센티브 처리
  • NonceManagerService: nonce 획득 및 동기화

Gradle 의존성

ADOT Agent가 런타임에 OpenTelemetry 구현을 제공하므로, 코드에서는 API만 있으면 된다.

// build.gradle.kts
implementation("io.opentelemetry:opentelemetry-api:1.34.1")
implementation("io.opentelemetry.instrumentation:opentelemetry-instrumentation-annotations:2.1.0")

opentelemetry-api는 인터페이스만 제공하고, 실제 트레이스 수집/전송은 ADOT Agent가 처리한다. 에이전트 없이 실행하면 @WithSpan이 no-op이 되므로, 로컬 개발에 영향 없다. → no-op(no operation)이란 "아무것도 안 하는 동작"이다. 에이전트가 없으면 @WithSpan이 달려있어도 그냥 무시되고 원래 코드만 실행된다.

Phase 5를 시도했다가 철회한 이야기

Tail Sampling(오류가 난 트레이스만 수집하는 설정)도 넣어봤다. → Tail Sampling이란 트레이스가 완전히 끝난 후에 "이 트레이스를 저장할지 말지" 판단하는 방식이다. 에러가 난 것만 저장하면 데이터 양을 줄일 수 있지만, 정상 요청의 성능 분석이 어려워진다. 트레이스 데이터가 너무 많아질까 걱정돼서. 근데 결제 시스템 특성상 모든 트레이스가 중요해서, 결국 제거했다.

2026-03-03 fix: Phase 5 (Sampling) 제거

"필요 없는 최적화는 안 하는 게 낫다"는 교훈.

배운 점

  • ADOT Agent 방식은 코드 침습성이 거의 없다. @WithSpan 어노테이션 몇 개가 전부다
  • OTLP 프로토콜(grpc vs http/protobuf)은 반드시 명시적으로 설정해야 한다. 디폴트 값에 의존하면 디버깅하기 어려운 연결 오류가 난다
  • 에러 응답에 traceId를 포함시키면, 프론트엔드-백엔드 간 디버깅이 극적으로 편해진다
  • OTEL_ENABLED 플래그로 환경별 on/off를 구현하면 로컬 개발 환경에서 오버헤드 없이 사용 가능
  • 모든 트레이스를 샘플링할 필요는 없지만, 결제처럼 모든 건이 중요한 시스템에서는 전수 수집이 맞다
결제 API가 느린데, 어디가 느린지 모르겠다 — OpenTelemetry 도입기 | KYUDORI