junyeokk
Blog
DevOps·2024. 11. 06

Winston 로거

Node.js 애플리케이션에서 console.log로 로그를 찍는 건 개발 중에는 편하지만, 프로덕션에서는 쓸 수 없다. 이유는 명확하다. 로그 레벨 구분이 안 되고, 파일로 저장이 안 되고, 포맷이 일정하지 않고, 로그가 쌓이면 디스크를 잡아먹는다. 이 문제들을 한 번에 해결하는 게 Winston이다.

Winston은 Node.js에서 가장 많이 사용되는 로깅 라이브러리다. 로그 레벨 분리, 다중 출력 대상(transport), 커스텀 포맷, 파일 로테이션까지 지원한다. NestJS에서는 nest-winston 패키지를 통해 NestJS의 기본 로거를 Winston으로 교체할 수 있다.


핵심 개념

Winston의 구조는 세 가지 축으로 이루어져 있다: 레벨, 포맷, 트랜스포트.

로그 레벨

Winston은 npm 로그 레벨을 기본으로 사용한다. 숫자가 낮을수록 심각도가 높다.

{ error: 0, warn: 1, info: 2, http: 3, verbose: 4, debug: 5, silly: 6 }

트랜스포트에 level: 'info'를 설정하면 info 이상(error, warn, info)만 기록된다. 이게 핵심이다. 프로덕션에서는 info 이상만 파일에 저장하고, 개발 환경에서는 debug까지 콘솔에 출력하는 식으로 환경별 로그 수준을 제어할 수 있다.

트랜스포트 (Transport)

트랜스포트는 로그가 어디로 출력되는지를 정의한다. Winston의 가장 강력한 기능 중 하나는 하나의 로거에 여러 트랜스포트를 동시에 연결할 수 있다는 점이다.

typescript
{ error: 0, warn: 1, info: 2, http: 3, verbose: 4, debug: 5, silly: 6 }

이 설정이면 모든 로그는 콘솔과 combined.log에 기록되고, error 레벨 로그는 error.log에 추가로 기록된다. 트랜스포트마다 독립적으로 레벨을 설정할 수 있어서, 에러 로그만 별도 파일로 분리하는 패턴이 가능하다.

기본 제공 트랜스포트:

트랜스포트설명
Console콘솔 출력
File파일 저장
HttpHTTP 엔드포인트로 전송
StreamNode.js 스트림으로 출력

포맷 (Format)

포맷은 로그 메시지가 어떤 형태로 출력되는지를 정의한다. Winston은 winston.format에 다양한 포맷터를 제공하고, combine()으로 여러 포맷을 조합할 수 있다.

typescript
import * as winston from 'winston';

const logger = winston.createLogger({
  transports: [
    // 콘솔에 출력
    new winston.transports.Console(),

    // 파일에 저장
    new winston.transports.File({ filename: 'error.log', level: 'error' }),
    new winston.transports.File({ filename: 'combined.log' }),
  ],
});

combine()에 전달되는 포맷터들은 순서대로 적용된다. timestamp()가 로그 객체에 timestamp 필드를 추가하고, label()label 필드를 추가하고, printf()가 최종 문자열을 생성한다. 파이프라인처럼 동작한다고 생각하면 된다.

주요 포맷터:

포맷터역할
timestamp()타임스탬프 추가
label()라벨 추가 (앱 이름, 모듈명 등)
printf()커스텀 출력 형식 정의
colorize()레벨별 색상 적용 (콘솔용)
json()JSON 형식 출력
errors({ stack: true })에러 스택트레이스 포함
splat()%s, %d 같은 문자열 보간 지원

로그 파일 로테이션

프로덕션에서 로그 파일이 무한히 커지면 디스크가 터진다. winston-daily-rotate-file은 날짜별로 로그 파일을 분리하고, 오래된 파일을 자동으로 삭제하거나 압축한다.

bash
const { combine, timestamp, label, printf, colorize } = winston.format;

const customFormat = printf(({ level, message, label, timestamp }) => {
  return `${timestamp} [${label}] ${level}: ${message}`;
});

const logger = winston.createLogger({
  format: combine(
    timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
    label({ label: 'my-app' }),
    customFormat,
  ),
});
typescript
npm install winston-daily-rotate-file

이 설정의 핵심 옵션들:

옵션설명
datePattern파일명에 포함될 날짜 패턴. 'YYYY-MM-DD'면 일별, 'YYYY-MM-DD-HH'면 시간별 분리
dirname로그 파일 저장 디렉토리
filename파일명 패턴. %DATE%가 datePattern으로 치환됨
maxFiles보관할 최대 파일 수. '30d'처럼 일 수로도 지정 가능
maxSize파일당 최대 크기. '20m'이면 20MB 초과 시 새 파일 생성
zippedArchivetrue면 로테이션된 파일을 gzip으로 압축

