NestJS 로깅 시스템 구조
NestJS는 내장 Logger 클래스를 제공하지만, 프로덕션에서는 구조화된 JSON 로깅, 요청 추적, 로그 레벨 제어가 필수입니다. 기본 Logger를 교체하고 Pino나 Winston을 통합하면 성능과 관측성을 동시에 확보할 수 있습니다. 이 글에서는 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을 선택합니다.