라이브러리나 공통 모듈을 만들다 보면 “이 함수가 언제 실행됐는지”, “외부 API 호출이 얼마나 걸렸는지”, “실패한 요청이 어떤 흐름에서 나왔는지”를 알고 싶어집니다. 가장 쉬운 방법은 코드 안에 console.log()를 넣는 것이지만, 라이브러리 내부 로그가 너무 많아지면 애플리케이션 사용자가 끄거나 바꾸기 어렵습니다. 반대로 관측 지점을 전혀 두지 않으면 운영 장애가 났을 때 원인을 찾기 힘듭니다.

Node.js의 diagnostics_channel은 이런 상황에서 쓸 수 있는 내장 관측 이벤트 채널 API입니다. 라이브러리는 특정 이름의 채널에 진단 메시지를 발행하고, 애플리케이션이나 모니터링 코드가 필요한 채널만 구독할 수 있습니다. 이 글에서는 Node.js diagnostics_channel 기본 사용법, 이벤트 이름 설계, 민감정보를 노출하지 않는 메시지 구성, 로그·메트릭으로 연결하는 실무 체크리스트를 정리합니다. 측정값 자체를 더 정확하게 만들고 싶다면 Node.js performance.now 가이드: 실행 시간을 정확하게 측정하는 법을 함께 참고하세요.

diagnostics_channel이 필요한 상황

H3. 라이브러리 코드와 로깅 정책을 분리한다

공통 라이브러리에서 직접 로그를 출력하면 사용하는 프로젝트마다 로그 형식, 레벨, 마스킹 기준을 맞추기 어렵습니다. diagnostics_channel을 쓰면 라이브러리는 “무슨 일이 일어났다”는 이벤트만 발행하고, 실제 로그 출력 여부는 구독하는 쪽에서 결정할 수 있습니다.

import diagnosticsChannel from 'node:diagnostics_channel';

const requestChannel = diagnosticsChannel.channel('app.http.request');

export async function fetchUser(userId) {
  requestChannel.publish({
    event: 'start',
    operation: 'fetchUser',
    userIdHash: hashForLog(userId),
  });

  // 실제 API 호출 또는 DB 조회 로직
}

이 구조에서는 라이브러리 내부에 특정 로거를 강하게 결합하지 않아도 됩니다. 애플리케이션은 운영 환경에서는 JSON 로그로 남기고, 로컬 개발 환경에서는 콘솔에 간단히 출력하는 식으로 정책을 바꿀 수 있습니다. 로그 예시를 공개 문서나 블로그에 넣을 때는 로그 예제 정제 가이드: 믿을 수 있는 개발 글을 위한 마스킹 원칙을 기준으로 실제 식별자를 제거하는 편이 안전합니다.

H3. 구독자가 있을 때만 비용이 큰 데이터를 만든다

진단 이벤트는 유용하지만, 모든 요청마다 큰 객체를 만들거나 스택 트레이스를 계산하면 성능 비용이 커질 수 있습니다. 이때 hasSubscribers를 확인하면 구독자가 있을 때만 메시지 생성 비용을 지불하도록 만들 수 있습니다.

import diagnosticsChannel from 'node:diagnostics_channel';

const queryChannel = diagnosticsChannel.channel('app.db.query');

export function publishQueryDiagnostics(query, durationMs) {
  if (!queryChannel.hasSubscribers) {
    return;
  }

  queryChannel.publish({
    operation: query.name,
    durationMs,
    rowCount: query.rowCount,
  });
}

단순한 객체 하나를 발행하는 정도라면 큰 문제가 아닐 수 있습니다. 하지만 요청 본문 정리, SQL 마스킹, 스택 생성, 큰 배열 요약처럼 비용이 있는 작업은 구독 여부를 먼저 확인하는 편이 좋습니다. 이런 작은 조건문 하나가 고트래픽 서비스에서는 불필요한 오버헤드를 줄여 줍니다.

기본 사용법

