NestJS Pino 구조화 로깅 심화

NestJS 로깅 시스템 구조

NestJS는 내장 Logger 클래스를 제공하지만, 프로덕션에서는 구조화된 JSON 로깅, 요청 추적, 로그 레벨 제어가 필수입니다. 기본 Logger를 교체하고 PinoWinston을 통합하면 성능과 관측성을 동시에 확보할 수 있습니다. 이 글에서는 NestJS 로깅 아키텍처, Pino 통합, 요청 컨텍스트 전파, 구조화 로깅 패턴을 다룹니다.

내장 Logger 이해

NestJS의 기본 Logger는 ConsoleLogger입니다.

import { Logger, Injectable } from '@nestjs/common';

@Injectable()
export class UserService {
  private readonly logger = new Logger(UserService.name);

  findOne(id: string) {
    this.logger.log(`사용자 조회: ${id}`);          // [UserService] 사용자 조회: 123
    this.logger.warn(`느린 쿼리 감지: ${id}`);       // [UserService] 느린 쿼리 감지: 123
    this.logger.error(`조회 실패: ${id}`, stack);    // [UserService] 조회 실패: 123
    this.logger.debug(`캐시 미스: ${id}`);           // debug 레벨
    this.logger.verbose(`상세 정보: ${id}`);         // verbose 레벨
  }
}

// main.ts에서 로그 레벨 제어
const app = await NestFactory.create(AppModule, {
  logger: ['error', 'warn', 'log'],  // debug, verbose 제외
});

내장 Logger의 한계: JSON 포맷 미지원, 요청별 컨텍스트 전파 불가, 파일/외부 전송 미지원. 프로덕션에서는 반드시 전문 로거로 교체해야 합니다.

Pino 통합: 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: {
        level: process.env.NODE_ENV === 'production' ? 'info' : 'debug',
        transport: process.env.NODE_ENV !== 'production'
          ? { target: 'pino-pretty', options: { colorize: true, singleLine: true } }
          : undefined,  // 프로덕션: JSON 그대로

        // 요청/응답 자동 로깅 커스터마이징
        serializers: {
          req(req) {
            return {
              method: req.method,
              url: req.url,
              query: req.query,
              // body는 민감정보 주의
            };
          },
          res(res) {
            return { statusCode: res.statusCode };
          },
        },

        // 자동 생성 필드
        genReqId: (req) => req.headers['x-request-id'] || crypto.randomUUID(),

        // 특정 경로 로깅 제외
        autoLogging: {
          ignore: (req) => ['/health', '/metrics'].includes(req.url),
        },

        // 커스텀 로그 레벨 매핑
        customLogLevel: (req, res, err) => {
          if (res.statusCode >= 500 || err) return 'error';
          if (res.statusCode >= 400) return 'warn';
          return 'info';
        },
      },
    }),
  ],
})
export class AppModule {}

// main.ts — NestJS 내장 Logger 교체
import { Logger } from 'nestjs-pino';

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

bufferLogs: true는 앱 초기화 중 로그를 버퍼링했다가 Pino Logger가 준비되면 한꺼번에 출력합니다. 초기화 로그도 놓치지 않습니다.

요청 컨텍스트 전파: PinoLogger

요청별로 고유한 requestId를 모든 로그에 자동 포함하는 것이 핵심입니다.

import { PinoLogger, InjectPinoLogger } from 'nestjs-pino';

@Injectable()
export class OrderService {
  constructor(
    @InjectPinoLogger(OrderService.name)
    private readonly logger: PinoLogger,
  ) {}

  async createOrder(dto: CreateOrderDto) {
    // requestId가 자동으로 포함됨!
    this.logger.info({ orderId: dto.orderId }, '주문 생성 시작');

    const order = await this.orderRepository.save(dto);
    this.logger.info({ orderId: order.id, amount: order.amount }, '주문 생성 완료');

    // 구조화된 에러 로깅
    try {
      await this.paymentService.process(order);
    } catch (error) {
      this.logger.error({ orderId: order.id, err: error }, '결제 실패');
      throw error;
    }

    return order;
  }
}

출력 예시 (JSON):

{"level":30,"time":1711324920000,"pid":1,"hostname":"api-pod-abc",
 "req":{"id":"550e8400-e29b-41d4-a716-446655440000"},
 "context":"OrderService",
 "orderId":"ORD-1001","amount":50000,
 "msg":"주문 생성 완료"}

req.id가 동일 요청의 모든 로그에 자동 포함되므로, ELK나 Loki에서 하나의 요청 흐름을 필터링할 수 있습니다.

커스텀 Logger 서비스

Pino 위에 팀 컨벤션을 추가한 래퍼를 만듭니다.

