결제 API가 느린데, 어디가 느린지 모르겠다 — OpenTelemetry 도입기
결제 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/protobuf와 gRPC는 같은 데이터를 보내지만 전송 방식이 다르다. 보내는 쪽과 받는 쪽이 같은 방식을 써야 통신이 된다.
→ Collector는 여러 서비스에서 보낸 트레이스 데이터를 받아서 저장소로 전달해주는 중간 수집기다.
# 이걸 명시해야 했다
OTEL_EXPORTER_OTLP_PROTOCOL=grpc
프로토콜 하나 명시 안 해서 반나절을 날렸다. OpenTelemetry에서 http/protobuf와 grpc는 같은 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, batchRefundTransactionService: 결제, 환불, 인센티브 처리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 프로토콜(
grpcvshttp/protobuf)은 반드시 명시적으로 설정해야 한다. 디폴트 값에 의존하면 디버깅하기 어려운 연결 오류가 난다 - 에러 응답에
traceId를 포함시키면, 프론트엔드-백엔드 간 디버깅이 극적으로 편해진다 OTEL_ENABLED플래그로 환경별 on/off를 구현하면 로컬 개발 환경에서 오버헤드 없이 사용 가능- 모든 트레이스를 샘플링할 필요는 없지만, 결제처럼 모든 건이 중요한 시스템에서는 전수 수집이 맞다