maxFiles: 30zippedArchive: true를 같이 사용하면, 30일치 로그만 유지하면서 오래된 로그는 압축해서 저장한다. 디스크 공간을 효율적으로 관리할 수 있다.

파일 구조 예시

위 설정으로 며칠간 운영하면 이런 구조가 된다:

logs/ ├── 2024-11-06.log ├── 2024-11-05.log.gz ├── 2024-11-04.log.gz └── error/ ├── 2024-11-06.error.log └── 2024-11-05.error.log.gz

당일 로그는 평문이고, 이전 날짜 로그는 gzip으로 압축된다.


NestJS 통합 (nest-winston)

NestJS에는 기본 로거(Logger)가 내장되어 있지만, 파일 저장이나 로테이션 같은 기능이 없다. nest-winston 패키지를 사용하면 NestJS의 로거를 Winston으로 교체할 수 있다.

bash
import * as DailyRotateFile from 'winston-daily-rotate-file';

const logger = winston.createLogger({
  transports: [
    new DailyRotateFile({
      level: 'info',
      datePattern: 'YYYY-MM-DD',
      dirname: './logs',
      filename: '%DATE%.log',
      maxFiles: 30,          // 30일치 보관
      zippedArchive: true,   // 오래된 로그 gzip 압축
    }),
    new DailyRotateFile({
      level: 'error',
      datePattern: 'YYYY-MM-DD',
      dirname: './logs/error',
      filename: '%DATE%.error.log',
      maxFiles: 30,
      zippedArchive: true,
    }),
  ],
});

모듈 등록

WinstonModule.forRoot()으로 앱 모듈에 등록한다.

typescript
logs/
├── 2024-11-06.log
├── 2024-11-05.log.gz
├── 2024-11-04.log.gz
└── error/
    ├── 2024-11-06.error.log
    └── 2024-11-05.error.log.gz

환경 분기가 핵심이다. 프로덕션에서는 파일 트랜스포트로 info와 error 로그를 분리 저장하고, 개발 환경에서는 콘솔에 색상 있는 로그를 출력한다. 두 환경에서 같은 logger.info() 호출을 사용하면서 출력 대상만 달라지는 것이다.

서비스에서 사용

WINSTON_MODULE_PROVIDER 토큰으로 주입받아 사용한다.

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

WINSTON_MODULE_NEST_PROVIDER를 사용하면 NestJS의 LoggerService 인터페이스에 맞는 래퍼가 주입된다. NestJS 내부 로그(부트스트랩, 라우트 매핑 등)도 Winston으로 출력하려면 이쪽을 사용해야 한다.

typescript
import { Module } from '@nestjs/common';
import { WinstonModule } from 'nest-winston';
import * as winston from 'winston';
import * as DailyRotateFile from 'winston-daily-rotate-file';

const isProduction = process.env.NODE_ENV === 'production';

@Module({
  imports: [
    WinstonModule.forRoot({
      format: winston.format.combine(
        winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
        winston.format.printf(({ level, message, timestamp }) => {
          return `${timestamp} ${level}: ${message}`;
        }),
      ),
      transports: isProduction
        ? [
            new DailyRotateFile({
              level: 'info',
              datePattern: 'YYYY-MM-DD',
              dirname: './logs',
              filename: '%DATE%.log',
              maxFiles: 30,
              zippedArchive: true,
            }),
            new DailyRotateFile({
              level: 'error',
              datePattern: 'YYYY-MM-DD',
              dirname: './logs/error',
              filename: '%DATE%.error.log',
              maxFiles: 30,
              zippedArchive: true,
            }),
          ]
        : [
            new winston.transports.Console({
              format: winston.format.combine(
                winston.format.colorize(),
                winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
                winston.format.printf(({ level, message, timestamp }) => {
                  return `${timestamp} ${level}: ${message}`;
                }),
              ),
            }),
          ],
    }),
  ],
})
export class AppModule {}

이렇게 하면 NestJS가 내부적으로 출력하는 모든 로그(모듈 초기화, 라우트 매핑, 에러 등)도 Winston 포맷으로 통일된다.


console.log vs Winston 비교

항목console.logWinston
로그 레벨❌ 없음 (log, warn, error 정도)✅ 7단계 레벨 (error~silly)
파일 저장❌ 직접 구현 필요✅ File 트랜스포트
로테이션❌ 없음✅ daily-rotate-file
포맷 커스텀❌ 제한적✅ printf, json, combine 등
다중 출력❌ stdout만✅ 콘솔 + 파일 + HTTP 동시
메타데이터❌ 없음✅ 객체로 추가 정보 전달
환경별 설정❌ 직접 분기✅ 트랜스포트 분기

