구조화 로그에 Request ID 넣기

구조화 로그에 Request ID 넣기

한눈에 보기

Request ID는 한 서비스가 처리한 요청과 그 과정의 로그를 묶는 상관관계 식별자다. 신뢰 경계에서 생성하거나 검증한 값을 응답, 로그, 외부 호출에 전달한다. Node.js에서는 AsyncLocalStorage로 비동기 호출 사슬에 문맥을 유지할 수 있지만, 분산 추적의 Trace ID와는 역할이 다르다.

목차

한 요청의 로그가 여러 줄로 흩어지는 문제

서버가 한 번에 한 요청만 처리한다면 시간순 로그만 읽어도 흐름을 따라갈 수 있다. 실제 서버는 여러 요청을 동시에 처리한다.

12:00:01 request started
12:00:01 request started
12:00:01 user loaded
12:00:01 payment requested
12:00:02 user loaded
12:00:02 payment rejected
12:00:02 request finished
12:00:02 request finished

이 로그만으로는 어떤 request started가 어떤 payment rejected와 연결되는지 알 수 없다. 사용자 ID를 넣으면 나아질 것 같지만 로그인 전 요청에는 없고, 한 사용자가 동시에 여러 요청을 보낼 수도 있다. 결제 ID 역시 요청 초반에는 아직 생성되지 않았을 수 있다.

요청이 시작될 때 상관관계 식별자를 하나 만들고 모든 로그에 넣으면 검색 기준이 생긴다.

{"level":"info","requestId":"req_01JEXAMPLEA","message":"request started"}
{"level":"info","requestId":"req_01JEXAMPLEB","message":"request started"}
{"level":"info","requestId":"req_01JEXAMPLEA","message":"user loaded"}
{"level":"warn","requestId":"req_01JEXAMPLEA","message":"payment rejected"}
{"level":"info","requestId":"req_01JEXAMPLEB","message":"request finished"}
requestId = "req_01JEXAMPLEA"

로 검색하면 하나의 처리 흐름만 다시 볼 수 있다.

Request ID가 답하는 질문

“이 로그가 어느 요청을 처리하다 발생했는가?”를 답한다. “어느 사용자인가?”, “어느 주문인가?”, “어느 배포 버전인가?” 같은 비즈니스·운영 식별자를 대체하지는 않는다.

구조화 로그와 Request ID가 함께 필요한 이유

문자열 로그 끝에 ID를 붙일 수도 있다.

console.log(
  `[${requestId}] payment rejected for ${paymentId}`,
);

하지만 로그 수집기에서 ID를 추출하려면 문자열 파싱 규칙이 필요하다. 문구가 바뀌거나 괄호 형식이 달라지면 검색이 깨진다.

구조화 로그에서는 각 정보를 독립 필드로 기록한다.

logger.warn(
  {
    requestId,
    paymentId,
    reason: "insufficient_balance",
  },
  "payment rejected",
);
{
  "level": "warn",
  "requestId": "req_01JEXAMPLEA",
  "paymentId": "pay_example_42",
  "reason": "insufficient_balance",
  "message": "payment rejected"
}

이제 필드마다 다른 용도로 검색할 수 있다.

필드 질문 예시 사용
requestId 한 요청에서 무슨 일이 있었나 오류 전후 흐름 재구성
traceId 여러 서비스에 걸쳐 어떤 경로를 지났나 분산 추적과 로그 연결
userId 특정 사용자에게 반복되는가 고객 문의 분석
paymentId 특정 결제의 상태 변화는 무엇인가 도메인 감사
deploymentVersion 특정 배포에서만 발생하는가 회귀 분석

필드를 많이 넣는다고 좋은 로그는 아니다. 카드번호, 액세스 토큰, 이메일처럼 민감하거나 고카디널리티인 값을 무분별하게 넣지 않는다. Request ID는 로그 검색에는 적합하지만 메트릭 라벨로 사용하면 시계열 수가 요청 수만큼 늘어날 수 있다.

메트릭 라벨에는 넣지 않는다

