Node.js 애플리케이션에서 관측성을 높이려면 로그, 메트릭, 트레이싱 코드를 곳곳에 넣게 됩니다. 문제는 계측 코드가 비즈니스 로직 안으로 너무 깊게 들어가면 유지보수가 어려워진다는 점입니다. 라이브러리나 공통 모듈이 특정 로거, APM SDK, 메트릭 클라이언트에 직접 의존하면 작은 변경도 여러 계층에 영향을 줍니다.

node:diagnostics_channel은 이런 결합도를 줄이기 위한 Node.js 내장 모듈입니다. Node.js 공식 문서에 따르면 이 모듈은 진단 목적의 메시지 데이터를 이름 있는 채널로 보고하기 위한 API이며, 모듈 작성자가 사용할 채널 이름과 메시지 형태를 문서화하는 것이 권장됩니다. 이 글에서는 diagnostics_channel을 공통 HTTP 클라이언트, 큐 작업, 내부 SDK에 적용할 때의 설계 기준을 정리합니다.

요청 단위 컨텍스트를 함께 전파하려면 Node.js AsyncLocalStorage 요청 컨텍스트 로깅 가이드를 먼저 보면 좋습니다. 로그 저장량과 민감정보 정책은 Node.js 로그 샘플링 가이드와 같이 설계하는 편이 안전합니다. 컨텍스트 기본값까지 정리하고 싶다면 Node.js AsyncLocalStorage defaultValue·name 가이드를 함께 참고하세요.

diagnostics_channel이 필요한 순간

H3. 공통 모듈이 특정 관측성 도구에 묶이지 않게 한다

예를 들어 사내 공통 HTTP 클라이언트가 있다고 가정해 보겠습니다. 처음에는 요청 시간만 로그로 남기면 충분해 보이지만, 시간이 지나면 메트릭, 분산 추적, 샘플링된 디버그 로그, 장애 재현용 이벤트가 추가됩니다. 이 모든 것을 HTTP 클라이언트 안에서 직접 처리하면 모듈이 빠르게 무거워집니다.

diagnostics_channel을 사용하면 공통 모듈은 “무슨 일이 일어났는지”만 발행하고, 실제 소비자는 애플리케이션 진입점에서 따로 구독할 수 있습니다.

import diagnosticsChannel from 'node:diagnostics_channel';

const requestStartChannel = diagnosticsChannel.channel('app.http-client.request.start');
const requestEndChannel = diagnosticsChannel.channel('app.http-client.request.end');

export async function requestJson(url, options = {}) {
  const start = performance.now();
  const method = options.method ?? 'GET';

  requestStartChannel.publish({
    method,
    host: new URL(url).host,
    startedAt: Date.now()
  });

  try {
    const response = await fetch(url, options);

    requestEndChannel.publish({
      method,
      host: new URL(url).host,
      statusCode: response.status,
      elapsedMs: Math.round(performance.now() - start)
    });

    return response.json();
  } catch (error) {
    requestEndChannel.publish({
      method,
      host: new URL(url).host,
      statusCode: null,
      elapsedMs: Math.round(performance.now() - start),
      errorName: error.name
    });

    throw error;
  }
}

이 구조의 핵심은 HTTP 클라이언트가 로거나 APM SDK를 import하지 않는다는 점입니다. 발행자는 안정적인 이벤트를 내보내고, 구독자는 운영 환경에 맞게 로그·메트릭·트레이싱으로 변환합니다.

H3. 라이브러리 사용자에게 선택권을 준다

공통 패키지를 여러 서비스에서 공유한다면 모든 서비스가 같은 관측성 정책을 쓰지는 않습니다. 어떤 서비스는 요청 지연 시간만 메트릭으로 집계하고, 어떤 서비스는 오류 이벤트만 로그로 남기며, 어떤 서비스는 OpenTelemetry 계측과 연결할 수 있습니다.

diagnostics_channel은 구독자가 없을 때도 발행 API를 호출할 수 있으므로 패키지 기본 동작을 크게 바꾸지 않고 관측성 확장 지점을 제공할 수 있습니다. 다만 메시지를 만들기 위해 비용이 큰 계산을 해야 한다면 hasSubscribers를 함께 사용하는 편이 좋습니다.

채널 이름과 메시지 스키마 설계

H3. 채널 이름은 충돌을 피하고 목적을 드러낸다

