OpenTelemetry 로그 정리: 로그 브리지로 추적과 잇기

OpenTelemetry 로그 정리: 로그 브리지로 추적과 잇기

남은 신호는 로그 하나입니다. 그런데 지표 정리까지 왔다면 좀 이상하게 느껴질 거예요. 로그는 이미 다들 쓰고 있잖아요. 😅 파이썬이면 logging, Node라면 Pino나 Winston을 붙여 두고 잘 돌아가는데 여기에 또 새로운 걸 얹어야 하나 싶죠.

OpenTelemetry도 같은 생각을 했습니다. 그래서 로그만 다른 길을 갔어요. 이 글에서는 그 선택이 무엇이었는지, 그리고 기존 로그를 추적과 이어 붙이는 방법을 다루겠습니다.

로그만 접근이 달랐던 이유

추적과 지표는 OpenTelemetry가 새로 표준을 만들었습니다. 그 전까지 널리 쓰이던 표준이 없었으니까요.

로그는 정반대였습니다. 수십 년 된 라이브러리가 언어마다 자리를 잡고 있었고, 다들 익숙했고, 잘 돌아갔어요. 여기에 “이제 이 API로 로그를 찍으세요”라고 새 표준을 들이미는 건 현실적이지 않았습니다.

그래서 택한 게 다리를 놓는 방식이었습니다. 기존 로깅 라이브러리가 뱉는 로그를 받아서 OpenTelemetry 형식으로 옮겨 주는 층을 만든 거죠. 이걸 로그 브리지(log bridge)라고 부르고, 애플리케이션 개발자가 직접 쓰는 API가 아니라 로깅 라이브러리를 만드는 쪽이 쓰는 API로 설계했습니다.

덕분에 Python 로깅이나 NestJS 로깅에서 쓰던 코드를 그대로 두고 출력만 OpenTelemetry 쪽으로 돌릴 수 있습니다.

진짜 얻고 싶은 것

로그를 OpenTelemetry에 태워서 뭐가 좋아질까요. 형식이 통일되는 것도 있지만 실익은 다른 데 있습니다.

로그와 추적을 이어 붙이는 겁니다. 장애를 볼 때 흐름을 떠올려 보면 분명해져요. 지표에서 오류율이 튀는 걸 보고, 추적에서 느린 요청 하나를 열고, 그 요청이 무슨 일을 했는지 로그를 찾습니다. 마지막 단계가 문제죠. 시각으로 어림잡아 검색하고, 비슷한 시각의 다른 요청 로그까지 섞여 나와 눈으로 골라내야 합니다.

trace_id가 로그에 있으면 이 과정이 검색 한 번으로 끝납니다. 추적 화면에서 ID를 복사해 로그 검색창에 넣으면 그 요청의 로그만 나오죠. 반대로 로그에서 시작해 추적으로 건너갈 수도 있고요.

기존 로거에 ID 붙이기

가장 빨리 효과를 보는 방법부터 보겠습니다. 로그를 찍는 시점에 활성 스팬에서 ID를 꺼내 함께 남기는 거예요.

log.ts
import { trace } from "@opentelemetry/api";

export function log(message: string, extra: Record<string, unknown> = {}) {
  const ctx = trace.getActiveSpan()?.spanContext();
  console.log(
    JSON.stringify({
      level: "info",
      message,
      trace_id: ctx?.traceId,
      span_id: ctx?.spanId,
      ...extra,
    }),
  );
}

trace.getActiveSpan()이 핵심입니다. 컨텍스트 전파 정리에서 본 그 컨텍스트에서 현재 스팬을 꺼내는 함수예요. 스팬이 없으면 undefined를 주니 추적이 없는 자리에서도 안전합니다.

server.ts
Bun.serve({
  port: 3000,
  fetch(request) {
    const id = new URL(request.url).pathname.split("/").at(-1) ?? "";
    return tracer.startActiveSpan("GET /orders/:id", (span) => {
      log("주문 조회 시작", { "order.id": id });
      span.end();
      return new Response("ok");
    });
  },
});