http_requests_total{request_id="..."}처럼 Request ID를 메트릭 라벨로 넣으면 각 요청마다 새 시계열이 생긴다. Request ID는 로그와 트레이스에서 사용하고, 메트릭은 상태 코드·경로 템플릿·서비스처럼 제한된 차원의 값으로 집계한다.

Request ID를 어디에서 만들 것인가

요청은 CDN, 로드 밸런서, API Gateway, 애플리케이션을 거칠 수 있다. 각 계층이 서로 다른 ID를 만들면 상관관계를 잇기 어렵다.

flowchart LR
    C[Client] --> CDN[CDN]
    CDN --> GW[API Gateway]
    GW --> API[Application]
    API --> PAY[Payment Service]

생성 위치는 신뢰 경계와 운영 도구에 따라 정한다.

가장 앞의 신뢰할 수 있는 프록시에서 생성

Gateway가 모든 요청에 ID를 만들고 downstream으로 전달한다. 프록시 로그와 애플리케이션 로그를 같은 ID로 검색할 수 있다는 장점이 있다.

애플리케이션에서 생성

앞단의 프록시가 없거나 헤더 정책을 통제할 수 없다면 애플리케이션에서 만든다. 클라이언트가 보낸 ID는 무시하거나 별도의 clientRequestId로 보관한다.

외부 ID를 검증한 뒤 재사용

내부 서비스 간 호출처럼 송신자를 신뢰할 수 있고 형식이 계약으로 정해졌다면 전달받은 ID를 유지한다. 다만 인터넷 클라이언트가 보낸 x-request-id를 무조건 신뢰해서는 안 된다.

실무에서는 다음 정책이 명확하다.

1. 신뢰할 수 있는 Gateway가 붙인 x-request-id가 있으면 검증 후 사용한다.
2. 없거나 올바르지 않으면 서버가 새 ID를 생성한다.
3. 최종 ID를 응답 헤더에 돌려준다.
4. 클라이언트가 보낸 원본 ID가 필요하면 별도 필드로 제한적으로 기록한다.

ID 생성에는 충분히 충돌 가능성이 낮은 방법을 사용한다.

import { randomUUID } from "node:crypto";

function createRequestId(): string {
  return `req_${randomUUID()}`;
}

시간 정렬이 필요한 경우 ULID 같은 형식을 고려할 수 있지만 Request ID의 핵심은 전역 순서가 아니라 상관관계다. 로그 저장소가 타임스탬프로 정렬할 수 있다면 UUID로도 충분한 경우가 많다.

외부에서 받은 ID를 그대로 믿지 않는다

다음 미들웨어는 짧지만 문제가 있다.

app.use((req, res, next) => {
  req.requestId =
    req.get("x-request-id") ?? randomUUID();

  next();
});

공격자나 잘못된 클라이언트가 매우 긴 문자열, 줄바꿈, 제어 문자, 다른 사용자의 ID를 보낼 수 있다.

X-Request-Id: aaaaaaaaaa...(수 MB)

또는 로그가 문자열 형식이라면 줄바꿈으로 가짜 로그를 삽입하려 할 수 있다.

valid-id
ERROR admin login succeeded

Request ID의 형식과 길이를 제한한다.

const REQUEST_ID_PATTERN =
  /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;

function normalizeIncomingRequestId(
  value: string | undefined,
): string | undefined {
  if (!value) {
    return undefined;
  }

  if (!REQUEST_ID_PATTERN.test(value)) {
    return undefined;
  }

  return value;
}
function resolveRequestId(
  incoming: string | undefined,
): string {
  return (
    normalizeIncomingRequestId(incoming) ??
    createRequestId()
  );
}

128자는 예시 정책일 뿐이다. 사용하는 Gateway와 로그 시스템의 형식에 맞춰 정한다. 중요한 것은 무제한 사용자 입력을 핵심 로그 필드에 넣지 않는 것이다.

신뢰할 프록시를 구분한다

