OpenTelemetry 정리: 계측 코드를 벤더에서 떼어 내기

OpenTelemetry 정리: 계측 코드를 벤더에서 떼어 내기

추적(trace)을 처음 붙일 때 Jaeger를 골랐다고 해 볼게요. Jaeger가 주는 라이브러리를 받아 서비스 곳곳에 스팬을 만드는 코드를 심었고, 잘 돌아갑니다. 그런데 반년 뒤 팀이 다른 백엔드로 옮기기로 합니다. 무엇을 다시 해야 할까요? 심어 둔 계측 코드 전부입니다. 😅

몇 년 전까지는 이게 기본값이었습니다. 관측 도구를 고르는 일이 곧 그 도구를 코드에 새겨 넣는 일이었고, 한번 새기면 되돌리기 어려웠죠. 지금은 다릅니다. 계측 코드는 그대로 두고 환경 변수 하나만 바꾸면 텔레메트리(telemetry)가 다른 곳으로 갑니다. 이 글에서는 그 차이를 만든 게 무엇인지, OpenTelemetry가 어떤 설계로 계측과 백엔드를 떼어 놓았는지 정리해 보겠습니다.

계측이 백엔드에 묶이면 생기는 일

지표, 로그, 추적 정리에서 세 신호를 다뤘는데요. 이 신호는 저절로 생기지 않습니다. 코드 어딘가에서 “여기서 스팬(span)을 시작하고 저기서 끝낸다”, “이 값을 세어 둔다”라고 적어 줘야 생기죠. 이렇게 관측 데이터를 만들도록 코드에 심는 작업을 계측(instrumentation)이라고 합니다.

문제는 이 계측 코드를 무엇으로 쓰느냐였습니다. Zipkin을 쓰면 Zipkin 라이브러리로, Jaeger를 쓰면 Jaeger 라이브러리로 써야 했어요. 스팬을 만드는 함수 이름부터 속성을 붙이는 방식까지 전부 달라서, 백엔드를 바꾸는 일이 곧 계측 코드를 갈아엎는 일이 됐습니다. 한번 고르면 사실상 못 바꾸는 선택이었던 거죠.

더 곤란한 쪽은 오픈소스 라이브러리를 만드는 사람이었습니다. HTTP 클라이언트나 데이터베이스 드라이버에 계측이 들어 있으면 쓰는 쪽에서 얼마나 편할까요. 그런데 라이브러리 저자는 사용자가 어떤 백엔드를 쓰는지 모릅니다. Zipkin 라이브러리에 의존하는 순간 Jaeger를 쓰는 사용자에게는 쓸모없는 의존성을 강요하는 셈이 되고요. 그래서 라이브러리 저자는 계측을 아예 넣지 않았고, 결과적으로 계측은 애플리케이션 코드에만 존재하고 그 아래층은 텅 비어 있었습니다.

표준을 만들려던 두 프로젝트

이 문제를 풀겠다고 나선 프로젝트가 둘이었습니다.

OpenTracing은 계측 API만 표준으로 정하자는 쪽이었습니다. 스팬을 시작하고 끝내는 인터페이스를 규격으로 못 박아 두면, 라이브러리 저자는 그 인터페이스에만 의존해 계측을 넣고 실제 구현은 사용자가 고르면 된다는 발상이었죠. 문제는 API만 정의했다는 데 있었습니다. 구현이 없으니 벤더마다 품질이 제각각이었고, 지표는 범위 밖이라 추적만 다뤘습니다.

OpenCensus는 Google에서 시작했고 반대로 갔습니다. API와 구현을 함께 제공하고 지표까지 다뤘어요. 대신 구현이 붙어 있다 보니 라이브러리에 넣기에는 무거웠습니다.

