Cheondi
개발 · API

관측 가능성을 만드는 증거 연결

로그, 메트릭, 트레이스를 많이 모으는 것보다 화면에서 서비스까지 같은 사건의 증거를 연결하는 방법을 정리한 기록입니다.

  • #opentelemetry
  • #observability
  • #tracing
  • #logs
  • #go

운영 문제를 확인할 때 “로그를 봤는데 아무것도 없다”는 상황을 자주 만난다. 실제로 요청이 오지 않은 건지, 다른 서버로 간 건지, 보고 있는 파일이 오래된 건지, 필요한 식별자가 없어서 못 찾는 건지 한 문장만으로는 알 수 없다.

처음에는 로그를 더 많이 남기면 해결될 거라고 생각했다. 그런데 양이 늘수록 한 요청과 관련 없는 줄도 같이 늘었다. 화면에서 본 오류 시각과 서버 시간대가 다르고, API gateway와 서비스가 서로 다른 ID를 쓰면 같은 사건인지 연결하기가 더 어려웠다.

이번에는 CNCF의 observability stack 글, OpenTelemetry 프로젝트 페이지, 공식 Signals 문서를 같이 읽었다. OpenTelemetry를 설치하는 방법보다 어떤 증거를 어떻게 이어야 하는지에 초점을 맞췄다.

OpenTelemetry Graduation — 도구의 성숙도와 내 시스템의 성숙도

OpenTelemetry는 2026년 5월 CNCF graduated 프로젝트가 됐다. 여러 언어의 API와 SDK, Collector, OTLP라는 전송 규약, 다양한 backend 연동이 넓게 쓰이고 있다는 신호다. 특정 vendor에 묶이지 않고 telemetry를 계측하고 옮길 수 있는 공통 기반이 생긴 점은 분명 큰 변화다.

하지만 프로젝트가 성숙했다고 내 서비스의 observability가 자동으로 성숙해지는 것은 아니다. trace를 켜도 span 이름이 전부 request라면 어디에서 시간을 썼는지 알기 어렵다. 모든 요청 body를 로그에 남기면 검색은 쉬워 보여도 민감 정보와 저장 비용 문제가 생긴다.

그래서 도구를 고르기 전에 먼저 질문을 적어보는 게 맞다고 생각했다.

사용자가 누른 요청이 서버에 도착했는가?
어느 서비스와 외부 API를 거쳤는가?
실패 전 마지막으로 확인된 상태는 무엇인가?
재시도는 같은 요청인가, 새 요청인가?
배포 전후에 오류 비율과 지연 시간이 달라졌는가?

이 질문에 답할 수 있는 최소 신호를 정한 다음 SDK와 Collector를 붙여야 한다. 반대로 가능한 signal부터 모두 수집하면 저장소는 커져도 조사 순서는 계속 사람 머릿속에만 남는다.

Telemetry Signals — 같은 데이터를 세 번 저장하지 않는 법

OpenTelemetry Signals 문서는 traces, metrics, logs, baggage, profiles를 나눠 설명한다. 각각은 비슷한 시스템을 보지만 답하는 질문이 다르다.

신호 잘 답하는 질문
Trace 한 요청이 어디를 거쳤고 어디에서 오래 걸렸는가
Metric 일정 시간 동안 오류율, 처리량, 지연 분포가 어떻게 변했는가
Log 특정 시점에 애플리케이션이 어떤 사건과 상태를 기록했는가
Baggage 서비스 경계를 넘어 함께 전달해야 할 작은 문맥은 무엇인가
Profile 프로세스가 CPU와 메모리를 어디에 사용했는가

에러 한 건을 찾는 데 metric의 모든 집계 값이 필요한 것은 아니다. 반대로 장애가 전체 사용자의 몇 퍼센트에 영향을 줬는지는 log 몇 줄만으로 알기 어렵다. 문제의 크기는 metric으로 보고, 개별 요청은 trace로 좁히고, 그 지점의 애플리케이션 판단은 log로 확인하는 흐름이 자연스럽다.

Go 서비스라면 log에 trace ID를 연결하는 작은 작업부터 시작할 수 있다.

func logFailure(ctx context.Context, logger *slog.Logger, route string, err error) {
    span := trace.SpanFromContext(ctx)
    logger.Error("request failed",
        "route", route,
        "trace_id", span.SpanContext().TraceID().String(),
        "error_type", fmt.Sprintf("%T", err),
    )
}