애플리케이션이 인터넷에 직접 노출되어 있다면 클라이언트가 Gateway와 같은 헤더를 보낼 수 있다. Gateway가 들어오는 x-request-id를 제거하고 새로 설정하거나, 애플리케이션이 인증된 내부 경로에서만 해당 헤더를 신뢰해야 한다.

Internet Client
    │ x-request-id: attacker-controlled
    ▼
Gateway
    │ 기존 헤더 제거
    │ x-request-id: gateway-generated
    ▼
Application

ID가 인증 수단은 아니라는 점도 중요하다. Request ID를 안다고 다른 사용자의 요청을 조회하거나 재실행할 수 있어서는 안 된다.

Request ID는 권한 증명이 아니다

충분히 무작위인 ID를 사용해도 인증 토큰처럼 취급하지 않는다. 오류 조회 API나 고객 지원 도구는 별도의 인증·인가를 거쳐야 한다.

Express 요청 경계에서 문맥을 만든다

먼저 Express의 요청 객체에 타입을 확장하는 단순한 방법을 볼 수 있다.

declare global {
  namespace Express {
    interface Request {
      requestId: string;
    }
  }
}
import type {
  Request,
  Response,
  NextFunction,
} from "express";

function requestIdMiddleware(
  req: Request,
  res: Response,
  next: NextFunction,
): void {
  const requestId = resolveRequestId(
    req.get("x-request-id"),
  );

  req.requestId = requestId;
  res.setHeader("x-request-id", requestId);

  next();
}

미들웨어는 가능한 앞쪽에 둔다. JSON 파싱이나 인증 미들웨어가 실패해도 Request ID가 있어야 하기 때문이다.

app.use(requestIdMiddleware);
app.use(requestLoggingMiddleware);
app.use(express.json());
app.use(authenticationMiddleware);
app.use(routes);
app.use(errorMiddleware);

응답 헤더에 돌려주면 클라이언트가 오류 문의에 활용할 수 있다.

HTTP/1.1 500 Internal Server Error
X-Request-Id: req_3b28c1b5-7ad9-49f8-a3aa-example
Content-Type: application/json

오류 응답 본문에도 넣을 수 있지만 API 계약과 정보 공개 정책에 맞춰 결정한다.

{
  "code": "INTERNAL_ERROR",
  "message": "요청을 처리하지 못했습니다.",
  "requestId": "req_3b28c1b5-7ad9-49f8-a3aa-example"
}

단순 요청 객체 방식은 컨트롤러에서 req.requestId를 전달해야 한다.

app.post("/payments", async (req, res) => {
  await paymentService.charge(
    req.body,
    {
      requestId: req.requestId,
    },
  );

  res.status(202).end();
});

명시적 전달은 의존성을 분명하게 보여주는 장점이 있다. 하지만 계층이 깊어질수록 모든 함수 인자에 문맥이 섞인다.

AsyncLocalStorage로 깊은 호출까지 전달한다

Node.js의 AsyncLocalStorage는 콜백과 Promise 사슬에 요청 문맥을 연결한다. 다른 언어의 thread-local storage와 비슷한 역할을 비동기 실행 단위에 제공한다.

import { AsyncLocalStorage } from "node:async_hooks";

type RequestContext = Readonly<{
  requestId: string;
  method: string;
  path: string;
  startedAt: number;
}>;

const requestContext =
  new AsyncLocalStorage<RequestContext>();

요청 미들웨어에서 run()으로 경계를 만든다.

function requestContextMiddleware(
  req: Request,
  res: Response,
  next: NextFunction,
): void {
  const requestId = resolveRequestId(
    req.get("x-request-id"),
  );

  res.setHeader("x-request-id", requestId);

  const context: RequestContext = Object.freeze({
    requestId,
    method: req.method,
    path: req.path,
    startedAt: performance.now(),
  });

  requestContext.run(context, next);
}

run()의 콜백 안에서 만들어진 비동기 작업은 같은 저장소를 볼 수 있다.