둘 다 같은 문제를 풀려고 만들어졌는데 각자로는 끝까지 풀지 못했다는 게 OpenTelemetry 공식 문서의 표현입니다. 그래서 2019년 5월, 두 프로젝트가 합쳐져 OpenTelemetry가 만들어집니다. 어느 한쪽이 흡수한 게 아니라 양쪽 모두의 다음 메이저 버전으로 자리를 잡았고, 그래서 지금도 두 프로젝트에서 넘어오는 마이그레이션 가이드가 공식 문서에 남아 있어요. 프로젝트는 CNCF(Cloud Native Computing Foundation)에 속해 있고 이름은 하이픈 없이 한 단어로 씁니다.

API와 SDK를 나눈 것이 전부다

합병으로 얻은 설계가 바로 이겁니다. 계측을 적는 층과 실제로 실행하는 층을 별도의 패키지로 갈라놓았어요.

API는 인터페이스만 정의합니다. “스팬을 시작한다”, “속성을 붙인다”, “끝낸다” 같은 동작의 모양만 있고 실제로 무슨 일을 할지는 비어 있어요. SDK가 그 구현입니다. 스팬을 실제로 만들고, 처리하고, 어디론가 내보내는 파이프라인이 전부 여기 들어 있죠.

여기에 규칙이 하나 붙습니다. 계측 코드는 API에만 의존하고, SDK는 애플리케이션이 시작할 때 딱 한 번 설정한다는 규칙이요.

flowchart TB
    accTitle: 계측 코드와 백엔드 사이에 API와 SDK가 놓인 구조
    accDescr: 애플리케이션 코드와 라이브러리는 OpenTelemetry API에만 의존하고, 애플리케이션 시작 시 한 번 설정하는 SDK가 그 구현을 제공하며, SDK가 OTLP로 백엔드에 내보낸다. 백엔드를 바꿔도 API에 의존하는 계측 코드는 그대로다.

    subgraph app ["계측하는 쪽"]
        direction TB
        code["애플리케이션 코드"]
        lib["라이브러리<br/>HTTP 클라이언트,<br/>DB 드라이버"]
    end

    api["OpenTelemetry API<br/>인터페이스만"]
    sdk["OpenTelemetry SDK<br/>시작할 때 한 번 설정"]
    backend[("백엔드<br/>Jaeger, Prometheus,<br/>상용 도구")]

    code --> api
    lib --> api
    api --> sdk
    sdk -- "OTLP" --> backend

    style app fill:none,stroke:#888,stroke-width:1px

이 그림에서 눈여겨볼 자리는 API와 SDK 사이입니다. 백엔드를 바꾸면 SDK 설정이 바뀌는데, 그 위의 계측 코드는 API만 보고 있으니 손댈 일이 없어요. 계측과 설정이 갈라진 이 이음매가 OpenTelemetry의 거의 전부입니다.

코드로 보면 더 분명하다

말로만 들으면 추상적이니 직접 돌려 보겠습니다. 예제는 Bun으로 띄울 거라 먼저 패키지부터 설치합니다.

bun add @opentelemetry/api @opentelemetry/sdk-trace-node @opentelemetry/exporter-trace-otlp-http @opentelemetry/resources @opentelemetry/semantic-conventions

SDK 설정부터 따로 떼어 둡니다. 어떤 스팬 처리기를 쓸지, 어디로 내보낼지, 서비스 이름을 뭐라고 할지가 전부 이 파일에 모입니다. 여기서는 결과를 눈으로 보려고 콘솔로 내보냈습니다.

instrument.ts
import {
  ConsoleSpanExporter,
  NodeTracerProvider,
  SimpleSpanProcessor,
} from "@opentelemetry/sdk-trace-node";
import { resourceFromAttributes } from "@opentelemetry/resources";
import { ATTR_SERVICE_NAME } from "@opentelemetry/semantic-conventions";

const provider = new NodeTracerProvider({
  // 이 프로세스가 무엇인지 알려주는 정보를 리소스라고 합니다
  resource: resourceFromAttributes({ [ATTR_SERVICE_NAME]: "greeting" }),
  // 스팬이 끝나는 대로 콘솔로 내보냅니다
  spanProcessors: [new SimpleSpanProcessor(new ConsoleSpanExporter())],
});

