본문으로 이동
·16분 읽기

Node.js 옵저버빌리티: 복잡함 없는 로그, 메트릭, 트레이스

Pino로 구조화된 로깅, Prometheus로 메트릭, OpenTelemetry로 분산 트레이싱. 제로부터 완전한 가시성까지, Node.js 프로덕션 앱을 위한 옵저버빌리티 스택.

공유:X / TwitterLinkedIn

예전에 저는 옵저버빌리티가 "console.log 몇 개 넣고 뭔가 고장나면 확인하는 것"이라고 생각했습니다. 그것은 더 이상 통하지 않을 때까지 통했습니다. 전환점은 API가 200을 반환하는데 데이터가 오래된 프로덕션 사고였습니다. 로그에 오류 없음. 예외 없음. 다운스트림 캐시가 오래되었는데 아무도 4시간 동안 눈치채지 못해서 조용히 잘못된 응답만 보내고 있었습니다.

그때 모니터링옵저버빌리티의 차이를 배웠습니다. 모니터링은 뭔가 잘못되었다고 알려줍니다. 옵저버빌리티는 잘못되었는지를 알려줍니다. 그리고 이 둘 사이의 격차가 프로덕션 사고가 사는 곳입니다.

이것은 대부분의 대안을 시도한 후 Node.js 서비스에 정착한 옵저버빌리티 스택입니다. 세상에서 가장 정교한 설정은 아니지만, 사용자가 알아차리기 전에 문제를 잡아내고, 무언가 빠져나갔을 때 몇 시간이 아닌 몇 분 안에 진단할 수 있습니다.

세 기둥, 그리고 왜 세 개 모두 필요한가#

모두가 "옵저버빌리티의 세 기둥" — 로그, 메트릭, 트레이스를 이야기합니다. 아무도 말해주지 않는 것은 각 기둥이 근본적으로 다른 질문에 답한다는 것이고, 단일 기둥으로는 모든 질문에 답할 수 없기 때문에 세 개 모두 필요합니다.

로그가 답하는 것: 무슨 일이 일어났는가?

로그 라인은 "14:23:07에 사용자 4821이 /api/orders를 요청했고 데이터베이스 연결 타임아웃으로 500을 받았다"고 말합니다. 서사입니다. 하나의 특정 이벤트에 대한 이야기를 들려줍니다.

메트릭이 답하는 것: 얼마나 일어나고 있는가?

메트릭은 "지난 5분간 p99 응답 시간은 2.3초이고 오류율은 4.7%였다"고 말합니다. 집계 데이터입니다. 개별 요청이 아닌 시스템 전체의 건강 상태를 알려줍니다.

트레이스가 답하는 것: 시간이 어디로 갔는가?

트레이스는 "이 요청이 Express 미들웨어에서 12ms, 본문 파싱에 3ms, PostgreSQL 대기에 847ms, 응답 직렬화에 2ms를 소비했다"고 말합니다. 워터폴입니다. 서비스 경계를 넘어 병목이 정확히 어디인지를 알려줍니다.

실질적인 함의는 이렇습니다: 새벽 3시에 호출기가 울릴 때, 순서는 거의 항상 같습니다.

  1. 메트릭이 뭔가 잘못되었다고 알려줍니다 (오류율 급증, 지연 시간 증가)
  2. 로그가 무슨 일이 일어나고 있는지 알려줍니다 (구체적인 오류 메시지, 영향 받는 엔드포인트)
  3. 트레이스가 왜인지 알려줍니다 (어떤 다운스트림 서비스나 데이터베이스 쿼리가 병목인지)

로그만 있으면 무엇이 고장났는지는 알지만 얼마나 심각한지는 모릅니다. 메트릭만 있으면 얼마나 심각한지는 알지만 무엇이 원인인지는 모릅니다. 트레이스만 있으면 아름다운 워터폴은 있지만 언제 봐야 하는지 알 방법이 없습니다.

각각을 구축해 봅시다.

Pino를 사용한 구조화된 로깅#

console.log이 충분하지 않은 이유#

알고 있습니다. 프로덕션에서 console.log를 사용해 왔고 "괜찮다"고요. 왜 그렇지 않은지 보여드리겠습니다.

typescript
// 여러분이 작성하는 것
console.log("User login failed", email, error.message);
 
// 로그 파일에 나오는 것
// User login failed john@example.com ECONNREFUSED
 
// 이제 이것을 시도해 보세요:
// 1. 지난 1시간 동안의 모든 로그인 실패를 검색
// 2. 사용자별 실패 횟수 집계
// 3. ECONNREFUSED 오류만 필터링
// 4. 이것을 트리거한 요청과 상관관계 파악
// 행운을 빕니다. 구조화되지 않은 문자열입니다. 텍스트를 grep하고 있는 겁니다.