function currentRequestContext():
  | RequestContext
  | undefined {
  return requestContext.getStore();
}
async function reservePayment(
  command: ReservePaymentCommand,
): Promise<void> {
  const context = currentRequestContext();

  logger.info(
    {
      requestId: context?.requestId,
      paymentId: command.paymentId,
    },
    "reserving payment",
  );

  await paymentRepository.reserve(command);
}

getStore()undefined일 수 있는 정책을 정해야 한다. 요청 밖의 시작 작업과 배치 작업에서도 같은 함수를 호출할 수 있기 때문이다.

function requireRequestContext(): RequestContext {
  const context = requestContext.getStore();

  if (!context) {
    throw new Error(
      "request context is not available",
    );
  }

  return context;
}

요청에서만 호출되어야 하는 코드라면 즉시 실패시키는 함수가 버그를 빨리 드러낸다. 공용 저장소처럼 요청 밖에서도 쓰이는 코드라면 선택 값으로 처리하는 편이 맞다.

run()enterWith()의 차이

요청 경계에는 범위가 명확한 run(store, callback)이 이해하기 쉽다. 콜백이 끝나면 이전 문맥으로 복원된다. enterWith()는 이후의 동기 실행 전체에 문맥을 적용하므로 이벤트 처리기 사이에 예상치 못하게 공유될 가능성을 더 신중히 봐야 한다.

requestContext.run(context, () => {
  next();
});

Express의 next를 직접 콜백으로 전달해도 같은 의미다.

requestContext.run(context, next);

요청별 자식 로거를 사용한다

매 로그마다 Request ID를 직접 추가하면 빠뜨리기 쉽다.

logger.info({ requestId }, "payment started");
logger.info("payment provider called"); // requestId 누락

구조화 로거의 child logger를 문맥에 넣으면 기본 필드가 자동으로 붙는다.

type RequestContext = Readonly<{
  requestId: string;
  logger: Logger;
  startedAt: number;
}>;
function requestContextMiddleware(
  req: Request,
  res: Response,
  next: NextFunction,
): void {
  const requestId = resolveRequestId(
    req.get("x-request-id"),
  );

  const requestLogger = logger.child({
    requestId,
    method: req.method,
    route: req.route?.path ?? req.path,
  });

  requestContext.run(
    {
      requestId,
      logger: requestLogger,
      startedAt: performance.now(),
    },
    next,
  );
}

실제 라우트가 결정되기 전에는 req.route가 없을 수 있다. 요청 시작 로그에는 정규화된 경로 템플릿을 알 수 없으므로 원본 URL을 그대로 메트릭 라벨에 쓰지 말고, 종료 시점에 라우트 정보를 얻거나 프레임워크 계측을 활용한다.

문맥 기반 로거 접근자를 만든다.

function currentLogger(): Logger {
  return (
    requestContext.getStore()?.logger ??
    logger
  );
}
async function loadPayment(
  paymentId: string,
): Promise<Payment> {
  currentLogger().debug(
    { paymentId },
    "loading payment",
  );

  return paymentRepository.findById(paymentId);
}

요청 시작과 종료 로그도 같은 문맥에서 남긴다.

function requestLoggingMiddleware(
  req: Request,
  res: Response,
  next: NextFunction,
): void {
  const context = requireRequestContext();

  context.logger.info("request started");

  res.once("finish", () => {
    context.logger.info(
      {
        statusCode: res.statusCode,
        durationMs:
          performance.now() - context.startedAt,
      },
      "request finished",
    );
  });

  res.once("close", () => {
    if (!res.writableFinished) {
      context.logger.warn(
        {
          durationMs:
            performance.now() - context.startedAt,
        },
        "request connection closed before response",
      );
    }
  });

  next();
}

finish는 응답이 전송되었을 때, close는 연결이 일찍 닫힌 경우까지 관찰하는 데 사용한다. 두 이벤트에서 같은 완료 로그를 중복 기록하지 않도록 조건을 둔다.

ID만 넣고 로그 품질을 놓치지 않는다

Request ID는 흩어진 로그를 모아 줄 뿐이다. payment failed만 반복하면 모은 뒤에도 원인을 모른다. 작업 이름, 결과, 안정적인 오류 코드, 도메인 식별자를 함께 구조화해야 한다.

