엔드투엔드 API 가시성을 위한 Go OpenTelemetry 트레이싱
Go OpenTelemetry 트레이싱을 실제 단계별로 설명합니다. HTTP 요청, 백그라운드 작업, 서드파티 호출 전반에서 트레이스, 메트릭, 로그를 상관관계하는 방법을 제시합니다.

Go API에서의 엔드투엔드 트레이싱 의미
트레이스는 요청이 시스템을 통과하는 전체 타임라인입니다. API 호출이 도착할 때 시작해 응답을 보낼 때 끝납니다.
트레이스 안에는 스팬이 있습니다. 스팬은 "요청 파싱", "SQL 실행", "결제 제공자 호출"처럼 시간 측정이 되는 한 단계입니다. 스팬은 HTTP 상태 코드, 안전한 사용자 식별자, 쿼리가 반환한 행 수 같은 유용한 세부 정보를 담을 수 있습니다.
"엔드투엔드"는 트레이스가 첫 번째 핸들러에서 멈추지 않는다는 뜻입니다. 미들웨어, 데이터베이스 쿼리, 캐시 호출, 백그라운드 작업, 서드파티 API(결제, 이메일, 지도), 다른 내부 서비스 등 문제가 숨어있기 쉬운 곳들을 따라갑니다.
트레이싱은 문제가 간헐적일 때 특히 가치가 큽니다. 200개 요청 중 한 번만 느려지는 경우, 로그만 보면 빠른 요청과 느린 요청이 동일해 보일 수 있습니다. 트레이스는 차이를 명확히 보여줍니다: 한 요청이 외부 호출을 기다리며 800ms를 소비했고, 두 번 재시도한 뒤 후속 작업을 시작했다는 식으로요.
또한 로그는 서비스 간에 연결하기 어렵습니다. API에 한 줄, 워커에 다른 줄의 로그만 있고 그 사이에서 아무 것도 없을 수 있습니다. 트레이싱을 사용하면 그런 이벤트들이 동일한 trace ID를 공유하므로 추측하지 않고 체인을 따라갈 수 있습니다.
트레이스, 메트릭, 로그: 각 신호의 역할
트레이스, 메트릭, 로그는 서로 다른 질문에 답합니다.
트레이스는 한 실제 요청에서 무슨 일이 있었는지를 보여줍니다. 핸들러, 데이터베이스 호출, 캐시 조회, 서드파티 요청에서 시간이 어디에 쓰였는지 알려줍니다.
메트릭은 추세를 보여줍니다. 집계가 안정적이고 비용이 낮아 경보에 가장 적합합니다: 지연의 퍼센타일, 요청률, 오류율, 큐 깊이, 포화도 등입니다.
로그는 "왜"를 평문으로 설명합니다: 검증 실패, 예상치 못한 입력, 엣지 케이스, 코드가 내린 결정 등입니다.
진짜 이점은 상관관계입니다. 동일한 trace ID가 스팬과 구조화된 로그에 나타나면 오류 로그에서 정확한 트레이스로 바로 이동해 어떤 의존성이 느려졌는지 또는 어떤 단계가 실패했는지를 즉시 볼 수 있습니다.
간단한 사고 모델
각 신호를 잘할 수 있는 용도로 사용하세요:
- 메트릭은 무언가 잘못되었음을 알려줍니다.
- 트레이스는 한 요청에서 시간이 어디에 쓰였는지 보여줍니다.
- 로그는 코드가 무엇을 결정했고 왜 그런지를 설명합니다.
예시: POST /checkout 엔드포인트가 타임아웃을 시작합니다. 메트릭은 p95 지연이 급증했음을 보여줍니다. 트레이스는 대부분의 시간이 결제 제공자 호출 내부에 있다는 것을 보여줍니다. 해당 스팬 내부의 상관된 로그 라인은 502로 인해 재시도가 발생했음을 보여주며, 이는 백오프 설정이나 상류 장애를 가리킵니다.
코드 추가 전에: 이름 짓기, 샘플링, 추적할 항목
사전 계획이 있어야 나중에 트레이스를 검색하기 쉽습니다. 계획이 없으면 데이터는 수집되지만 기본적인 질문들이 어려워집니다: "이것이 스테이징인가 프로덕션인가?" "어떤 서비스가 문제를 시작했는가?"
일관된 식별부터 시작하세요. 각 Go API에 명확한 service.name을 정하고(예: checkout-api), deployment.environment=dev|staging|prod 같은 단일 환경 필드를 사용하세요. 이 값들을 안정적으로 유지하세요. 이름이 주중에 바뀌면 차트와 검색에서 서로 다른 시스템처럼 보입니다.
다음으로 샘플링을 결정하세요. 개발에서는 모든 요청을 추적하는 것이 좋지만, 프로덕션에서는 비용이 부담스러울 수 있습니다. 일반적인 방법은 정상 트래픽의 작은 비율을 샘플링하고, 오류와 느린 요청은 항상 남기는 것입니다. 고빈도 엔드포인트(헬스체크, 폴링)는 적게 또는 전혀 추적하지 않는 것이 좋습니다.
마지막으로 스팬에 어떤 태그를 달지, 어떤 것을 절대 수집하지 않을지 합의하세요. 서비스 간 이벤트를 연결하는 데 도움이 되는 속성의 허용 목록을 짧게 유지하고 간단한 개인정보 보호 규칙을 만드세요.
좋은 태그는 보통 안정적인 ID와 대략적인 요청 정보를 포함합니다(라우트 템플릿, 메서드, 상태 코드). 민감한 페이로드는 절대 수집하지 마세요: 비밀번호, 결제 데이터, 전체 이메일, 인증 토큰, 원시 요청 본문 등. 사용자 관련 값을 꼭 포함해야 한다면 해시하거나 가리세요.
단계별: Go HTTP API에 OpenTelemetry 트레이싱 추가하기
시작 시점에 한 번 트레이서 프로바이더를 설정합니다. 이 설정은 스팬이 어디로 전송되는지와 모든 스팬에 첨부될 리소스 속성을 결정합니다.
1) OpenTelemetry 초기화
반드시 service.name을 설정하세요. 이 값이 없으면 서로 다른 서비스의 트레이스가 섞여 차트가 읽기 어려워집니다.
// main.go (startup)
exp, _ := stdouttrace.New(stdouttrace.WithPrettyPrint())
res, _ := resource.New(context.Background(),
resource.WithAttributes(
semconv.ServiceName("checkout-api"),
),
)
tp := sdktrace.NewTracerProvider(
sdktrace.WithBatcher(exp),
sdktrace.WithResource(res),
)
otel.SetTracerProvider(tp)
이것이 Go OpenTelemetry 트레이싱의 기초입니다. 다음으로는 들어오는 각 요청에 대해 스팬을 만들어야 합니다.
2) HTTP 미들웨어 추가 및 주요 필드 캡처
자동으로 스팬을 시작하고 상태 코드와 지속 시간을 기록하는 HTTP 미들웨어를 사용하세요. 스팬 이름은 원시 URL이 아니라 라우트 템플릿(예: /users/:id)을 사용해 설정하세요. 그렇지 않으면 수천 개의 고유 경로가 생깁니다.
기본으로 깔끔한 규칙을 지향하세요: 요청당 서버 스팬 하나, 라우트 기반 스팬 이름, 캡처된 HTTP 상태, 핸들러 실패는 스팬 오류로 반영, 지속 시간은 트레이스 뷰어에서 확인 가능.
3) 실패를 명확히 표시하기
문제가 발생하면 오류를 반환하고 현재 스팬을 실패로 표시하세요. 그러면 로그를 보기 전에도 트레이스가 눈에 띕니다.
핸들러에서 다음을 할 수 있습니다:
span := trace.SpanFromContext(r.Context())
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
4) 로컬에서 trace ID 확인하기
API를 실행하고 엔드포인트를 호출해 보세요. 요청 컨텍스트에서 trace ID를 한 번 로그에 남겨 요청마다 바뀌는지 확인하세요. 항상 비어 있다면 미들웨어가 핸들러가 받는 동일한 컨텍스트를 사용하지 않는 것입니다.
DB와 서드파티 호출에서 컨텍스트 유지하기
context.Context를 잃어버리는 순간 엔드투엔드 가시성은 깨집니다. 들어오는 요청 컨텍스트는 모든 DB 호출, HTTP 호출, 헬퍼에 전달하는 실밥(thread)이어야 합니다. context.Background()로 교체하거나 전달을 잊으면 트레이스는 별개의 관련 없는 작업으로 나뉩니다.
아웃바운드 HTTP의 경우, 계측된 트랜스포트를 사용해 Do(req)가 현재 요청의 자식 스팬이 되게 하세요. 하류 서비스가 동일한 트레이스에 스팬을 붙일 수 있도록 W3C 트레이스 헤더를 전달하세요.
데이터베이스 호출도 같은 처리가 필요합니다. 계측된 드라이버를 사용하거나 QueryContext 및 ExecContext 주위에 스팬을 래핑하세요. 안전한 세부 정보만 기록하세요. 느린 쿼리를 찾고자 하지만 데이터를 유출해서는 안 됩니다.
유용하면서 위험이 적은 속성은 작업 이름(예: SELECT user_by_id), 테이블 또는 모델 이름, 행 수(개수만), 지속 시간, 재시도 횟수, 그리고 거친 오류 유형(타임아웃, 취소, 제약 위반) 등을 포함합니다.
타임아웃은 단지 실패의 일부가 아닙니다. DB와 서드파티 호출에 context.WithTimeout을 설정하고 취소가 전파되게 하세요. 호출이 취소되면 스팬을 오류로 표시하고 deadline_exceeded 같은 짧은 이유를 추가하세요.
백그라운드 작업과 큐 트레이싱
백그라운드 작업은 트레이스가 멈추기 쉬운 곳입니다. HTTP 요청이 끝나고 워커가 다른 머신에서 나중에 메시지를 처리하면 컨텍스트가 공유되지 않습니다. 아무것도 하지 않으면 API 트레이스와 어디선가 시작된 것처럼 보이는 작업 트레이스 두 개만 남습니다.
해결 방법은 간단합니다: 작업을 큐에 넣을 때 현재 트레이스 컨텍스트를 캡처해 작업 메타데이터(페이로드, 헤더, 속성 등)에 저장하세요. 워커가 시작될 때 그 컨텍스트를 추출해 원래 요청의 자식으로 새 스팬을 시작하세요.
컨텍스트를 안전하게 전파하기
트레이스 컨텍스트만 복사하고 사용자 데이터를 복사하지 마세요.
- trace 식별자와 샘플링 플래그(W3C traceparent 스타일)만 주입하세요.
- 비즈니스 필드와 분리해서 보관하세요(예: 별도의 "otel" 또는 "trace" 필드).
- 다시 읽을 때는 신뢰하지 않는 입력으로 취급하세요(형식 검증, 누락 처리).
- 토큰, 이메일, 요청 본문을 작업 메타데이터에 넣지 마세요.
노이즈를 만들지 않고 추가할 스팬
읽기 쉬운 트레이스는 보통 몇 개의 의미 있는 스팬을 가지며, 수십 개의 작은 스팬이 있지는 않습니다. 경계와 "대기 지점" 주위에 스팬을 만드세요. 시작점으로는 API 핸들러의 enqueue 스팬과 워커의 job.run 스팬이 적당합니다.
소량의 컨텍스트만 추가하세요: 시도 번호, 큐 이름, 작업 종류, 페이로드 크기(내용 아님). 재시도가 발생하면 백오프 지연을 볼 수 있게 별도의 스팬이나 이벤트로 기록하세요.
예약된 작업에도 부모가 필요합니다. 요청이 없다면 각 실행마다 새로운 루트 스팬을 만들고 스케줄 이름을 태그하세요.
로그와 트레이스 상관관계(그리고 로그 안전 유지)
트레이스는 시간이 어디에 쓰였는지를 말해주고, 로그는 무슨 일이 일어났고 왜인지 말합니다. 이를 연결하는 가장 간단한 방법은 모든 로그 항목에 trace_id와 span_id를 구조화된 필드로 추가하는 것입니다.
Go에서는 context.Context에서 활성 스팬을 가져와 각 요청(또는 작업)마다 로거를 확장하세요. 그러면 모든 로그 라인이 특정 트레이스를 가리킵니다.
span := trace.SpanFromContext(ctx)
sc := span.SpanContext()
logger := baseLogger.With(
"trace_id", sc.TraceID().String(),
"span_id", sc.SpanID().String(),
)
logger.Info("charge_started", "order_id", orderID)
이 정도면 로그 항목에서 정확한 스팬으로 바로 이동할 수 있습니다. 또한 컨텍스트가 없으면 trace_id가 비어 있어 이를 통해 문제를 알 수 있습니다.
PII를 유출하지 않고 로그 유용성 유지하기
로그는 트레이스보다 더 오래 보관되고 더 넓게 전파되는 경우가 많으므로 더 엄격해야 합니다. 안정적인 식별자와 결과를 선호하세요: user_id, order_id, payment_provider, status, error_code. 사용자 입력을 꼭 로그로 남겨야 한다면 먼저 가리고 길이를 제한하세요.
오류 그룹화를 쉽게 만들기
일관된 이벤트 이름과 오류 유형을 사용해 집계하고 검색하기 쉽게 만드세요. 문구가 매번 바뀌면 동일한 이슈가 여러 다른 것으로 보입니다.
문제를 찾는 데 실제로 도움이 되는 메트릭 추가하기
메트릭은 초기 경고 시스템입니다. 이미 Go OpenTelemetry 트레이싱을 사용하는 환경에서는 메트릭으로: 얼마나 자주, 얼마나 심각한지, 언제부터인지를 답하게 하세요.
대부분의 API에 거의 항상 유용한 소규모 집합으로 시작하세요: 요청 수, 오류 수(상태 클래스별), 지연 퍼센타일(p50, p95, p99), 처리 중 요청 수, DB 및 주요 서드파티 호출의 의존성 지연.
트레이스와 메트릭을 정렬하려면 같은 라우트 템플릿과 이름을 사용하세요. 스팬이 /users/{id}를 사용하면 메트릭도 동일하게 하세요. 그러면 차트에 "/checkout의 p95이 급증"이 보일 때 해당 라우트로 필터링된 트레이스로 바로 들어갈 수 있습니다.
라벨(속성)은 주의해서 사용하세요. 잘못된 라벨 하나가 비용을 폭발시키고 대시보드를 쓸모없게 만들 수 있습니다. 라우트 템플릿, 메서드, 상태 클래스, 서비스 이름은 보통 안전합니다. 사용자 ID, 이메일, 전체 URL, 원시 오류 메시지는 보통 안전하지 않습니다.
비즈니스 중요 이벤트(예: checkout 시작/완료, 결제 실패 유형별, 백그라운드 작업 성공 대 재시도)용 커스텀 메트릭을 몇 개 추가하세요. 집합을 작게 유지하고 사용하지 않는 것은 제거하세요.
텔레메트리 내보내기 및 안전한 롤아웃
내보내기는 OpenTelemetry가 실용화되는 지점입니다. 서비스는 스팬, 메트릭, 로그를 신뢰할 수 있는 곳으로 지연 없이 보내야 합니다.
로컬 개발에서는 단순하게 유지하세요. 콘솔 익스포터나 로컬 컬렉터로 OTLP를 보내면 트레이스를 빠르게 보고 스팬 이름과 속성을 검증할 수 있습니다. 프로덕션에서는 서비스 근처의 에이전트나 OpenTelemetry Collector로 OTLP를 보내는 것을 선호하세요. 그러면 재시도, 라우팅, 필터링을 한 곳에서 처리할 수 있습니다.
배치 전송이 중요합니다. 짧은 간격의 배치와 엄격한 타임아웃으로 전송해 네트워크가 막혀도 요청을 차단하지 않게 하세요. 텔레메트리는 크리티컬 경로에 있어서는 안 됩니다. 익스포터가 따라가지 못하면 메모리를 쌓기보다는 데이터를 버려야 합니다.
샘플링으로 비용을 예측 가능하게 유지하세요. 먼저 헤드 기반 샘플링(예: 요청의 1~10%)으로 시작하고 간단한 규칙을 추가하세요: 오류는 항상 샘플링, 특정 임계값 이상의 느린 요청은 항상 샘플링. 고빈도 백그라운드 작업은 낮은 비율로 샘플링하세요.
롤아웃은 작은 단계로 하세요: 개발에서 100% 샘플링, 스테이징에서 현실적인 트래픽과 낮은 샘플링, 프로덕션에서는 보수적인 샘플링과 익스포터 실패에 대한 알림.
엔드투엔드 가시성을 망치는 흔한 실수들
엔드투엔드 가시성은 대개 단순한 이유로 실패합니다: 데이터는 존재하지만 연결되지 않습니다.
Go에서 분산 트레이싱을 깨뜨리는 흔한 문제는 대개 다음과 같습니다:
- 레이어 간에 컨텍스트를 버림. 핸들러는 스팬을 만들지만 DB 호출, HTTP 클라이언트, 고루틴이 요청 컨텍스트 대신
context.Background()를 사용함. - 오류를 반환하면서 스팬을 표시하지 않음. 오류를 기록하고 스팬 상태를 설정하지 않으면 트레이스가 사용자에게는 500이지만 "정상"처럼 보일 수 있음.
- 모든 것을 계측함. 모든 헬퍼가 스팬이 되면 트레이스가 소음이 되고 비용이 증가함.
- 고카디널리티 속성 추가. ID가 포함된 전체 URL, 이메일, 원시 SQL 값, 요청 본문, 원시 오류 문자열은 수백만 개의 고유값을 생성할 수 있음.
- 평균으로 성능을 판단함. 사고는 평균이 아니라 퍼센타일(p95/p99)과 오류율에 드러남.
빠른 점검은 하나의 실제 요청을 골라 경계들을 따라가는 것입니다. 인바운드 요청, DB 쿼리, 서드파티 호출, 비동기 워커를 통해 하나의 trace ID가 흐르는지 볼 수 없다면 엔드투엔드 가시성이 아직 확보된 것이 아닙니다.
실용적인 "완료" 체크리스트
사용자 보고서에서 정확한 요청으로 이동한 뒤 그 요청을 모든 홉에서 따라갈 수 있을 때 거의 완료된 것입니다.
- 하나의 API 로그 라인을 골라
trace_id로 정확한 트레이스를 찾으세요. 동일한 요청의 더 깊은 로그(DB, HTTP 클라이언트, 워커)가 동일한 트레이스 컨텍스트를 가지고 있는지 확인하세요. - 트레이스를 열어 중첩 구조를 확인하세요: 최상단에 HTTP 서버 스팬, 그 아래 DB 호출과 서드파티 API의 자식 스팬들이 있는지. 평평한 목록이면 컨텍스트가 손실된 것입니다.
- API 요청에서 백그라운드 작업을 트리거(예: 영수증 이메일 전송)하고 워커 스팬이 요청으로 연결되는지 확인하세요.
- 기본 메트릭(요청 수, 오류율, 지연 퍼센타일)을 확인하고 라우트나 작업으로 필터링할 수 있는지 확인하세요.
- 속성과 로그에서 안전성을 검사하세요: 비밀번호, 토큰, 전체 신용카드 번호, 원시 개인정보가 없는지 확인하세요.
간단한 현실 테스트는 결제 제공자가 지연될 때 느린 체크아웃을 시뮬레이션하는 것입니다. 한 트레이스에서 외부 호출 스팬이 명확히 표시되고 체크아웃 라우트의 p95 지연이 급증하는 메트릭이 보여야 합니다.
만약 AppMaster로 Go 백엔드를 생성한다면(예: AppMaster), 이 체크리스트를 릴리스 루틴의 일부로 만들어 새 엔드포인트와 워커가 앱 성장에 따라 계속 추적 가능하도록 하는 것이 도움됩니다. AppMaster (appmaster.io)는 실제 Go 서비스를 생성하므로 하나의 OpenTelemetry 설정을 표준화해 서비스와 백그라운드 작업에 걸쳐 적용할 수 있습니다.
예시: 서비스 전반의 느린 체크아웃 디버깅
고객이 "체크아웃이 가끔 멈춥니다"라고 보고합니다. 재현이 어렵다면 Go OpenTelemetry 트레이싱의 이점이 발휘됩니다.
먼저 메트릭으로 문제의 형태를 파악하세요. 요청률, 오류율, 체크아웃 엔드포인트의 p95 또는 p99 지연을 살펴보세요. 지연이 짧은 시간에만 발생하고 일부 요청에만 영향을 준다면 보통 의존성, 큐잉, 재시도 동작이 원인이지 CPU는 아닙니다.
다음으로 같은 시간대의 느린 트레이스를 열어보세요. 하나의 트레이스면 충분할 때가 많습니다. 정상적인 체크아웃은 보통 300600ms입니다. 문제 있는 경우 812초가 될 수 있으며 대부분의 시간이 하나의 스팬 내부에 있습니다.
흔한 패턴은 이렇습니다: API 핸들러는 빠르지만 DB 작업은 대체로 괜찮고, 결제 제공자 스팬에서 재시도가 반복되며 다운스트림 호출이 잠금이나 큐 뒤에서 대기합니다. 응답이 여전히 200을 반환할 수 있어 오류 기반 알림은 전혀 울리지 않을 수 있습니다.
상관된 로그는 정확한 경로를 평문으로 알려줍니다: "retrying Stripe charge: timeout" 다음에 "db tx aborted: serialization failure", 이어서 "retry checkout flow" 같은 로그가 나오면 작은 문제들이 결합해 사용자 경험을 망가뜨리고 있다는 명확한 신호입니다.
병목을 찾은 뒤에는 일관성이 시간이 지나도 가독성을 유지합니다. 스팬 이름, 안전한 속성(사용자 ID 해시, 주문 ID, 의존성 이름), 샘플링 규칙을 서비스 전반에 걸쳐 표준화해 모두가 같은 방식으로 트레이스를 읽을 수 있게 하세요.