구조화된 로깅은 모든 로그 항목이 일관된 필드를 가진 JSON 객체라는 의미입니다. 기계에 적대적인 사람이 읽을 수 있는 문자열 대신, (올바른 도구를 사용하면) 사람도 읽을 수 있는 기계가 읽을 수 있는 객체를 얻습니다.

typescript
// 구조화된 로깅이 어떤 모습인지
{
  "level": 50,
  "time": 1709312587000,
  "msg": "User login failed",
  "email": "john@example.com",
  "error": "ECONNREFUSED",
  "requestId": "req-abc-123",
  "route": "POST /api/auth/login",
  "responseTime": 1247,
  "pid": 12345
}

이제 이것을 쿼리할 수 있습니다. level >= 50 AND msg = "User login failed" AND time > now() - 1h로 정확히 필요한 것을 얻습니다.

Pino vs Winston#

둘 다 광범위하게 사용해 봤습니다. 간단히 말하면:

Winston은 더 인기 있고, 더 유연하고, 더 많은 트랜스포트가 있으며, 상당히 더 느립니다. 또한 나쁜 패턴을 조장합니다 — "format" 시스템이 개발 환경에서는 보기 좋지만 프로덕션에서는 파싱할 수 없는 구조화되지 않은 예쁜 인쇄 로그를 너무 쉽게 만들 수 있게 합니다.

Pino는 더 빠르고 (벤치마크에서 5-10배), JSON 출력에 대한 확고한 견해를 가지며, Unix 철학을 따릅니다: 한 가지를 잘 하고 (stdout에 JSON 쓰기) 나머지는 다른 도구에 맡기기 (전송, 포맷팅, 집계).

저는 Pino를 사용합니다. 초당 수천 개의 요청을 로깅할 때 성능 차이가 중요하고, 확고한 접근 방식은 팀의 모든 개발자가 일관된 로그를 생산한다는 것을 의미합니다.

기본 Pino 설정#

typescript
// src/lib/logger.ts
import pino from "pino";
 
const isProduction = process.env.NODE_ENV === "production";
 
export const logger = pino({
  level: process.env.LOG_LEVEL || (isProduction ? "info" : "debug"),
  // 프로덕션에서는 stdout에 JSON만. PM2/컨테이너 런타임이 나머지를 처리.
  // 개발 환경에서는 사람이 읽을 수 있는 출력을 위해 pino-pretty 사용.
  ...(isProduction
    ? {}
    : {
        transport: {
          target: "pino-pretty",
          options: {
            colorize: true,
            translateTime: "HH:MM:ss",
            ignore: "pid,hostname",
          },
        },
      }),
  // 모든 로그 라인의 표준 필드
  base: {
    service: process.env.SERVICE_NAME || "api",
    version: process.env.APP_VERSION || "unknown",
  },
  // Error 객체를 올바르게 직렬화
  serializers: {
    err: pino.stdSerializers.err,
    error: pino.stdSerializers.err,
    req: pino.stdSerializers.req,
    res: pino.stdSerializers.res,
  },
  // 민감한 필드 수정
  redact: {
    paths: [
      "req.headers.authorization",
      "req.headers.cookie",
      "password",
      "creditCard",
      "ssn",
    ],
    censor: "[REDACTED]",
  },
});

redact 옵션은 중요합니다. 이것 없이는 결국 비밀번호나 API 키를 로깅하게 됩니다. 만약이 아니라 언제의 문제입니다. 어떤 개발자가 logger.info({ body: req.body }, "incoming request")를 추가하면 갑자기 신용카드 번호를 로깅하게 됩니다. 수정은 안전망입니다.

로그 레벨: 제대로 사용하기#

typescript
// FATAL (60) - 프로세스가 곧 크래시할 것. 누군가를 깨워라.
logger.fatal({ err }, "Unrecoverable database connection failure");
 
// ERROR (50) - 발생하지 않아야 할 것이 실패함. 곧 조사하라.
logger.error({ err, userId, orderId }, "Payment processing failed");
 
// WARN (40) - 예상치 못했지만 처리됨. 주시하라.
logger.warn({ retryCount: 3, service: "email" }, "Retry limit approaching");
 
// INFO (30) - 기록할 가치가 있는 정상 운영. "무슨 일이 일어났나" 로그.
logger.info({ userId, action: "login" }, "User authenticated");
 
// DEBUG (20) - 디버깅을 위한 상세 정보. 절대 프로덕션에서 사용하지 않음.
logger.debug({ query, params }, "Database query executing");
 
// TRACE (10) - 극도로 상세. 정말 절박할 때만.
logger.trace({ headers: req.headers }, "Incoming request headers");

규칙: INFO와 DEBUG 사이에서 고민한다면 DEBUG입니다. WARN과 ERROR 사이에서 고민한다면 자문하세요: "새벽 3시에 이것에 대해 알림을 받고 싶은가?" 그렇다면 ERROR. 아니라면 WARN.