외부 HTTP 호출과 메시지로 전파한다

서비스 A의 로그만 묶여도 서비스 B로 넘어가면 흐름이 끊긴다.

sequenceDiagram
    participant Client
    participant API as Checkout API
    participant Payment as Payment API

    Client->>API: request
    Note over API: requestId=req_A
    API->>Payment: HTTP call without ID
    Note over Payment: requestId=req_B
    Payment-->>API: 500
    API-->>Client: 502
    Note over API,Payment: 두 서비스의 로그를 직접 연결하기 어려움

내부 HTTP 클라이언트가 현재 Request ID를 헤더에 넣도록 한다.

async function callPaymentApi(
  input: PaymentInput,
): Promise<PaymentResult> {
  const context = requestContext.getStore();

  const response = await fetch(
    "https://payment.internal.example/charges",
    {
      method: "POST",
      headers: {
        "content-type": "application/json",
        ...(context && {
          "x-request-id": context.requestId,
        }),
      },
      body: JSON.stringify(input),
    },
  );

  return parsePaymentResponse(response);
}

모든 호출부가 직접 구현하면 누락되므로 공통 HTTP 클라이언트 인터셉터나 래퍼에서 처리한다. 이때 신뢰 경계를 넘어 외부 파트너로 Request ID를 전파할지는 별도로 결정한다. 내부 토폴로지나 로그 검색 키를 외부에 불필요하게 노출할 수 있다.

비동기 메시지

HTTP 응답이 끝난 뒤 큐에서 처리되는 작업은 같은 요청의 연장선일 수도 있다. 메시지 메타데이터에 상관관계 정보를 넣는다.

type PaymentRequestedMessage = Readonly<{
  messageId: string;
  causationId: string;
  correlationId: string;
  payload: {
    paymentId: string;
    amount: number;
  };
}>;

각 ID의 역할을 구분하면 재시도와 여러 이벤트를 다루기 쉽다.

ID 의미
messageId 이 메시지 자체의 고유 ID
causationId 직접 이 메시지를 발생시킨 명령·이벤트
correlationId 전체 비즈니스 흐름을 묶는 ID
requestId 원래 HTTP 요청을 찾기 위한 보조 문맥

Request ID 하나를 모든 역할에 재사용하면 한 요청이 여러 메시지를 만들 때 각 메시지를 구분하기 어렵다.

const context = requestContext.getStore();

await publisher.publish({
  messageId: createMessageId(),
  causationId: command.commandId,
  correlationId:
    command.checkoutId,
  requestId: context?.requestId,
  payload: {
    paymentId: command.paymentId,
  },
});

소비자는 메시지마다 새 실행 문맥을 만든다.

async function handlePaymentRequested(
  message: PaymentRequestedMessage,
): Promise<void> {
  const requestId =
    message.requestId ?? createRequestId();

  const jobLogger = logger.child({
    requestId,
    messageId: message.messageId,
    correlationId: message.correlationId,
  });

  await requestContext.run(
    {
      requestId,
      logger: jobLogger,
      startedAt: performance.now(),
    },
    async () => {
      await paymentHandler.handle(message.payload);
    },
  );
}

Request ID와 Trace ID를 구분한다

Request ID와 Trace ID는 둘 다 로그 상관관계에 쓰이므로 혼동하기 쉽다.

Request ID

Trace ID

Trace ID: 4bf92f3577b34da6a3ce929d0e0e4736
├── Span: Gateway
├── Span: Checkout API
│   ├── Span: SELECT order
│   └── Span: POST payment
└── Span: Payment API

W3C traceparent 헤더는 대략 다음 형식이다.

traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01
version - trace-id - parent-id - trace-flags

직접 파싱하고 생성하기보다 OpenTelemetry 같은 계측 라이브러리를 사용하는 편이 안전하다.

둘을 함께 로그에 넣을 수 있다.