// 전역 API가 이 구현을 쓰도록 등록합니다
provider.register();

이제 서버 코드입니다. 여기서 가져다 쓰는 건 @opentelemetry/api 하나뿐이라는 점을 봐 주세요. 어느 백엔드로 갈지, 스팬을 언제 내보낼지는 이 파일 어디에도 없습니다.

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

const tracer = trace.getTracer("greeting");

Bun.serve({
  port: 3000,
  fetch(request) {
    return tracer.startActiveSpan("greet", (span) => {
      const name = new URL(request.url).searchParams.get("name") ?? "world";
      span.setAttribute("greeting.name", name);
      span.end();
      return new Response(`Hello, ${name}!`);
    });
  },
});

서버를 띄우고 요청을 한 번 보내 보겠습니다.

bun run server.ts
curl "http://localhost:3000/?name=Dale"

스팬이 콘솔에 찍힙니다.

결과
{
  resource: {
    attributes: {
      "service.name": "greeting",
    },
  },
  instrumentationScope: {
    name: "greeting",
    version: undefined,
    schemaUrl: undefined,
  },
  traceId: "a037edebaab29146fe965693b0e97774",
  parentSpanContext: undefined,
  traceState: undefined,
  name: "greet",
  id: "aaf9f7b392ccab46",
  kind: 0,
  timestamp: 1790539677179000,
  duration: 1999.459,
  attributes: {
    "greeting.name": "Dale",
  },
  status: {
    code: 0,
  },
  events: [],
  links: [],
}

traceId와 id가 보이는데 이게 추적과 스팬을 식별하는 값이고, parentSpanContext가 비어 있으니 이 스팬이 추적의 뿌리라는 뜻입니다. 스팬 하나하나를 어떻게 설계하는지는 다음 글의 주제라 여기서는 넘어가겠습니다.

SDK를 빼면 어떻게 될까

앞의 규칙이 진짜인지 확인하는 방법이 있습니다. server.ts에서 import "./instrument" 한 줄만 지우고 다시 돌려 보는 거예요.

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

같은 요청을 보내면 응답은 Hello, Dale! 그대로 돌아오고, 콘솔에는 아무것도 찍히지 않습니다. 오류도 나지 않아요. SDK가 등록되지 않으면 API가 아무 일도 하지 않는 빈 구현으로 동작하기 때문입니다.

이게 라이브러리 저자의 오랜 고민을 푼 지점입니다. 라이브러리가 API에만 의존해 계측을 넣어 두면, 그 라이브러리를 쓰는 사람이 OpenTelemetry를 설정했을 때만 데이터가 나오고 설정하지 않았으면 조용히 지나갑니다. 아무에게도 백엔드를 강요하지 않게 된 거죠. 요즘 HTTP 클라이언트나 데이터베이스 드라이버에 계측이 들어 있는 것도 이 구조 덕분입니다. 테스트에서 텔레메트리를 끄고 싶을 때 SDK만 빼면 된다는 것도 같은 성질이고요.

신호는 하나씩 자란다

OpenTelemetry는 추적, 지표, 로그를 한꺼번에 완성하지 않고 신호별로 따로 키워 왔습니다. 신호마다 API와 SDK, 그리고 뒤에서 이야기할 OTLP와 수집기(Collector)를 각각 갖추는 식이에요. 그 밑에 깔린 공통 바닥이 컨텍스트 전파(context propagation)인데, 서비스 경계를 넘어 추적을 이어 붙이는 장치라 여러 신호가 함께 기댑니다.

그래서 “OpenTelemetry가 안정적인가”라는 질문에는 하나로 답할 수 없습니다. 신호마다 다르고 언어마다도 다르거든요. 대체로 추적이 가장 먼저 자리를 잡았고 지표가 뒤를 이었으며 로그가 가장 늦습니다. 로그는 기존 로깅 라이브러리가 이미 자리를 잡고 있어서, 새 API를 미는 대신 Python 로깅이나 NestJS 로깅에서 쓰던 라이브러리의 출력을 받아 넘기는 다리를 놓는 쪽을 택했고요.

