NestJS 구조화 로깅: Pino·CLS

NestJS 기본 Logger의 한계

NestJS 내장 Logger는 개발 환경에서는 충분하지만, 프로덕션에서는 구조화된 JSON 로깅, 요청별 상관 ID(Correlation ID), 로그 레벨 동적 제어, 외부 수집 시스템 연동이 필수입니다.

이 글에서는 Pino 기반 구조화 로깅 구축, AsyncLocalStorage를 활용한 요청 컨텍스트 전파, 커스텀 LoggerService 구현, 그리고 ELK/Datadog 연동까지 실전 패턴을 다룹니다.

nestjs-pino: 구조화 로깅의 표준

Pino는 Node.js에서 가장 빠른 JSON 로거입니다. nestjs-pino 패키지가 NestJS와의 통합을 제공합니다.

npm install nestjs-pino pino-http pino-pretty
// app.module.ts
import { LoggerModule } from 'nestjs-pino';

@Module({
  imports: [
    LoggerModule.forRoot({
      pinoHttp: {
        // 프로덕션: JSON, 개발: pretty print
        transport: process.env.NODE_ENV !== 'production'
          ? { target: 'pino-pretty', options: { colorize: true } }
          : undefined,

        level: process.env.LOG_LEVEL || 'info',

        // 요청/응답 자동 로깅 설정
        autoLogging: true,

        // 민감 정보 마스킹
        redact: {
          paths: [
            'req.headers.authorization',
            'req.headers.cookie',
            'req.body.password',
            'req.body.token',
          ],
          censor: '[REDACTED]',
        },

        // 커스텀 직렬화
        serializers: {
          req(req) {
            return {
              method: req.method,
              url: req.url,
              query: req.query,
              params: req.params,
              // body는 POST/PUT만
              ...((['POST', 'PUT', 'PATCH'].includes(req.method))
                && { body: req.raw.body }),
            };
          },
          res(res) {
            return { statusCode: res.statusCode };
          },
        },

        // 커스텀 속성 추가
        customProps: (req) => ({
          context: 'HTTP',
        }),

        // 요청 ID 자동 생성
        genReqId: (req) =>
          req.headers['x-request-id'] || crypto.randomUUID(),
      },
    }),
  ],
})
export class AppModule {}
// main.ts — NestJS 내장 Logger를 Pino로 교체
import { Logger } from 'nestjs-pino';

async function bootstrap() {
  const app = await NestFactory.create(AppModule, { bufferLogs: true });
  app.useLogger(app.get(Logger));
  await app.listen(3000);
}
bootstrap();

이제 모든 로그가 구조화된 JSON으로 출력됩니다:

{"level":30,"time":1709060000000,"pid":1234,"hostname":"api-pod-xyz",
 "reqId":"550e8400-e29b-41d4-a716","req":{"method":"POST","url":"/api/users"},
 "msg":"request completed","responseTime":45,"res":{"statusCode":201}}

AsyncLocalStorage: 요청 컨텍스트 전파

서비스 레이어, 리포지토리, 유틸 함수 어디서든 현재 요청의 상관 ID, 유저 정보 등을 접근해야 합니다. AsyncLocalStorage가 이를 해결합니다.

npm install nestjs-cls
// app.module.ts
import { ClsModule } from 'nestjs-cls';

@Module({
  imports: [
    ClsModule.forRoot({
      global: true,
      middleware: {
        mount: true,
        generateId: true,
        idGenerator: (req) =>
          req.headers['x-correlation-id'] as string || crypto.randomUUID(),
      },
    }),
  ],
})
export class AppModule {}
// 요청 컨텍스트에 유저 정보 세팅 (Guard 또는 Middleware에서)
@Injectable()
export class AuthGuard implements CanActivate {
  constructor(
    private readonly cls: ClsService,
    private readonly jwtService: JwtService,
  ) {}

  canActivate(context: ExecutionContext): boolean {
    const request = context.switchToHttp().getRequest();
    const user = this.jwtService.verify(request.headers.authorization);

    // CLS에 유저 정보 저장 → 어디서든 접근 가능
    this.cls.set('userId', user.id);
    this.cls.set('userEmail', user.email);
    this.cls.set('correlationId', this.cls.getId());

    return true;
  }
}
// 서비스 어디서든 컨텍스트 접근
@Injectable()
export class OrderService {
  private readonly logger = new Logger(OrderService.name);

  constructor(
    private readonly cls: ClsService,
    private readonly orderRepo: OrderRepository,
  ) {}

  async createOrder(dto: CreateOrderDto) {
    const userId = this.cls.get('userId');
    const correlationId = this.cls.getId();

    this.logger.log({
      msg: '주문 생성 시작',
      correlationId,
      userId,
      productIds: dto.items.map(i => i.productId),
    });

    const order = await this.orderRepo.save({ ...dto, userId });

    this.logger.log({
      msg: '주문 생성 완료',
      correlationId,
      orderId: order.id,
      totalAmount: order.totalAmount,
    });

    return order;
  }
}