H3. 채널을 만들고 이벤트를 발행한다

diagnostics_channel.channel(name)은 이름이 같은 채널 객체를 반환합니다. 동일한 이름을 쓰면 발행자와 구독자가 같은 통로를 공유할 수 있습니다. 채널 이름은 충돌을 피하기 위해 프로젝트나 패키지 접두사를 붙이는 편이 좋습니다.

import diagnosticsChannel from 'node:diagnostics_channel';

const cacheChannel = diagnosticsChannel.channel('myapp.cache');

export function getFromCache(key) {
  const hit = memoryCache.has(key);

  cacheChannel.publish({
    event: hit ? 'hit' : 'miss',
    keyPrefix: String(key).slice(0, 12),
  });

  return hit ? memoryCache.get(key) : null;
}

메시지는 보통 평범한 객체로 보냅니다. 이 객체에는 원본 비밀번호, 토큰, 전체 개인정보, 큰 요청 본문을 넣지 않는 것이 원칙입니다. 운영 로그는 여러 시스템을 거쳐 오래 남을 수 있으므로, 진단 메시지도 처음부터 공개 가능성에 가까운 기준으로 설계해야 합니다.

H3. 필요한 채널만 구독한다

구독자는 diagnostics_channel.subscribe(name, callback)으로 특정 채널의 메시지를 받을 수 있습니다. 콜백은 메시지와 채널 이름을 받아 원하는 방식으로 처리합니다.

import diagnosticsChannel from 'node:diagnostics_channel';

function onCacheEvent(message, name) {
  console.log(JSON.stringify({
    channel: name,
    event: message.event,
    keyPrefix: message.keyPrefix,
  }));
}

diagnosticsChannel.subscribe('myapp.cache', onCacheEvent);

이 구독 코드는 애플리케이션 시작 지점이나 관측성 초기화 모듈에 두면 됩니다. 테스트에서는 구독자를 붙여 이벤트가 발행되는지 검증하고, 운영에서는 로거나 메트릭 수집기로 연결할 수 있습니다. 내장 테스트 러너를 함께 쓰는 방식은 Node.js test runner 가이드: 내장 테스트 도구로 빠르게 검증하는 법와 잘 맞습니다.

H3. 구독 해제까지 준비한다

테스트 코드나 플러그인 구조에서는 같은 프로세스 안에서 구독자를 붙였다가 제거해야 할 때가 있습니다. 이때는 같은 콜백 참조로 unsubscribe를 호출해야 합니다.

import diagnosticsChannel from 'node:diagnostics_channel';

function handler(message) {
  capturedEvents.push(message);
}

diagnosticsChannel.subscribe('myapp.cache', handler);

// 테스트 또는 플러그인 종료 시점
diagnosticsChannel.unsubscribe('myapp.cache', handler);

구독 해제를 빼먹으면 테스트 간 상태가 섞이거나, 같은 메시지가 여러 번 처리되는 문제가 생길 수 있습니다. 특히 watch 모드로 테스트를 반복 실행하거나 개발 서버를 오래 켜 두는 환경에서는 초기화와 정리 코드를 같이 두는 습관이 중요합니다. 개발 중 자동 재시작 흐름은 Node.js watch 모드 가이드: 파일 변경 시 개발 서버를 자동 재시작하는 법도 참고할 수 있습니다.

이벤트 이름과 메시지 설계

H3. 채널 이름은 계층형으로 정한다

채널 이름은 단순히 log, event, request처럼 넓게 잡기보다 책임을 드러내는 계층형 이름이 좋습니다. 예를 들어 myapp.http.client, myapp.db.query, myapp.cache처럼 영역을 나누면 필요한 구독만 선택하기 쉽습니다.

const channels = {
  httpClient: diagnosticsChannel.channel('myapp.http.client'),
  dbQuery: diagnosticsChannel.channel('myapp.db.query'),
  cache: diagnosticsChannel.channel('myapp.cache'),
};