자식 로거와 요청 컨텍스트#

여기서 Pino가 진짜 빛납니다. 자식 로거는 부모의 모든 설정을 상속하지만 추가 컨텍스트 필드를 더합니다.

typescript
// 이 자식 로거의 모든 로그에는 userId와 sessionId가 포함됩니다
const userLogger = logger.child({ userId: "usr_4821", sessionId: "ses_xyz" });
 
userLogger.info("User viewed dashboard");
// 출력에 userId와 sessionId가 자동 포함
 
userLogger.info({ page: "/settings" }, "User navigated");
// 출력에 userId, sessionId, 그리고 page가 포함

HTTP 서버의 경우, 요청 수명 주기의 모든 로그 라인에 요청 ID가 포함되도록 요청당 자식 로거가 필요합니다:

typescript
// src/middleware/request-logger.ts
import { randomUUID } from "node:crypto";
import { logger } from "../lib/logger";
import type { Request, Response, NextFunction } from "express";
 
export function requestLogger(req: Request, res: Response, next: NextFunction) {
  const requestId = req.headers["x-request-id"]?.toString() || randomUUID();
  const startTime = performance.now();
 
  // 요청에 자식 로거 연결
  req.log = logger.child({
    requestId,
    method: req.method,
    path: req.url,
  });
 
  // 응답이 완료되면 로깅
  res.on("finish", () => {
    const duration = Math.round(performance.now() - startTime);
 
    req.log.info(
      {
        statusCode: res.statusCode,
        duration,
        contentLength: res.getHeader("content-length"),
      },
      "Request completed"
    );
  });
 
  // 응답 헤더에 요청 ID 설정 (디버깅에 유용)
  res.setHeader("x-request-id", requestId);
 
  next();
}

이제 요청 수명 주기 어디에서든 로깅하면 자동으로 요청 ID가 포함됩니다:

typescript
// 코드 어딘가의 서비스
export async function getOrder(orderId: string, req: Request) {
  req.log.info({ orderId }, "Fetching order");
 
  const order = await db.orders.findById(orderId);
 
  if (!order) {
    req.log.warn({ orderId }, "Order not found");
    return null;
  }
 
  req.log.info({ orderId, status: order.status }, "Order retrieved");
  return order;
}

모든 로그 라인에 requestId, method, path가 포함되어 있어 단일 요청의 모든 로그를 필터링할 수 있습니다. 이것은 프로덕션 디버깅에서 엄청난 변화입니다.

Express에서의 Pino HTTP 미들웨어#

위의 커스텀 미들웨어 대신 pino-http를 사용할 수도 있습니다. 요청/응답 로깅을 자동으로 처리합니다:

typescript
import pinoHttp from "pino-http";
import { logger } from "./lib/logger";
 
app.use(
  pinoHttp({
    logger,
    // 노이즈가 많은 경로 커스터마이즈
    autoLogging: {
      ignore: (req) => req.url === "/health" || req.url === "/ready",
    },
    // 쿼리 문자열이나 헤더 같은 추가 컨텍스트
    customProps: (req) => ({
      userAgent: req.headers["user-agent"],
    }),
    // 상태 코드에 따른 커스텀 로그 레벨
    customLogLevel: (req, res, error) => {
      if (res.statusCode >= 500 || error) return "error";
      if (res.statusCode >= 400) return "warn";
      return "info";
    },
  })
);

autoLogging.ignore 부분이 중요합니다. 이것 없이는 헬스체크 엔드포인트가 매 5초마다 로그 라인을 생성해서 로그를 쓰레기로 채웁니다.

로그 전송: 어디로 보내야 하나#

Pino는 stdout에 JSON을 씁니다. 그러면 어디로 가나요?

프로덕션 파이프라인:

앱 (Pino → stdout) → PM2/Docker → 로그 파일 → Filebeat/Fluentd → Elasticsearch/Loki

또는 컨테이너를 사용한다면:

앱 (Pino → stdout) → Docker 로그 드라이버 → CloudWatch/Datadog/Loki

핵심 원칙: 앱에서 로그를 전송하지 마세요. 앱은 stdout에 쓰기만 하고, 로그 수집기에게 나머지를 맡기세요. 이것은 앱을 간단하게 유지하고, 로그 대상을 코드 변경 없이 바꿀 수 있게 해주며, 로그 전송 실패가 앱에 영향을 주지 않도록 합니다.

개발 환경에서는 pino-pretty를 직접 사용합니다:

bash
npm install -D pino-pretty
node server.js | pino-pretty

또는 위 설정처럼 개발 환경에서 Pino의 내장 트랜스포트를 사용합니다.

Prometheus를 사용한 메트릭#