채널 이름은 전역 문자열처럼 동작합니다. 따라서 너무 짧은 이름을 쓰면 다른 패키지와 충돌하거나 의미가 불명확해질 수 있습니다. 팀이나 패키지 접두어를 포함하고, 도메인과 이벤트 시점을 드러내는 방식이 관리하기 좋습니다.

const channels = {
  start: diagnosticsChannel.channel('app.queue.worker.job.start'),
  end: diagnosticsChannel.channel('app.queue.worker.job.end'),
  error: diagnosticsChannel.channel('app.queue.worker.job.error')
};

이름은 한 번 공개하면 바꾸기 어렵습니다. 처음부터 start, end, error처럼 이벤트 시점을 나누고, 메시지 형태를 README나 내부 문서에 남겨 두면 소비자가 안정적으로 구독할 수 있습니다.

H3. 메시지는 작고 안전한 요약으로 제한한다

진단 이벤트라고 해서 원문 요청, 전체 옵션, 사용자 입력, 토큰을 그대로 담으면 안 됩니다. 채널 메시지도 결국 로그나 외부 관측성 도구로 흘러갈 수 있기 때문입니다.

function buildJobMessage(job, extra = {}) {
  return {
    jobName: job.name,
    queueName: job.queueName,
    attempt: job.attempt,
    jobIdHash: hashForDiagnostics(job.id),
    ...extra
  };
}

좋은 메시지는 문제를 좁히는 데 필요한 최소 필드만 담습니다. 큐 이름, 작업 이름, 상태 코드, 지연 시간, 재시도 횟수처럼 분석 가능한 값은 유용합니다. 반대로 이메일, 전화번호, 세션 쿠키, 원문 payload, 외부 API 토큰은 넣지 않습니다.

H3. 스키마 변경은 추가 중심으로 관리한다

구독자는 메시지 필드에 의존합니다. 따라서 기존 필드를 제거하거나 의미를 바꾸면 운영 대시보드, 알림, 테스트가 깨질 수 있습니다.

변경이 필요하다면 새 필드를 추가하고 구독자 전환 기간을 둡니다. 예를 들어 duration이라는 모호한 필드가 있었다면 바로 없애기보다 elapsedMs를 추가하고, 일정 기간 뒤에 duration을 제거하는 식이 안전합니다.

성능 민감 코드에서의 발행 패턴

H3. 구독자가 있을 때만 비싼 메시지를 만든다

publish() 자체는 단순하지만, 메시지를 만들기 위해 URL 파싱, 객체 복사, 스택 생성, 큰 배열 요약 같은 작업을 하면 비용이 생깁니다. 성능 민감 경로에서는 구독자가 있는지 먼저 확인합니다.

import diagnosticsChannel from 'node:diagnostics_channel';

const cacheHitChannel = diagnosticsChannel.channel('app.cache.hit');

export function getFromCache(cache, key) {
  const value = cache.get(key);

  if (cacheHitChannel.hasSubscribers) {
    cacheHitChannel.publish({
      cacheName: cache.name,
      keyHash: hashForDiagnostics(key),
      hit: value !== undefined
    });
  }

  return value;
}

이 패턴은 고빈도 함수에서 특히 중요합니다. 구독자가 없는 운영 환경에서는 계측 비용을 낮게 유지하고, 장애 분석이나 실험 환경에서는 구독자를 붙여 더 많은 정보를 볼 수 있습니다.

H3. 구독자 코드가 발행자를 느리게 만들 수 있음을 기억한다

구독자는 메시지를 받는 쪽이지만 같은 프로세스 안에서 실행됩니다. 구독자 함수에서 동기 파일 쓰기, 무거운 직렬화, 네트워크 호출을 직접 수행하면 발행 경로가 느려질 수 있습니다.

diagnosticsChannel.subscribe('app.http-client.request.end', (message) => {
  metrics.histogram('http_client_elapsed_ms', message.elapsedMs, {
    host: message.host,
    method: message.method,
    statusCode: String(message.statusCode ?? 'error')
  });
});

구독자에서는 가벼운 변환과 비동기 전송 큐에 넘기는 정도로 제한하는 편이 좋습니다. 오류가 발생해도 애플리케이션의 핵심 흐름을 방해하지 않도록 구독자 내부 예외 처리도 분리합니다.

tracingChannel을 쓰는 경우

H3. 시작·종료·오류 흐름이 한 묶음이면 tracingChannel을 고려한다

단일 이벤트 발행에는 channel()이 충분합니다. 하지만 작업의 시작, 비동기 흐름, 종료, 오류를 하나의 계측 단위로 다루고 싶다면 tracingChannel()이 더 적합할 수 있습니다. Node.js 공식 문서의 진단 채널 API는 tracing channel을 통해 시작·종료·비동기 시작·비동기 종료·오류 이벤트를 묶어 다룰 수 있게 합니다.