{
  "requestId": "req_3b28c1b5-example",
  "traceId": "4bf92f3577b34da6a3ce929d0e0e4736",
  "spanId": "00f067aa0ba902b7",
  "message": "payment provider responded"
}

이미 분산 추적이 모든 진입점에서 안정적으로 동작한다면 Trace ID를 고객에게 돌려주는 상관관계 ID로 사용할 수도 있다. 하지만 다음 이유로 별도 Request ID를 유지하기도 한다.

하나로 합칠지 둘로 둘지는 운영 계약의 문제다

중요한 것은 이름을 섞지 않는 것이다. requestId 필드에 어떤 서비스에서는 UUID, 다른 서비스에서는 Trace ID를 넣으면 검색 규칙이 불안정해진다.

백그라운드 작업에는 새로운 문맥이 필요하다

요청 처리 중 만든 비동기 작업이 응답 뒤에도 계속 실행되면 Request ID 문맥이 남을 수 있다.

app.post("/reports", async (req, res) => {
  void generateLargeReport(req.body);
  res.status(202).end();
});

이 코드는 작업 실패를 관찰하기 어렵고 프로세스 종료 시 유실될 수 있다는 문제가 먼저 있다. 내구성 있는 큐에 넣는 편이 일반적이다.

백그라운드 작업은 HTTP 요청과 별도의 생명주기를 갖는다. 원래 Request ID는 원인 추적용으로 보존하되 작업 자체의 ID와 새로운 실행 문맥을 둔다.

type JobContext = Readonly<{
  jobId: string;
  originatingRequestId?: string;
  logger: Logger;
  startedAt: number;
}>;
async function runJob(job: ReportJob): Promise<void> {
  const jobLogger = logger.child({
    jobId: job.id,
    originatingRequestId: job.requestId,
  });

  await jobContext.run(
    {
      jobId: job.id,
      originatingRequestId: job.requestId,
      logger: jobLogger,
      startedAt: performance.now(),
    },
    () => reportWorker.execute(job),
  );
}

스케줄러 작업에는 원래 HTTP 요청이 없으므로 억지로 Request ID를 만들기보다 jobRunId를 중심 식별자로 쓰는 편이 의미가 정확하다.

{
  "jobName": "daily-settlement",
  "jobRunId": "run_2025-07-29_example",
  "message": "settlement started"
}

성능과 장애 상황에서 주의할 점

문맥 객체를 작게 유지한다

AsyncLocalStorage 저장소에 요청 본문, 응답, 사용자 객체 전체를 넣지 않는다.

// 피해야 할 예
requestContext.run(
  {
    req,
    res,
    user,
    body: req.body,
  },
  next,
);

비동기 작업이 오래 유지되면 큰 객체도 함께 참조되어 메모리 해제가 늦어질 수 있다. ID와 작은 불변 메타데이터, 자식 로거 정도만 둔다.

일부 비표준 비동기 경계에서 문맥이 끊길 수 있다

Node.js의 Promise와 일반 콜백에서는 문맥이 유지되지만, 특정 네이티브 애드온이나 직접 만든 이벤트 추상화에서 끊길 수 있다. 문제가 발생하면 어느 경계에서 getStore()undefined가 되는지 확인하고, 라이브러리의 계측 지원이나 AsyncResource를 검토한다. 직접 async_hooks 기반 구현을 만들기보다 AsyncLocalStorage를 우선 사용하는 것이 Node.js 공식 권장이다.

모든 로그에 성공 로그를 남기지 않는다

요청 시작과 종료를 모든 요청에 기록하면 트래픽이 많을 때 로그 비용이 커진다. 다음 전략을 조합할 수 있다.

샘플링하더라도 오류 로그에는 Request ID를 반드시 남겨야 한다. 트레이스 샘플링 플래그를 외부 입력 그대로 신뢰해 공격자가 관측 비용을 늘리지 못하게 한다.

응답 전에 헤더를 정한다

응답이 시작된 뒤에는 x-request-id를 추가할 수 없다. Request ID 미들웨어를 라우트와 오류 처리보다 앞에 둔다.