다른 Node.js 로거와 비교

Winston 외에도 Node.js에서 많이 사용되는 로거가 있다.

Pino

Pino는 성능에 집중한 로거다. JSON 출력이 기본이고, Winston보다 5~10배 빠르다고 주장한다. 로그를 JSON으로 구조화해서 ELK 스택 같은 로그 수집 시스템과 연동하기 좋다. NestJS에서는 nestjs-pino 패키지로 통합할 수 있다.

typescript
import { Inject, Injectable } from '@nestjs/common';
import { WINSTON_MODULE_PROVIDER } from 'nest-winston';
import { Logger } from 'winston';

@Injectable()
export class SomeService {
  constructor(
    @Inject(WINSTON_MODULE_PROVIDER) private readonly logger: Logger,
  ) {}

  doSomething() {
    this.logger.info('작업 시작');
    try {
      // 비즈니스 로직
      this.logger.info('작업 완료');
    } catch (error) {
      this.logger.error('작업 실패', { error: error.message, stack: error.stack });
    }
  }
}

Winston은 커스텀 포맷과 다양한 트랜스포트가 강점이고, Pino는 순수 성능과 구조화된 로깅이 강점이다. 로그 볼륨이 극도로 많은 서비스에서는 Pino가 유리하고, 유연한 설정이 필요한 일반적인 서비스에서는 Winston이 더 편하다.

Morgan

Morgan은 HTTP 요청 전용 로거다. Express 미들웨어로 동작하며, 요청/응답 정보를 자동으로 기록한다. 애플리케이션 전체 로깅을 담당하는 Winston과는 역할이 다르다. 보통 Morgan으로 HTTP 로그를 찍고, Winston에 스트림으로 연결하는 조합으로 사용한다.

typescript
// main.ts에서 NestJS 전체 로거 교체
import { WINSTON_MODULE_NEST_PROVIDER } from 'nest-winston';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  app.useLogger(app.get(WINSTON_MODULE_NEST_PROVIDER));
  await app.listen(3000);
}

실전 팁

메타데이터 활용

Winston은 로그 메시지와 함께 구조화된 메타데이터를 전달할 수 있다. 디버깅할 때 매우 유용하다.

typescript
// Pino 기본 사용
import pino from 'pino';
const logger = pino({ level: 'info' });
logger.info({ userId: 123 }, 'User logged in');
// 출력: {"level":30,"time":1699228461000,"msg":"User logged in","userId":123}

JSON 포맷을 사용하면 이 메타데이터가 구조화된 형태로 저장되어서, 나중에 로그 검색 시스템에서 userId로 필터링하는 게 가능해진다.

에러 로깅 시 스택트레이스 포함

에러를 로깅할 때 메시지만 기록하면 디버깅이 어렵다. 반드시 스택트레이스를 포함시켜야 한다.

typescript
import morgan from 'morgan';

// Morgan의 출력을 Winston으로 스트리밍
app.use(
  morgan('combined', {
    stream: { write: (message) => logger.http(message.trim()) },
  }),
);

errors({ stack: true }) 포맷을 추가하면 Error 객체를 직접 전달해도 스택이 포함된다:

typescript
// 단순 문자열만
logger.info('사용자 로그인');

// 메타데이터와 함께
logger.info('사용자 로그인', {
  userId: 'user-123',
  ip: '192.168.1.1',
  userAgent: 'Mozilla/5.0...',
});

환경별 로그 레벨 제어

개발 환경에서는 debug 레벨까지, 스테이징에서는 info까지, 프로덕션에서는 warn 이상만 기록하는 식으로 제어할 수 있다.

typescript
try {
  await riskyOperation();
} catch (error) {
  // ❌ 이렇게 하면 안 됨
  logger.error(error.message);

  // ✅ 스택트레이스 포함
  logger.error('작업 실패', {
    error: error.message,
    stack: error.stack,
    context: { operationId: 'op-456' },
  });
}

환경 변수로 로그 레벨을 주입하면 코드 변경 없이 배포 시점에 로그 수준을 조절할 수 있다. 프로덕션에서 디버깅이 필요할 때 LOG_LEVEL=debug로 재시작하면 된다.


정리

  • 레벨/포맷/트랜스포트 3축 구조로, 환경별 출력 대상과 로그 수준을 코드 변경 없이 분리할 수 있다
  • daily-rotate-file로 날짜별 파일 분리, maxFiles 보관, gzip 압축까지 디스크 관리를 자동화한다
  • NestJS에서는 nest-winston + WINSTON_MODULE_NEST_PROVIDER로 프레임워크 내부 로그까지 Winston 포맷으로 통일할 수 있다

관련 문서