import diagnosticsChannel from 'node:diagnostics_channel';

const queryTrace = diagnosticsChannel.tracingChannel('app.db.query');

export async function runQuery(sql, params) {
  return queryTrace.tracePromise(
    async () => database.query(sql, params),
    {
      statementName: getStatementName(sql),
      paramCount: params.length
    }
  );
}

이 예제에서도 SQL 원문이나 파라미터 값을 그대로 넣지 않는 것이 중요합니다. 쿼리 이름, 파라미터 개수, 테이블 그룹처럼 안전한 요약값만 남겨야 합니다.

H3. AsyncLocalStorage와 함께 쓰면 상관관계가 좋아진다

진단 이벤트는 단독으로도 유용하지만 요청 ID나 trace ID와 함께 보면 훨씬 강해집니다. 구독자에서 AsyncLocalStorage의 현재 컨텍스트를 읽어 메시지에 결합하면 발행자는 요청 컨텍스트를 몰라도 됩니다.

diagnosticsChannel.subscribe('app.queue.worker.job.error', (message) => {
  const context = requestContext.getStore();

  logger.error({
    requestId: context.requestId,
    traceId: context.traceId,
    jobName: message.jobName,
    queueName: message.queueName,
    attempt: message.attempt,
    errorName: message.errorName
  }, 'queue job failed');
});

이렇게 하면 공통 큐 모듈은 컨텍스트 저장소에 직접 의존하지 않고, 애플리케이션 레벨 구독자가 로그 정책을 결정할 수 있습니다. 발행자와 소비자 사이의 결합을 낮추는 것이 diagnostics_channel을 도입하는 가장 큰 이유입니다.

운영 체크리스트

H3. 도입 전 확인할 것

diagnostics_channel을 추가하기 전에는 다음 기준을 먼저 정리합니다.

  1. 채널 이름에 팀·패키지·도메인 접두어가 있는가?
  2. start, end, error 같은 이벤트 시점이 명확한가?
  3. 메시지 스키마가 문서화되어 있는가?
  4. 원문 payload, 토큰, 개인정보가 들어가지 않는가?
  5. 고빈도 경로에서 hasSubscribers로 비싼 메시지 생성을 피하는가?
  6. 구독자 코드가 발행 경로를 느리게 만들지 않는가?

이 체크리스트를 코드 리뷰 기준으로 두면 계측 코드가 무질서하게 늘어나는 것을 막을 수 있습니다.

H3. 테스트에서는 발행 여부와 메시지 형태를 검증한다

진단 이벤트는 운영 도구와 연결되는 계약입니다. 따라서 핵심 공통 모듈에서는 이벤트가 발행되는지, 메시지에 필요한 필드가 있는지 테스트하는 편이 좋습니다.

import assert from 'node:assert/strict';
import diagnosticsChannel from 'node:diagnostics_channel';
import { test } from 'node:test';
import { requestJson } from './http-client.js';

test('publishes http client end diagnostics', async () => {
  const messages = [];

  function onMessage(message) {
    messages.push(message);
  }

  diagnosticsChannel.subscribe('app.http-client.request.end', onMessage);

  try {
    await requestJson('https://example.test/health');
  } finally {
    diagnosticsChannel.unsubscribe('app.http-client.request.end', onMessage);
  }

  assert.equal(messages.length, 1);
  assert.equal(messages[0].method, 'GET');
  assert.equal(typeof messages[0].elapsedMs, 'number');
});

테스트에서는 구독 해제를 반드시 처리합니다. 테스트 파일 여러 개가 같은 프로세스에서 실행될 때 구독자가 남아 있으면 다른 테스트 결과에 영향을 줄 수 있습니다.

마무리

node:diagnostics_channel은 로그를 더 많이 찍기 위한 도구가 아니라, 계측 지점을 더 느슨하게 설계하기 위한 도구입니다. 공통 모듈은 안전한 진단 이벤트를 발행하고, 애플리케이션은 필요한 채널만 구독해 로그·메트릭·트레이싱으로 연결합니다.

실무에서는 채널 이름, 메시지 스키마, 민감정보 제한, 구독자 성능을 함께 봐야 합니다. 이 네 가지를 지키면 관측성 코드를 늘리면서도 비즈니스 로직의 결합도와 운영 리스크를 낮출 수 있습니다.