if (res.headersSent) {
  // 이 시점에는 헤더 추가가 늦었다.
}

ID 충돌을 보안 경계로 다룰 필요는 없지만 관측한다

충분히 무작위인 UUID의 충돌 가능성은 매우 낮다. 그래도 클라이언트가 임의 값을 보낼 수 있는 정책이라면 같은 ID가 여러 요청에서 나타날 수 있다. 타임스탬프, 서비스, 인스턴스, Trace ID 같은 필드와 함께 검색하고 Request ID만으로 데이터 무결성을 결정하지 않는다.

테스트와 운영 점검

미들웨어 단위 테스트

it("creates an ID when the header is absent", async () => {
  const response = await request(app)
    .get("/example")
    .expect(200);

  expect(response.headers["x-request-id"]).toMatch(
    /^req_[0-9a-f-]+$/,
  );
});
it("rejects an invalid incoming ID and creates a new one", async () => {
  const invalidId = "invalid\nrequest-id";

  const response = await request(app)
    .get("/example")
    .set("x-request-id", invalidId)
    .expect(200);

  expect(response.headers["x-request-id"])
    .not.toBe(invalidId);
});

HTTP 라이브러리가 줄바꿈 헤더 자체를 거부할 수 있으므로 실제 테스트에서는 지나치게 긴 값이나 허용하지 않는 문자처럼 전송 가능한 잘못된 입력도 확인한다.

비동기 문맥 테스트

it("keeps the same context across promises", async () => {
  const context: RequestContext = {
    requestId: "req_test_example",
    logger: createTestLogger(),
    startedAt: performance.now(),
  };

  await requestContext.run(context, async () => {
    await Promise.resolve();
    await delay(1);

    expect(
      requestContext.getStore()?.requestId,
    ).toBe("req_test_example");
  });
});

전파 테스트

it("propagates the current ID to an internal call", async () => {
  await requestContext.run(
    createTestContext("req_test_example"),
    async () => {
      await callPaymentApi(examplePaymentInput());
    },
  );

  expect(mockPaymentServer.lastRequest.headers)
    .toMatchObject({
      "x-request-id": "req_test_example",
    });
});

운영 체크리스트

로그 탐색 훈련도 해볼 수 있다.

1. 테스트 요청을 보내 응답의 x-request-id를 기록한다.
2. Gateway 로그에서 해당 ID를 검색한다.
3. 애플리케이션의 시작·종료·오류 로그를 시간순으로 확인한다.
4. downstream 서비스 로그 또는 trace로 이동한다.
5. 민감 정보 없이 원인을 재구성할 수 있는지 확인한다.

ID가 존재하는 것과 실제로 검색 가능한 것은 다르다. 로그 수집 파이프라인이 JSON을 올바르게 파싱하는지, 필드 이름이 서비스마다 일관적인지, 보존 기간이 고객 문의 대응 기간보다 짧지 않은지도 확인해야 한다.

마무리

동시 요청을 처리하는 서버에서 시간순 로그만으로 한 요청의 흐름을 복원하기는 어렵다. Request ID를 요청 경계에서 확정하고 구조화 로그의 독립 필드로 기록하면 흩어진 이벤트를 하나로 묶을 수 있다.

Request ID는 신뢰할 수 있는 계층에서 생성하거나 엄격히 검증하고, 응답·로그·내부 호출에 일관되게 전파해야 한다. Node.js에서는 AsyncLocalStorage와 요청별 자식 로거를 조합하면 깊은 호출 계층에서도 문맥 누락을 줄일 수 있다.

Request ID를 인증 수단이나 메트릭 차원으로 사용하지 않고, 분산 환경에서는 Trace ID·Span ID·메시지 상관관계 ID와 역할을 구분한다. 결국 중요한 것은 ID 한 줄을 추가하는 일이 아니라, 장애가 발생했을 때 프록시에서 데이터베이스 앞까지 한 요청의 이야기를 실제로 재구성할 수 있는 관측 경로를 만드는 것이다.

참고 자료

관련 노트