메트릭이 로그보다 나은 점#

로그는 이벤트입니다. 메트릭은 집계입니다. 이 구분이 중요합니다.

로그로 "지난 5분간 오류율은 얼마인가?"를 답하려면 모든 로그 라인을 쿼리하고, 집계하고, 시간 범위를 계산해야 합니다. 카운터 메트릭으로는 그냥 rate(http_errors_total[5m])입니다. 더 빠르고, 더 저렴하며, 실시간입니다.

로그로 답하기 어려운 질문들:

  • 오류율이 정상보다 높은가?
  • p99 지연 시간은 얼마인가?
  • 초당 몇 개의 요청을 처리하고 있는가?
  • 데이터베이스 커넥션 풀이 고갈되고 있는가?
  • 오류율이 급증하기 시작한 것이 정확히 언제인가?

이것들은 메트릭 질문입니다. 이것들을 로그로 답하려는 것은 포크로 수프를 먹으려는 것과 같습니다 — 기술적으로 가능하지만 잘못된 도구입니다.

Prometheus 클라이언트 설정#

typescript
// src/lib/metrics.ts
import { Registry, Counter, Histogram, Gauge, collectDefaultMetrics } from "prom-client";
 
export const register = new Registry();
 
// 기본 Node.js 메트릭 (이벤트 루프 지연, 메모리 사용량, GC 등)
collectDefaultMetrics({ register });
 
// HTTP 요청 카운터
export const httpRequestsTotal = new Counter({
  name: "http_requests_total",
  help: "Total number of HTTP requests",
  labelNames: ["method", "route", "status_code"] as const,
  registers: [register],
});
 
// HTTP 요청 기간 히스토그램
export const httpRequestDuration = new Histogram({
  name: "http_request_duration_seconds",
  help: "HTTP request duration in seconds",
  labelNames: ["method", "route", "status_code"] as const,
  buckets: [0.001, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10],
  registers: [register],
});
 
// 활성 연결 게이지
export const activeConnections = new Gauge({
  name: "active_connections",
  help: "Number of active connections",
  registers: [register],
});
 
// 데이터베이스 쿼리 기간
export const dbQueryDuration = new Histogram({
  name: "db_query_duration_seconds",
  help: "Database query duration in seconds",
  labelNames: ["operation", "table"] as const,
  buckets: [0.001, 0.005, 0.01, 0.05, 0.1, 0.5, 1, 5],
  registers: [register],
});
 
// 외부 API 호출 기간
export const externalApiDuration = new Histogram({
  name: "external_api_duration_seconds",
  help: "External API call duration in seconds",
  labelNames: ["service", "endpoint", "status"] as const,
  buckets: [0.01, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10],
  registers: [register],
});
 
// 캐시 히트/미스 카운터
export const cacheOperations = new Counter({
  name: "cache_operations_total",
  help: "Cache operations",
  labelNames: ["operation", "result"] as const,
  registers: [register],
});

메트릭 엔드포인트#

typescript
// src/routes/metrics.ts
import { register } from "../lib/metrics";
import type { Request, Response } from "express";
 
export async function metricsHandler(req: Request, res: Response) {
  try {
    res.set("Content-Type", register.contentType);
    res.end(await register.metrics());
  } catch (err) {
    res.status(500).end(err);
  }
}
 
// app.ts에서
app.get("/metrics", metricsHandler);

이 엔드포인트를 공개하지 마세요. 내부 네트워크에서만 접근 가능해야 합니다. 직접적인 성능 수치와 운영 데이터를 반환합니다.

Express 미들웨어에서의 메트릭#

typescript
// src/middleware/metrics.ts
import { httpRequestsTotal, httpRequestDuration, activeConnections } from "../lib/metrics";
import type { Request, Response, NextFunction } from "express";
 
export function metricsMiddleware(req: Request, res: Response, next: NextFunction) {
  // 헬스체크와 메트릭 엔드포인트는 건너뜀
  if (req.path === "/health" || req.path === "/metrics" || req.path === "/ready") {
    return next();
  }
 
  activeConnections.inc();
  const end = httpRequestDuration.startTimer();
 
  res.on("finish", () => {
    activeConnections.dec();
 
    // 와일드카드를 고유 ID 대신 경로 패턴으로 정규화
    const route = normalizeRoute(req.route?.path || req.path);
 
    const labels = {
      method: req.method,
      route,
      status_code: res.statusCode.toString(),
    };
 
    httpRequestsTotal.inc(labels);
    end(labels);
  });
 
  next();
}
 
function normalizeRoute(path: string): string {
  // /users/123/orders/456 → /users/:id/orders/:id
  return path
    .replace(/\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/g, "/:id")
    .replace(/\/\d+/g, "/:id");
}