팀 단위로 운영한다면 채널 이름 규칙을 문서화해 두는 편이 좋습니다. 패키지 이름, 도메인 이름, 이벤트 범위를 일관되게 맞추면 나중에 대시보드나 알림 규칙을 만들 때도 해석 비용이 줄어듭니다. 이름 규칙이 흔들리면 관측 이벤트가 쌓일수록 오히려 검색하기 어려운 로그가 됩니다.

H3. 메시지에는 원인 추적에 필요한 최소 필드만 넣는다

진단 메시지는 자세할수록 좋아 보이지만, 너무 많은 필드는 성능과 보안 리스크를 함께 키웁니다. 실무에서는 이벤트 종류, 작업 이름, duration, 상태 코드, 안전하게 요약한 식별자 정도부터 시작하는 편이 좋습니다.

httpClientChannel.publish({
  event: 'complete',
  operation: 'callPaymentApi',
  statusCode: response.status,
  durationMs,
  retryCount,
});

요청 헤더 전체, 원본 Authorization 값, 결제 정보, 주민등록번호 같은 민감정보는 절대 넣지 않아야 합니다. 식별이 꼭 필요하다면 원문 대신 해시, 내부 추적 ID, 앞뒤 일부만 남긴 마스킹 문자열을 사용하세요. 민감정보가 없는 구조를 기본값으로 만들면 나중에 구독자가 늘어나도 안전하게 확장할 수 있습니다.

로그와 메트릭으로 연결하기

H3. 로그 구독자는 실패 원인을 설명하는 데 집중한다

로그로 남길 때는 사람이 장애 상황에서 읽을 수 있어야 합니다. 단순히 메시지 객체를 통째로 출력하기보다, 채널 이름과 이벤트 종류, 핵심 필드를 정리해 일관된 형식으로 남기는 편이 좋습니다.

import diagnosticsChannel from 'node:diagnostics_channel';

const channelsToLog = [
  'myapp.http.client',
  'myapp.db.query',
  'myapp.cache',
];

for (const channelName of channelsToLog) {
  diagnosticsChannel.subscribe(channelName, (message, name) => {
    console.log(JSON.stringify({
      level: 'info',
      channel: name,
      event: message.event,
      operation: message.operation,
      durationMs: message.durationMs,
      statusCode: message.statusCode,
    }));
  });
}

이 예시는 설명을 위해 console.log()를 사용했지만, 운영에서는 팀이 쓰는 로거로 연결하는 편이 일반적입니다. 중요한 것은 발행자 쪽에 특정 로거를 강제하지 않고, 구독자 쪽에서 포맷과 레벨을 정한다는 점입니다. 이 분리가 되어 있으면 라이브러리 재사용성이 좋아지고 테스트도 단순해집니다.

H3. 메트릭은 집계 가능한 숫자로 만든다

모든 진단 이벤트를 로그로만 남기면 장기 추세를 보기 어렵습니다. 반응 시간, 실패 횟수, 캐시 적중률처럼 숫자로 집계할 수 있는 값은 메트릭 수집기로 보내는 편이 좋습니다.

diagnosticsChannel.subscribe('myapp.http.client', (message) => {
  if (message.event !== 'complete') return;

  metrics.histogram('http_client_duration_ms', message.durationMs, {
    operation: message.operation,
    status_family: `${Math.floor(message.statusCode / 100)}xx`,
  });
});

태그에는 고유 사용자 ID나 전체 URL처럼 값 종류가 무한히 늘어나는 데이터를 넣지 않는 것이 좋습니다. 카디널리티가 높아지면 메트릭 저장 비용이 커지고 대시보드가 느려질 수 있습니다. 대신 작업 이름, 상태 코드 범위, 성공·실패 여부처럼 집계 가능한 값으로 제한하세요.

테스트와 운영 체크리스트

H3. 이벤트 발행 여부를 테스트한다