커스텀 LoggerService: Pino + CLS 통합

@Injectable()
export class AppLoggerService {
  private readonly logger: PinoLogger;

  constructor(
    pinoLogger: PinoLogger,
    private readonly cls: ClsService,
  ) {
    this.logger = pinoLogger;
  }

  private enrichLog(obj: Record<string, any> = {}) {
    return {
      ...obj,
      correlationId: this.cls.getId(),
      userId: this.cls.get('userId'),
      traceId: this.cls.get('traceId'),
    };
  }

  info(msg: string, obj?: Record<string, any>) {
    this.logger.info(this.enrichLog(obj), msg);
  }

  warn(msg: string, obj?: Record<string, any>) {
    this.logger.warn(this.enrichLog(obj), msg);
  }

  error(msg: string, error?: Error, obj?: Record<string, any>) {
    this.logger.error({
      ...this.enrichLog(obj),
      err: error ? {
        message: error.message,
        stack: error.stack,
        name: error.name,
      } : undefined,
    }, msg);
  }

  debug(msg: string, obj?: Record<string, any>) {
    this.logger.debug(this.enrichLog(obj), msg);
  }
}

로그 레벨 동적 제어

// 런타임에 로그 레벨 변경 엔드포인트
@Controller('admin/logging')
@UseGuards(AdminGuard)
export class LoggingController {
  constructor(@Inject('PinoLogger') private readonly pino: any) {}

  @Put('level')
  setLevel(@Body('level') level: string) {
    // pino 인스턴스의 레벨 동적 변경
    this.pino.logger.level = level; // 'debug', 'info', 'warn', 'error'
    return { level: this.pino.logger.level };
  }

  @Get('level')
  getLevel() {
    return { level: this.pino.logger.level };
  }
}

프로덕션에서 디버깅이 필요할 때 재배포 없이 로그 레벨을 변경할 수 있습니다. DI Scope를 활용하면 특정 요청에만 debug 레벨을 적용하는 것도 가능합니다.

ELK Stack 연동

# docker-compose.yml (로그 수집 파이프라인)
services:
  app:
    image: my-nestjs-app
    logging:
      driver: json-file
      options:
        max-size: "10m"
        max-file: "3"

  # Filebeat → Elasticsearch 직접 전송
  filebeat:
    image: elastic/filebeat:8.12.0
    volumes:
      - /var/lib/docker/containers:/var/lib/docker/containers:ro
      - ./filebeat.yml:/usr/share/filebeat/filebeat.yml:ro
# filebeat.yml
filebeat.inputs:
  - type: container
    paths: ['/var/lib/docker/containers/*/*.log']
    processors:
      - decode_json_fields:
          fields: ["message"]
          target: ""
          overwrite_keys: true

output.elasticsearch:
  hosts: ["elasticsearch:9200"]
  index: "nestjs-logs-%{+yyyy.MM.dd}"

Pino의 JSON 출력은 ELK, Datadog, CloudWatch 등 모든 로그 수집 시스템과 파싱 없이 바로 연동됩니다.

성능 비교

  • Pino: ~30,000 ops/sec — Node.js 최고 성능. 비동기 직렬화, 워커 스레드 지원
  • Winston: ~10,000 ops/sec — 유연한 Transport, 다양한 플러그인
  • NestJS 내장: console.log 래퍼 — 프로덕션 부적합

고트래픽 API에서는 Pino가 압도적으로 유리합니다. pino.destination()으로 파일 직접 쓰기 시 비동기 I/O로 이벤트 루프 블로킹을 최소화합니다.

실전 체크리스트

  • JSON 구조화: 프로덕션은 반드시 JSON 포맷 (grep → jq로 검색)
  • Correlation ID: 모든 로그에 요청 추적 ID 포함
  • 민감 정보 마스킹: password, token, authorization 헤더 redact
  • 로그 레벨: dev=debug, staging=info, prod=warn (동적 변경 가능하게)
  • 에러 로깅: stack trace 포함, 구조화된 err 객체로
  • 요청/응답: method, url, statusCode, responseTime 기본 포함

마무리

NestJS 프로덕션 로깅의 핵심은 Pino + AsyncLocalStorage(CLS) 조합입니다. 구조화된 JSON 로그에 요청별 Correlation ID가 자동 포함되면, 마이크로서비스 환경에서도 요청 흐름을 완전히 추적할 수 있습니다. Interceptor로 요청/응답 로깅을 자동화하고, Pipe 검증 실패도 구조화된 에러 로그로 남기면 운영 가시성이 크게 향상됩니다.

위로 스크롤
WordPress Appliance - Powered by TurnKey Linux