normalizeRoute 함수가 중요합니다. 이것 없이는 /users/123, /users/456, /users/789 등에 대해 별도의 레이블을 갖게 되어 카디널리티가 폭발하고 Prometheus가 슬퍼집니다. 경로를 패턴으로 정규화하세요.

네 가지 메트릭 유형#

카운터 — 증가만 합니다. 요청 총수, 오류 총수, 처리된 바이트 수.

typescript
httpRequestsTotal.inc({ method: "GET", route: "/api/users", status_code: "200" });

게이지 — 오르내립니다. 활성 연결 수, 큐 크기, 메모리 사용량.

typescript
activeConnections.inc();  // 요청 시작
activeConnections.dec();  // 요청 완료

히스토그램 — 값의 분포를 관찰합니다. 요청 기간, 응답 크기. 백분위수를 계산할 수 있습니다.

typescript
const timer = httpRequestDuration.startTimer();
// ... 작업 수행 ...
timer({ method: "GET", route: "/api/users", status_code: "200" });

서머리 — 히스토그램과 비슷하지만 클라이언트 측에서 백분위수를 계산합니다. 대부분의 경우 히스토그램이 더 좋습니다 — 서버 간 집계가 가능합니다.

비즈니스 메트릭#

기술 메트릭만으로는 부족합니다. 비즈니스를 추적하는 메트릭을 추가하세요:

typescript
// 비즈니스 메트릭 예시
export const ordersCreated = new Counter({
  name: "orders_created_total",
  help: "Total orders created",
  labelNames: ["payment_method", "currency"] as const,
  registers: [register],
});
 
export const orderValue = new Histogram({
  name: "order_value_dollars",
  help: "Order value in dollars",
  buckets: [10, 25, 50, 100, 250, 500, 1000, 5000],
  registers: [register],
});
 
export const activeUsers = new Gauge({
  name: "active_users",
  help: "Currently active users",
  registers: [register],
});
 
// 코드에서 사용
ordersCreated.inc({ payment_method: "credit_card", currency: "USD" });
orderValue.observe(149.99);

온콜 엔지니어가 새벽 3시에 "주문이 완전히 멈췄나?"라는 질문에 답할 수 있게 해주는 메트릭입니다.

Grafana 대시보드 핵심 패널#

모든 Node.js 서비스에 포함시키는 패널:

  1. 요청 비율: rate(http_requests_total[5m])
  2. 오류율: rate(http_requests_total{status_code=~"5.."}[5m]) / rate(http_requests_total[5m])
  3. p99 지연 시간: histogram_quantile(0.99, rate(http_request_duration_seconds_bucket[5m]))
  4. 활성 연결: active_connections
  5. Node.js 이벤트 루프 지연: nodejs_eventloop_lag_seconds
  6. 메모리 사용량: process_resident_memory_bytes
  7. DB 쿼리 기간 p99: histogram_quantile(0.99, rate(db_query_duration_seconds_bucket[5m]))
  8. 캐시 히트율: rate(cache_operations_total{result="hit"}[5m]) / rate(cache_operations_total[5m])

처음 네 개가 가장 중요합니다. 이것들이 "이 서비스가 건강한가?"를 즉시 알려줍니다.

알림 규칙#

Prometheus 알림 규칙 예시:

yaml
groups:
  - name: api-alerts
    rules:
      # 5분간 오류율이 5% 이상
      - alert: HighErrorRate
        expr: rate(http_requests_total{status_code=~"5.."}[5m]) / rate(http_requests_total[5m]) > 0.05
        for: 2m
        labels:
          severity: critical
        annotations:
          summary: "높은 오류율 감지"
          description: "5분간 오류율 {{ $value | humanizePercentage }}"
 
      # p99 지연 시간이 2초 이상
      - alert: HighLatency
        expr: histogram_quantile(0.99, rate(http_request_duration_seconds_bucket[5m])) > 2
        for: 5m
        labels:
          severity: warning
        annotations:
          summary: "높은 지연 시간 감지"
          description: "p99 지연 시간 {{ $value | humanizeDuration }}"
 
      # Node.js 이벤트 루프 지연이 100ms 이상
      - alert: EventLoopLag
        expr: nodejs_eventloop_lag_seconds > 0.1
        for: 2m
        labels:
          severity: warning
        annotations:
          summary: "높은 이벤트 루프 지연"

OpenTelemetry를 사용한 분산 트레이싱#

트레이싱이 중요한 이유#

마이크로서비스가 있습니다 (또는 API가 데이터베이스, 캐시, 외부 서비스를 호출하는 모놀리스가 있습니다). 요청이 느립니다. 어디가 느린가요?

로그로는 각 서비스의 타이밍을 일일이 상관시켜야 합니다. 트레이스를 사용하면 전체 요청의 워터폴 뷰를 얻습니다:

[API Gateway  ] ████████████████████████████████████████████ 450ms
  [Auth Service] ██████ 52ms
  [Order Service] ████████████████████████████████████ 380ms
    [PostgreSQL  ] ███████████████████████████ 312ms  ← 여기가 병목
    [Redis Cache ] ██ 3ms
  [Email Service] █ 8ms (비동기)

한눈에 PostgreSQL 쿼리가 병목이라는 것을 알 수 있습니다. 이것은 로그만으로는 불가능한 가시성입니다.

OpenTelemetry 설정#

OpenTelemetry는 분산 트레이싱의 현재 표준입니다. 벤더 중립적이어서 Jaeger, Zipkin, Datadog, Honeycomb 또는 다른 백엔드로 트레이스를 보낼 수 있습니다.

typescript
// src/lib/tracing.ts
import { NodeSDK } from "@opentelemetry/sdk-node";
import { getNodeAutoInstrumentations } from "@opentelemetry/auto-instrumentations-node";
import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http";
import { Resource } from "@opentelemetry/resources";
import { ATTR_SERVICE_NAME, ATTR_SERVICE_VERSION } from "@opentelemetry/semantic-conventions";
 
const sdk = new NodeSDK({
  resource: new Resource({
    [ATTR_SERVICE_NAME]: process.env.SERVICE_NAME || "api",
    [ATTR_SERVICE_VERSION]: process.env.APP_VERSION || "unknown",
    environment: process.env.NODE_ENV || "development",
  }),
  traceExporter: new OTLPTraceExporter({
    url: process.env.OTEL_EXPORTER_OTLP_ENDPOINT || "http://localhost:4318/v1/traces",
  }),
  instrumentations: [
    getNodeAutoInstrumentations({
      // 가장 유용한 자동 계측 설정
      "@opentelemetry/instrumentation-http": {
        ignoreIncomingPaths: ["/health", "/ready", "/metrics"],
      },
      "@opentelemetry/instrumentation-express": {
        enabled: true,
      },
      "@opentelemetry/instrumentation-pg": {
        enhancedDatabaseReporting: true,
      },
      "@opentelemetry/instrumentation-ioredis": {
        enabled: true,
      },
      // fs 계측은 노이즈가 많음 — 비활성화
      "@opentelemetry/instrumentation-fs": {
        enabled: false,
      },
    }),
  ],
});
 
sdk.start();
 
// 정상 종료 처리
process.on("SIGTERM", () => {
  sdk.shutdown().then(() => process.exit(0));
});

중요: 이 파일은 앱의 다른 것보다 먼저 로드되어야 합니다. require 또는 import보다 앞서야 합니다:

bash
node --require ./src/lib/tracing.js ./src/server.js
# 또는 tsx/ts-node 사용 시
node --require ./src/lib/tracing.ts ./src/server.ts

또는 package.json에서:

json
{
  "scripts": {
    "start": "node --require ./dist/lib/tracing.js ./dist/server.js"
  }
}

자동 계측이 주는 것#

auto-instrumentations-node를 사용하면 코드를 변경하지 않고도 다음에 대한 스팬을 자동으로 얻습니다:

  • HTTP 요청 (인바운드와 아웃바운드)
  • Express 라우트와 미들웨어
  • PostgreSQL 쿼리 (쿼리 텍스트와 파라미터 포함)
  • Redis 명령
  • DNS 조회
  • gRPC 호출

이것은 마법입니다. 기존 앱에 계측 패키지를 추가하면 갑자기 데이터베이스 쿼리가 각 요청에서 얼마나 걸리는지 정확한 스팬을 얻습니다.

커스텀 스팬 추가#

자동 계측이 대부분을 다루지만, 비즈니스 로직에는 커스텀 스팬이 필요합니다:

typescript
import { trace, SpanStatusCode } from "@opentelemetry/api";
 
const tracer = trace.getTracer("order-service");
 
async function processOrder(orderId: string, items: OrderItem[]) {
  return tracer.startActiveSpan("processOrder", async (span) => {
    try {
      span.setAttribute("order.id", orderId);
      span.setAttribute("order.item_count", items.length);
 
      // 재고 확인
      await tracer.startActiveSpan("checkInventory", async (inventorySpan) => {
        const available = await inventoryService.checkAll(items);
        inventorySpan.setAttribute("inventory.all_available", available);
        inventorySpan.end();
      });
 
      // 결제 처리
      await tracer.startActiveSpan("processPayment", async (paymentSpan) => {
        const result = await paymentService.charge(orderId);
        paymentSpan.setAttribute("payment.method", result.method);
        paymentSpan.setAttribute("payment.amount", result.amount);
        paymentSpan.end();
      });
 
      // 주문 확정
      await tracer.startActiveSpan("confirmOrder", async (confirmSpan) => {
        await db.orders.update(orderId, { status: "confirmed" });
        confirmSpan.end();
      });
 
      span.setStatus({ code: SpanStatusCode.OK });
    } catch (error) {
      span.setStatus({
        code: SpanStatusCode.ERROR,
        message: error instanceof Error ? error.message : "Unknown error",
      });
      span.recordException(error as Error);
      throw error;
    } finally {
      span.end();
    }
  });
}