중요한 관측 이벤트는 테스트로 고정해 두면 리팩터링 중 빠지는 일을 줄일 수 있습니다. 구독자를 테스트 안에서 붙이고, 함수 실행 후 기대한 메시지가 들어왔는지 확인하면 됩니다.

import assert from 'node:assert/strict';
import diagnosticsChannel from 'node:diagnostics_channel';
import { test } from 'node:test';
import { getFromCache } from './cache.js';

test('cache diagnostics are published', () => {
  const events = [];

  function handler(message) {
    events.push(message);
  }

  diagnosticsChannel.subscribe('myapp.cache', handler);
  try {
    getFromCache('profile:123');
    assert.equal(events.length, 1);
    assert.equal(events[0].event, 'miss');
  } finally {
    diagnosticsChannel.unsubscribe('myapp.cache', handler);
  }
});

테스트에서 중요한 부분은 finally로 구독 해제를 보장하는 것입니다. 테스트 실패 중간에 구독자가 남으면 다음 테스트가 같은 이벤트를 또 받아서 원인 모를 실패가 생길 수 있습니다. 작은 정리 코드가 테스트 안정성을 크게 높입니다.

H3. 운영 전 체크리스트를 통과시킨다

diagnostics_channel을 도입할 때는 코드보다 운영 규칙을 먼저 확인해야 합니다. 아래 항목을 통과하면 작은 서비스부터 안전하게 적용하기 좋습니다.

  • 채널 이름에 패키지나 서비스 접두사가 있는가?
  • 메시지에 토큰, 비밀번호, 원본 개인정보, 큰 요청 본문이 없는가?
  • 비용이 큰 메시지 생성은 hasSubscribers로 보호했는가?
  • 구독자는 로그 레벨과 출력 포맷을 일관되게 적용하는가?
  • 테스트나 플러그인에서 구독 해제가 보장되는가?
  • 메트릭 태그에 고카디널리티 값이 들어가지 않는가?

처음부터 모든 모듈에 적용하기보다 장애 분석이 자주 필요한 경계부터 시작하세요. 외부 API 클라이언트, DB 쿼리 래퍼, 캐시 계층, 큐 작업 처리기처럼 입출력과 시간이 중요한 지점이 좋은 후보입니다. 이벤트가 쌓인 뒤에는 실제 장애 대응에 도움이 된 필드만 남기고 나머지는 줄이는 업데이트 루프가 필요합니다.

자주 묻는 질문

H3. diagnostics_channel은 일반 이벤트 버스처럼 써도 되나요?

권장하지 않습니다. diagnostics_channel은 애플리케이션 기능 흐름을 연결하는 이벤트 버스라기보다 관측과 진단을 위한 통로에 가깝습니다. 비즈니스 로직이 이 채널 구독 여부에 의존하면 디버깅용 코드와 핵심 기능이 섞일 수 있습니다. 기능 이벤트가 필요하다면 명시적인 함수 호출, 큐, EventEmitter 같은 별도 설계를 사용하는 편이 낫습니다.

H3. 로그 라이브러리를 이미 쓰고 있어도 필요할까요?

필요한 경우가 있습니다. 로그 라이브러리는 “어떻게 출력할지”에 강하고, diagnostics_channel은 “라이브러리 내부의 관측 지점을 어떻게 외부에 열지”에 가깝습니다. 공통 패키지나 재사용 모듈을 만들고 있다면 진단 이벤트만 발행하고, 실제 로그 라이브러리는 애플리케이션 구독자에서 선택하는 구조가 더 유연합니다.

H3. 어떤 데이터부터 발행하면 좋나요?

외부 의존성과 성능 경계부터 시작하는 것을 권합니다. HTTP 클라이언트 요청 시작·완료·실패, DB 쿼리 소요 시간, 캐시 hit/miss, 큐 작업 처리 결과처럼 운영 중 원인 분석에 바로 도움이 되는 이벤트가 우선순위입니다. 각 메시지는 최소 필드로 시작하고, 실제 장애 분석에서 부족했던 정보만 신중하게 추가하세요.

함께 읽기