언어별 현재 상태는 공식 사이트의 계측 현황 표에 정리돼 있으니, 도입 전에 쓰는 언어의 줄을 확인해 보는 편이 좋습니다. 여기 숫자를 옮겨 적어 두면 금세 낡으니까요.

OTLP는 무엇을 어디로 보내나

이제 도입부의 환경 변수로 돌아갈 차례입니다.

계측 데이터를 백엔드로 보내려면 형식과 전송 방법을 정해야 하는데, 이걸 규격으로 만든 게 OTLP(OpenTelemetry Protocol)입니다. gRPC와 HTTP 두 가지 방식이 있고 기본 포트는 각각 4317과 4318이에요. 신호마다 경로가 나뉘어서 HTTP로 보낸다면 추적은 /v1/traces, 지표는 /v1/metrics, 로그는 /v1/logs로 갑니다.

받는 쪽이 OTLP를 알아듣기만 하면 되니, 보내는 쪽이 신경 쓸 건 주소 하나뿐입니다.

OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318 bun run server.ts

앞서 instrument.ts에서 콘솔로 보내던 익스포터(exporter)를 OTLP용으로 바꾸면 이 변수 하나로 목적지가 정해집니다. 도입부의 이사가 여기서 끝나는 셈이에요. Jaeger로 보내던 걸 다른 백엔드로 돌리려면 주소만 바꾸면 되고, 스팬을 만드는 코드는 한 줄도 건드리지 않습니다. 중간에 수집기를 두고 거기서 여러 백엔드로 나눠 보내는 구성이 흔하지만, 그게 필수는 아니고 SDK에서 백엔드로 바로 보내도 됩니다.

OpenTelemetry가 하지 않는 일

마지막으로 경계를 그어 두는 게 좋겠습니다. OpenTelemetry는 백엔드가 아닙니다. 데이터를 저장하지도, 검색해 주지도, 대시보드를 그려 주지도 않아요. 신호를 만들고 내보내는 데까지가 범위입니다.

그래서 관측성 정리에서 다룬 이야기 중 상당 부분은 여전히 OpenTelemetry 밖에 있습니다. 어떤 조건에서 알림을 울릴지, SLO를 몇 퍼센트로 잡을지는 데이터를 받아 보관하는 쪽이 정하는 일이거든요. OpenTelemetry는 그 위층이 무엇이 되든 같은 방식으로 계측할 수 있게 해 주는 아래층입니다. 둘을 섞어 놓고 “OpenTelemetry를 도입하면 관측성이 생긴다”고 기대하면 도입 후에 허전해집니다.

마치며

계측이 백엔드 라이브러리에 직접 묶여 있던 시절에는 백엔드를 바꾸는 일이 곧 코드를 다시 쓰는 일이었고, 라이브러리 저자는 계측을 넣을 방법이 없었습니다. OpenTracing과 OpenCensus가 각자 절반씩 풀던 이 문제를 OpenTelemetry가 API와 SDK를 나누는 방식으로 마무리했고, 그 덕에 계측 코드는 인터페이스만 보고 목적지는 시작 지점의 설정 한 곳에 모이게 됐어요. SDK를 빼면 같은 코드가 조용히 아무 일도 하지 않는다는 점까지 확인해 봤고요.

다음 글에서는 스팬을 직접 만들어 보려고 합니다. 오늘은 greet 하나만 찍고 끝냈지만, 실제로는 스팬을 어느 단위로 나눌지와 부모 자식 관계를 어떻게 잡을지가 추적의 쓸모를 가릅니다.

프로젝트의 배경과 범위는 OpenTelemetry 공식 문서의 What is OpenTelemetry에 잘 정리돼 있으니 함께 읽어 보시면 좋겠습니다.

This work is licensed under CC BY 4.0CCBY

개발자를 위한 뉴스레터

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

Discord