이것은 중첩된 스팬 트리를 생성합니다:

processOrder (전체 기간)
├── checkInventory
├── processPayment
└── confirmOrder

각 스팬에 속성이 연결되어 있어 특정 결제 방법이 느린지, 재고 확인이 병목인지 등을 필터링할 수 있습니다.

컨텍스트 전파#

분산 트레이싱의 핵심은 컨텍스트 전파입니다 — 트레이스 ID를 서비스 간에 전달하는 것. OpenTelemetry HTTP 계측이 자동으로 처리합니다.

서비스 A가 서비스 B를 호출하면:

  1. 서비스 A가 HTTP 요청에 traceparent 헤더를 추가합니다
  2. 서비스 B가 헤더를 읽고 동일한 트레이스의 일부로 스팬을 생성합니다
  3. 두 서비스의 스팬이 같은 트레이스 아래에 나타납니다

이것은 자동 계측에서 무료로 얻는 것입니다. fetchaxios로 HTTP 호출을 하면 트레이스 헤더가 자동으로 추가됩니다.

트레이싱 백엔드#

Jaeger — 오픈소스, 셀프 호스팅. 좋은 UI, 좋은 검색. 소규모에서 중규모 팀에 좋습니다.

yaml
# docker-compose.yml
services:
  jaeger:
    image: jaegertracing/all-in-one:latest
    ports:
      - "16686:16686"  # UI
      - "4318:4318"    # OTLP HTTP
    environment:
      - COLLECTOR_OTLP_ENABLED=true

Grafana Tempo — Grafana 스택의 일부. Grafana 대시보드에서 직접 트레이스를 볼 수 있습니다. 메트릭에서 트레이스로 직접 이동하는 것이 강력합니다.

Datadog / Honeycomb / New Relic — 관리형 서비스. 설정이 쉽고, 비용이 높으며, 추가 기능 (이상 탐지, SLO 등)이 있습니다.

개인적으로 Jaeger로 시작한 다음, 팀과 트래픽이 커지면 Grafana Tempo로 이동하는 것을 추천합니다.

세 가지를 연결하기#

세 기둥의 진정한 힘은 서로 연결될 때 나옵니다.

로그에서 트레이스로#

로그에 트레이스 ID를 포함하세요:

typescript
import { trace } from "@opentelemetry/api";
 
function getTraceContext() {
  const span = trace.getActiveSpan();
  if (!span) return {};
 
  const context = span.spanContext();
  return {
    traceId: context.traceId,
    spanId: context.spanId,
  };
}
 
// 로거에 통합
const loggerWithTrace = logger.child(getTraceContext());
loggerWithTrace.error({ err }, "Payment failed");
// 출력: {"traceId":"abc123...","spanId":"def456...","msg":"Payment failed",...}

이제 Kibana나 Loki에서 로그를 보다가 오류를 발견하면 traceId를 복사해서 Jaeger에 붙여넣으면 정확한 요청 워터폴을 볼 수 있습니다.

메트릭에서 트레이스로#

Grafana에서 이것이 아름답게 작동합니다. p99 지연 시간 스파이크를 보면 Exemplar를 클릭해서 해당 기간의 실제 트레이스로 이동할 수 있습니다.

typescript
// Exemplar를 메트릭에 추가
const span = trace.getActiveSpan();
if (span) {
  httpRequestDuration.observe(
    {
      method: "GET",
      route: "/api/orders",
      status_code: "200",
    },
    duration,
    { traceID: span.spanContext().traceId }  // Exemplar
  );
}

이것이 전체 워크플로입니다:

  1. Grafana 대시보드에서 지연 시간 스파이크를 봅니다
  2. Exemplar를 클릭해서 느린 트레이스로 이동합니다
  3. 트레이스에서 PostgreSQL 쿼리가 800ms 걸리는 것을 봅니다
  4. 트레이스 ID로 로그를 검색해서 정확한 쿼리와 파라미터를 찾습니다

이것이 몇 시간의 디버깅을 몇 분으로 줄이는 것입니다.

프로덕션 배포 체크리스트#

새 Node.js 서비스를 프로덕션에 배포하기 전에 반드시 확인하는 목록:

로깅#

  • 구조화된 JSON 로깅 (Pino)
  • 요청 ID가 모든 로그에 포함
  • 민감한 필드 수정 (비밀번호, 토큰, 카드 번호)
  • 올바른 로그 레벨 (프로덕션에서 INFO, 개발에서 DEBUG)
  • 헬스체크 로그 필터링
  • 오류에 스택 트레이스 포함