여기에는 request body나 계정 값이 없다. 조사에 필요한 route, trace ID, 오류 종류만 남긴다. 상세 데이터가 정말 필요하면 접근 제어된 별도 저장소와 보존 정책을 먼저 정해야 한다.

Three Stacks — 수집 도구가 나뉘어도 문맥은 이어져야 한다

CNCF 글은 많은 팀이 logs, metrics, traces를 서로 다른 stack으로 운영하는 현실을 다룬다. 도구가 셋인 것 자체가 항상 문제는 아니다. 각 도구의 검색과 저장 특성이 다르기 때문이다. 진짜 불편은 한 도구에서 찾은 사건을 다른 도구에서 다시 찾을 열쇠가 없을 때 생긴다.

예를 들어 사용자가 21시 10분에 오류 화면을 봤다고 하자. 다음 정보가 이어져 있으면 조사 범위가 빠르게 줄어든다.

화면: correlation_id=web-7f3a, 사용자 현지 시각
API access: correlation_id=web-7f3a, trace_id=4c91..., status=502
service span: trace_id=4c91..., external.request
service log: trace_id=4c91..., provider_timeout
metric: provider_timeout_total 증가 구간

화면에서 내부 trace ID를 직접 보여줄 필요는 없다. 사용자에게 전달할 짧은 문의 코드와 내부 trace ID를 access layer에서 연결할 수 있다. 중요한 건 각 계층이 자기 ID를 새로 만들고 버리지 않는 것이다.

Collector는 이 신호들을 여러 backend로 보내는 공통 지점이 될 수 있다. 다만 Collector가 요청 문맥을 새로 만들어 주지는 않는다. 애플리케이션이 incoming context를 읽고 outgoing request에 전달해야 trace가 이어진다.

req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://api.example.com/status", nil)
if err != nil {
    return err
}

otel.GetTextMapPropagator().Inject(ctx, propagation.HeaderCarrier(req.Header))
response, err := client.Do(req)

외부 서비스가 trace header를 받지 않더라도 내부 span에는 외부 요청의 시작, 종료, timeout 종류를 남길 수 있다. 상대 시스템의 내부 상태까지 보인다고 가정하지 않고 내가 통제하는 경계의 증거를 정확히 남기는 편이 낫다.

Evidence Chain — 화면에서 원인까지 따라가는 순서

실제 조사에서는 멋진 dashboard보다 순서가 중요했다. 내가 유지하고 싶은 기본 흐름은 다음과 같다.

  1. 사용자가 본 화면, 시각, 요청 종류를 확인한다.
  2. access log에서 요청 도착과 correlation ID를 확인한다.
  3. 해당 trace에서 서비스와 외부 호출의 순서를 본다.
  4. 실패 span과 같은 trace ID의 애플리케이션 log를 읽는다.
  5. metric으로 같은 문제가 전체적으로 늘었는지 확인한다.
  6. 배포 artifact와 설정이 조사한 runtime과 일치하는지 확인한다.

여기서 어느 한 단계가 없으면 “없음”과 “관측하지 못함”을 구분해서 적어야 한다. 오래된 log 파일에서 결과가 없다고 요청이 오지 않았다고 결론내리면 안 된다. trace sampling 때문에 개별 요청이 빠졌을 수도 있다. 증거의 부재가 바로 사건의 부재는 아니다.

서비스에 처음 적용할 때도 전체 구조를 한 번에 바꾸기보다 한 요청 경로를 골라 끝까지 연결해 보는 편이 좋다.

route: POST /api/example/orders
evidence:
  - browser_correlation_id
  - access_status_and_latency
  - service_trace_id
  - database_span_without_query_values
  - external_call_result
success: five layers point to the same request

이 작은 경로에서 민감 정보가 섞이지 않는지, 비용이 어느 정도인지, 장애 때 실제로 찾을 수 있는지 확인한 뒤 다른 API로 넓힐 수 있다.

OpenTelemetry를 읽고 얻은 결론은 모든 신호를 한 제품에 모아야 한다는 게 아니었다. 조사할 때 서로 다른 증거가 같은 사건을 가리키도록 연결해야 한다는 쪽에 가깝다.

로그는 여전히 중요하다. 다만 로그 한 줄이 화면 증상과 trace, metric, 배포 상태 사이에서 어떤 위치를 갖는지 알아야 한다. 다음부터는 “로그를 더 남기자”보다 “이 요청을 어느 열쇠로 끝까지 찾을 수 있을까”를 먼저 물어보려고 한다.


참고한 글과 코드