찍힌 로그입니다.

결과
{"level":"info","message":"주문 조회 시작","trace_id":"9c98bf0842975ff42b6772eee402a837","span_id":"52522621ea587045","order.id":"55"}

이게 전부입니다. 로그 수집 경로는 기존 그대로 두고 필드 두 개만 늘린 건데, 추적과 로그를 오갈 수 있게 됐어요. 실제 프로젝트에서는 이 함수를 직접 만들기보다 쓰고 있는 로깅 라이브러리의 문맥 기능에 같은 값을 넣어 둡니다.

로그 브리지로 태우기

로그도 OTLP로 보내고 싶다면 로거 제공자(LoggerProvider)를 설정합니다. 앞서 본 추적, 지표 설정과 모양이 같습니다.

instrument.ts
import { BatchLogRecordProcessor, LoggerProvider } from "@opentelemetry/sdk-logs";
import { OTLPLogExporter } from "@opentelemetry/exporter-logs-otlp-http";
import { resourceFromAttributes } from "@opentelemetry/resources";
import { ATTR_SERVICE_NAME } from "@opentelemetry/semantic-conventions";
import { logs } from "@opentelemetry/api-logs";

const provider = new LoggerProvider({
  resource: resourceFromAttributes({ [ATTR_SERVICE_NAME]: "orders" }),
  processors: [new BatchLogRecordProcessor({ exporter: new OTLPLogExporter() })],
});

logs.setGlobalLoggerProvider(provider);

로그 API는 @opentelemetry/api가 아니라 @opentelemetry/api-logs에 있습니다. 로그가 나중에 합류한 신호라 패키지가 따로 떨어져 있어요.

server.ts
import { logs, SeverityNumber } from "@opentelemetry/api-logs";

const logger = logs.getLogger("orders");

logger.emit({
  severityNumber: SeverityNumber.INFO,
  severityText: "INFO",
  body: "주문 조회 시작",
  attributes: { "order.id": id },
});

수집기에 로그 파이프라인을 하나 더 만들고 요청을 보내면 이렇게 도착합니다.

결과
ResourceLog #0
Resource attributes:
     -> service.name: Str(orders)
     -> deployment.environment: Str(local)
LogRecord #0
ObservedTimestamp: 2026-09-27 20:51:06.598 +0000 UTC
SeverityText: INFO
SeverityNumber: Info(9)
Body: Str(주문 조회 시작)
Attributes:
     -> order.id: Str(55)
Trace ID: 9c98bf0842975ff42b6772eee402a837
Span ID: 52522621ea587045

마지막 두 줄을 봐 주세요. emit에 추적 ID를 넘긴 적이 없는데 붙어 있습니다. 로그 브리지가 활성 컨텍스트에서 알아서 꺼내 채워 주기 때문이에요. 앞 절에서 손으로 꺼내 넣던 걸 대신해 주는 셈이죠.

SeverityNumber도 눈여겨볼 만합니다. 라이브러리마다 warn과 warning, error와 fatal처럼 이름이 제각각인데, OpenTelemetry는 1부터 24까지의 숫자로 정규화해 둡니다. 어느 언어에서 온 로그든 같은 기준으로 걸러낼 수 있게 하려는 거예요.

어느 쪽을 고를까

둘 다 설명했으니 정리해 보죠.

flowchart TB
    accTitle: 로그를 내보내는 두 가지 경로
    accDescr: 기존 로깅 라이브러리로 찍은 로그에 trace_id를 넣어 기존 수집 경로로 보내는 방법과, 로그 브리지를 거쳐 OTLP로 수집기에 보내는 방법이 있다. 둘 다 추적 ID로 추적과 이어진다.

    app["애플리케이션 코드"] --> lib["기존 로깅 라이브러리"]

    lib -- "trace_id 필드 추가" --> file[("표준 출력, 파일<br/>기존 수집 경로")]
    lib -- "로그 브리지" --> otlp["OTLP"]
    otlp --> col["수집기"]

    file -.-> corr{{"추적 ID로<br/>추적과 연결"}}
    col -.-> corr