@Injectable()
export class AppLogger {
  constructor(private readonly pino: PinoLogger) {}

  // 비즈니스 이벤트 로깅
  event(name: string, data: Record<string, any>) {
    this.pino.info({ event: name, ...data }, `[EVENT] ${name}`);
  }

  // 성능 측정
  startTimer(label: string): () => void {
    const start = performance.now();
    return () => {
      const duration = Math.round(performance.now() - start);
      this.pino.info({ label, durationMs: duration }, `[PERF] ${label}: ${duration}ms`);
    };
  }

  // 외부 API 호출 로깅
  externalCall(service: string, method: string, url: string, status: number, ms: number) {
    const level = status >= 400 ? 'warn' : 'info';
    this.pino[level](
      { external: { service, method, url, status, durationMs: ms } },
      `[EXT] ${service} ${method} ${url} → ${status} (${ms}ms)`,
    );
  }
}

// 사용
@Injectable()
export class PaymentService {
  constructor(private readonly log: AppLogger) {}

  async process(order: Order) {
    const stop = this.log.startTimer('payment.process');

    const result = await this.pgClient.charge(order);
    this.log.externalCall('PG', 'POST', '/v1/payments', 200, 340);
    this.log.event('payment.completed', { orderId: order.id, pgId: result.id });

    stop();  // [PERF] payment.process: 352ms
  }
}

로그 레벨 동적 변경

재배포 없이 런타임에 로그 레벨을 변경하는 패턴입니다.

@Controller('admin/logger')
@Auth('admin')
export class LoggerController {
  constructor(@Inject('PinoLogger') private readonly logger: any) {}

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

  @Put('level')
  setLevel(@Body('level') level: string) {
    const validLevels = ['fatal', 'error', 'warn', 'info', 'debug', 'trace'];
    if (!validLevels.includes(level)) {
      throw new BadRequestException(`유효한 레벨: ${validLevels.join(', ')}`);
    }
    this.logger.logger.level = level;
    return { level, message: `로그 레벨이 ${level}로 변경되었습니다` };
  }
}

// 사용: curl -X PUT /admin/logger/level -d '{"level":"debug"}'
// → 재배포 없이 즉시 debug 로그 활성화

프로덕션 장애 시 일시적으로 debug 레벨을 활성화하고, 문제 해결 후 다시 info로 복원하는 패턴입니다. NestJS Middleware로 요청별 로그 레벨을 동적 제어할 수도 있습니다.

Winston 통합 대안

Pino 대신 Winston을 선호하는 팀을 위한 설정입니다.

// npm install nest-winston winston winston-daily-rotate-file

import { WinstonModule, utilities } from 'nest-winston';
import * as winston from 'winston';
import 'winston-daily-rotate-file';

@Module({
  imports: [
    WinstonModule.forRoot({
      transports: [
        // 콘솔 (개발)
        new winston.transports.Console({
          format: winston.format.combine(
            winston.format.timestamp(),
            utilities.format.nestLike('MyApp', { prettyPrint: true }),
          ),
        }),
        // 파일 로테이션 (프로덕션)
        new winston.transports.DailyRotateFile({
          filename: 'logs/app-%DATE%.log',
          datePattern: 'YYYY-MM-DD',
          maxSize: '100m',
          maxFiles: '30d',
          format: winston.format.combine(
            winston.format.timestamp(),
            winston.format.json(),
          ),
        }),
        // 에러 전용 파일
        new winston.transports.DailyRotateFile({
          filename: 'logs/error-%DATE%.log',
          datePattern: 'YYYY-MM-DD',
          level: 'error',
          maxSize: '50m',
          maxFiles: '90d',
        }),
      ],
    }),
  ],
})
항목 Pino Winston
성능 5배 빠름 보통
요청 컨텍스트 자동 (AsyncLocalStorage) 수동 구현
Transport 별도 프로세스 인프로세스
파일 로테이션 pino-roll 내장 지원
추천 고성능 API 다양한 전송 필요 시

Pino는 로그를 stdout으로 출력하고 전송은 별도 프로세스(pino-transport)가 처리하므로 메인 스레드를 블로킹하지 않습니다. NestJS 성능 최적화에서 다루는 이벤트 루프 보호와 직결됩니다.

정리

NestJS 프로덕션 로깅의 핵심은 구조화된 JSON + 요청 컨텍스트 자동 전파입니다. nestjs-pino로 Pino를 통합하면 requestId가 모든 로그에 자동 포함되어 분산 추적이 가능합니다. 커스텀 Logger 서비스로 팀 컨벤션을 강제하고, 런타임 로그 레벨 변경으로 장애 대응 속도를 높이세요. 성능이 중요하면 Pino, 다양한 전송이 필요하면 Winston을 선택합니다.

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