메트릭#

  • HTTP 요청 카운터와 히스토그램
  • 데이터베이스 쿼리 기간
  • 캐시 히트율
  • 비즈니스 메트릭 (주문 수, 매출 등)
  • Node.js 기본 메트릭 (이벤트 루프, 메모리, GC)
  • 경로 정규화 (카디널리티 폭발 방지)
  • Grafana 대시보드 설정
  • 알림 규칙 (오류율, 지연 시간, 이벤트 루프)

트레이싱#

  • OpenTelemetry SDK 초기화 (앱 시작 전)
  • 자동 계측 (HTTP, Express, PostgreSQL, Redis)
  • 핵심 비즈니스 로직에 커스텀 스팬
  • 트레이스 ID가 로그에 포함
  • 헬스체크 경로 제외
  • 정상 종료 처리

흔한 실수들#

1. 모든 것을 로깅하기#

typescript
// 나쁨 — 모든 요청의 전체 본문을 로깅
app.use((req, res, next) => {
  logger.info({ body: req.body, headers: req.headers }, "Incoming request");
  next();
});

이것은 로그 스토리지를 빠르게 채우고, 성능에 영향을 미치며, 민감한 데이터를 노출합니다. 필요한 것만 로깅하세요.

2. 높은 카디널리티 레이블#

typescript
// 나쁨 — 사용자 ID를 레이블로 사용
httpRequestsTotal.inc({
  method: "GET",
  route: "/api/users",
  user_id: userId,  // 사용자가 100만 명이면 100만 개의 시계열
});

Prometheus는 시계열당 메모리를 사용합니다. 고유 값이 많은 레이블은 메모리를 폭파시킵니다. 사용자 ID, 세션 ID, 요청 ID를 레이블로 사용하지 마세요. 이런 것들은 로그와 트레이스에 넣으세요.

3. 오류를 삼키기#

typescript
// 나쁨 — 오류를 잡고 조용히 넘김
try {
  await processPayment(order);
} catch (error) {
  // 결제 실패를 "처리"
  return { success: false };
}

오류를 잡았으면 로깅하세요. 메트릭에 기록하세요. 트레이스에 기록하세요. 조용히 실패하면 문제를 찾을 방법이 없습니다.

typescript
// 좋음
try {
  await processPayment(order);
} catch (error) {
  logger.error({ err: error, orderId: order.id }, "Payment processing failed");
  paymentFailures.inc({ reason: categorizeError(error) });
  span?.recordException(error as Error);
  return { success: false, error: "Payment failed" };
}

4. 샘플링 없이 운영하기#

프로덕션에서 초당 수천 건의 요청이 있으면 모든 트레이스를 저장하는 것은 비실용적입니다. 샘플링을 사용하세요:

typescript
import { TraceIdRatioBasedSampler } from "@opentelemetry/sdk-trace-base";
 
const sdk = new NodeSDK({
  // 트레이스의 10%만 저장
  sampler: new TraceIdRatioBasedSampler(0.1),
  // ...
});

또는 더 스마트한 접근 — 테일 기반 샘플링으로 오류가 있거나 느린 트레이스는 항상 유지합니다.

5. 알림이 너무 많거나 너무 적거나#

알림 피로는 실제 문제입니다. 너무 많은 알림은 모두 무시하게 만들고, 너무 적은 알림은 문제를 놓치게 합니다.

원칙: 알림은 즉각적인 인간의 행동이 필요할 때만 울려야 합니다. "이벤트 루프 지연이 50ms"는 알림이 아니라 대시보드에 표시할 정보입니다. "오류율이 10%를 넘었다"는 알림입니다.

마무리#

옵저버빌리티는 도구가 아니라 실천입니다. 최고의 도구를 가지고 있어도 아무도 대시보드를 보지 않으면 소용없습니다.

제 최소 설정:

  1. Pino로 구조화된 로깅 → stdout → 로그 집계 서비스
  2. Prometheus 클라이언트로 메트릭 → Prometheus → Grafana
  3. OpenTelemetry로 분산 트레이싱 → Jaeger/Tempo
  4. 세 기둥을 트레이스 ID로 연결

이것은 완벽하지 않습니다. 하지만 문제가 발생했을 때 "무엇이, 얼마나, 왜"에 답할 수 있는 기반을 제공합니다. 그리고 그것이 새벽 3시의 사고 해결 시간을 몇 시간에서 몇 분으로 줄이는 차이를 만듭니다.

프로덕션 사고는 일어납니다. 옵저버빌리티는 사고를 막지 않습니다. 사고를 빠르게 해결하게 해줍니다. 그리고 빠른 해결이 결국 좋은 서비스의 차이를 만듭니다.

관련 게시물