기존 수집 경로가 잘 돌아가고 있다면 ID만 붙이는 쪽으로 시작하는 게 좋습니다. 바꾸는 게 필드 두 개뿐이라 위험이 거의 없고, 원하던 효과의 대부분을 바로 얻거든요.

브리지로 넘어갈 이유는 따로 있습니다. 수집기 한곳에서 세 신호를 함께 다루고 싶을 때요. 수집기 정리에서 본 처리기가 로그에도 그대로 적용되니, 민감한 필드를 지우거나 공통 속성을 붙이는 걸 로그에도 같은 방식으로 할 수 있습니다. 로그 수집 체계를 새로 만드는 중이라면 처음부터 이쪽이 낫고요.

로거를 손으로 부르지 않기

앞의 예제는 logger.emit을 직접 불렀지만, 실제로는 이렇게 쓰지 않습니다. 브리지 API는 로깅 라이브러리를 만드는 쪽을 위한 것이고, 애플리케이션 코드는 원래 쓰던 로거를 계속 부르는 게 맞아요.

그래서 언어마다 연결 고리가 따로 제공됩니다. 파이썬은 표준 logging에 붙이는 핸들러가 있고, Node 쪽은 Pino나 Winston 같은 라이브러리에 물리는 전송기나 부가 기능이 있습니다. 붙여 두면 기존 코드에서 찍은 로그가 그대로 브리지를 타고 나가죠.

핵심은 브리지를 어디에 두든 애플리케이션 코드는 그대로라는 점입니다. OpenTelemetry 정리에서 본 API와 SDK를 나눈 원칙이 로그에서도 같은 모양으로 반복되는 셈이에요.

남는 문제는 양이다

로그는 세 신호 중 가장 비쌉니다. 요청마다 여러 줄이 쌓이고 각 줄이 자유 형식이라, 지표처럼 미리 집계되지도 추적처럼 샘플링되지도 않으면 저장 비용이 금방 커집니다.

줄이는 방법은 대체로 셋입니다. 우선 수준을 정리하는 거예요. 운영에서 디버그 로그를 켜 두면 양이 몇 배가 되는데, 정작 장애 때 필요한 정보는 추적에 있는 경우가 많습니다. 다음으로 추적과 겹치는 로그를 지웁니다. “주문 조회 시작”, “주문 조회 완료” 같은 줄은 스팬이 이미 담고 있는 내용이라 둘 다 남길 이유가 없어요. 마지막으로 반복되는 값은 메시지 문자열이 아니라 속성으로 넣습니다. 그래야 검색과 집계가 되고 압축도 잘 됩니다.

그러고도 남는 로그가 진짜 로그입니다. 미리 질문을 정하지 않은 상태에서 “그때 무슨 일이 있었나”에 답해야 하는 기록이요. 관측성 정리에서 이야기한 원시 이벤트의 자리이기도 하고요.

마치며

로그는 이미 자리 잡은 도구가 많아서 OpenTelemetry도 새 표준을 밀기보다 다리를 놓는 쪽을 택했습니다. 실익은 형식 통일이 아니라 추적과의 연결이고, trace.getActiveSpan()으로 ID 두 개만 붙여도 대부분을 얻을 수 있어요. 브리지를 쓰면 이 연결이 자동으로 채워지고 수집기의 처리기를 로그에도 쓸 수 있습니다.

이걸로 세 신호를 모두 계측했는데 아직 남은 질문이 있습니다. 속성 이름을 order.id라고 지었는데 이게 맞는 이름일까요? 다음 글에서는 이름을 정해 둔 규약을 다루며 시리즈를 마무리하겠습니다.

로그 브리지의 설계 배경은 OpenTelemetry 공식 문서의 Logs에 설명돼 있습니다.

This work is licensed under CC BY 4.0CCBY

개발자를 위한 뉴스레터

달레가 정리한 AI 개발 트렌드와 직접 만든 콘텐츠를 전해드립니다.

Discord