Krong Dev.
Backend Nest.js Logging

NestJS 스터디 9주차 회고 — 로그는 사람도 읽고, 시스템도 읽는다

NestJS Built-in Logger의 운영 환경 한계를 직시하고, Pino로 전환하는 과정을 정리했습니다. 구조화 로깅 설계부터 ExceptionFilter DI 연동, APP_FILTER 등록 방식까지의 형태가 되기까지의 구현 기록입니다.

NestJS 스터디 9주차 회고 — 로그는 사람도 읽고, 시스템도 읽는다

지난 글에서 HttpExceptionFilterAllExceptionsFilter를 만들면서,
필터 내부에 new Logger(HttpExceptionFilter.name)으로 NestJS의 기본 로거를 직접 생성해서 썼습니다.
그때는 ‘로그가 찍히면 됐지’라고 생각하고 넘어갔는데, 5주차 운영 준비 편을 다시 떠올리면서 의문이 생겼습니다.

Built-in Logger로 찍은 문자열 로그를, Datadog 같은 로그 수집 시스템이 제대로 파싱할 수 있을까?

HTTP 요청마다 인터셉터를 따로 만들지 않고도 자동으로 로그를 남길 수 있는 방법이 있을까?

이 글에서는 Built-in Logger의 한계를 직접 확인하고, Pino로 전환하는 과정을 정리합니다.


Built-in Logger를 떠나야 하는 이유

Built-in Logger의 한계

NestJS 기본 로거는 학습 단계에서 쓰기엔 충분하지만, 운영 환경에서는 다음 표처럼 빈칸이 많아집니다.

필요한 기능Built-in Logger외부 모듈
콘솔 출력
JSON 포맷 출력
파일 저장
외부 서비스 전송 (Datadog, CloudWatch 등)
HTTP 요청 자동 로깅✅ (Pino)
구조화 로깅 (메타데이터 분리)
비동기 로깅 (성능 영향 없음)✅ (Pino)

로그 수집 시스템(ELK Stack, Datadog 등)은 구조화된 필드 추출을 쉽게 하도록 JSON을 선호합니다.
Built-in Logger의 문자열 출력은 구조화 로깅, 필드 검색, 외부 수집 연동, 로그 보관 기능을 기본으로 제공하지 않습니다.

Winston vs Pino

외부 로깅 라이브러리 후보는 두 가지입니다.

Winston

Node.js 생태계에서 가장 오래되고 널리 쓰이는 로깅 라이브러리입니다. NestJS와 통합하려면 nest-winston 패키지를 사용합니다.

  • Transport 시스템: 콘솔, 파일, 외부 서비스에 동시 전송 가능
  • 높은 설정 자유도: 원하는 형태로 커스터마이징 가능
  • 풍부한 레퍼런스: 한국어 자료를 포함해 자료가 가장 많음
WinstonModule.forRoot({
  transports: [
    new winston.transports.Console({
      format: winston.format.combine(
        winston.format.timestamp(),
        winston.format.colorize(),
        winston.format.printf(({ timestamp, level, message, context }) => {
          return `${timestamp} [${context}] ${level}: ${message}`;
        }),
      ),
    }),
    new winston.transports.File({ filename: "error.log", level: "error" }),
  ],
});

기본 출력이 JSON이 아니라 직접 설정해야 하고, Pino 대비 느립니다. HTTP 요청 자동 로깅도 없습니다.


Pino

성능을 최우선으로 설계한 로깅 라이브러리입니다. NestJS와 통합하려면 nestjs-pino 패키지를 사용합니다.

  1. 기본 출력이 JSON: 별도 설정 없이 바로 로그 수집 시스템에 적합
  2. 구조화 로깅 친화적: 메시지와 메타데이터를 분리해서 검색/필터링이 강력
  3. 비동기 로깅: 로그 작업이 메인 이벤트 루프를 막지 않음
  4. HTTP 요청 자동 로깅: 별도 인터셉터 없이 모든 요청/응답이 자동 기록됨
// 구조화 로깅: 메시지 + 메타데이터 분리
this.logger.info({ userId: 5, action: "login", ip: "1.2.3.4" }, "User logged in");
// → userId=5인 사용자의 모든 액션 조회 가능
// → 특정 IP에서 발생한 모든 로그 조회 가능

한국어 자료가 거의 없고, 사람이 읽기 좋은 출력을 위해 pino-pretty를 별도로 설치해야 한다는 점이 단점입니다.


비교와 선택

기준WinstonPino
성능보통✅ Winston 대비 5~10배 빠름
기본 JSON 출력❌ (설정 필요)
HTTP 자동 로깅
구조화 로깅가능✅ 자연스러움
한국어 자료✅ 많음❌ 적음
설정 자유도✅ 높음중간
운영 환경 적합성좋음✅ 더 좋음

이 프로젝트는 Pino를 선택했습니다.
장기적으로 운영 환경까지 보고 있고, JSON 출력과 HTTP 자동 로깅이 처음부터 되는 게 유리합니다.
한국어 자료가 없어서 직접 부딪혀야 하는 과정 자체가 학습이 된다는 판단도 있었습니다.


Pino 설치부터 ExceptionFilter 연동까지

설치 및 기본 설정

pnpm add nestjs-pino pino-http
pnpm add -D pino-pretty

nestjs-pino는 NestJS용 래퍼이고, pino-http는 HTTP 요청 자동 로깅을 위한 미들웨어입니다.
pino-pretty는 개발 환경에서 JSON 로그를 사람이 읽기 좋게 변환해주는 개발 전용 패키지입니다.

// src/app.module.ts
import { LoggerModule } from "nestjs-pino";

@Module({
  imports: [
    LoggerModule.forRoot({
      pinoHttp: {
        transport: process.env.NODE_ENV !== "production" ? { target: "pino-pretty", options: { colorize: true, singleLine: true } } : undefined,
        level: process.env.NODE_ENV === "production" ? "info" : "debug",
        autoLogging: {
          ignore: (req) => req.url === "/health",
        },
        redact: ["req.headers.authorization", "req.headers.cookie"],
        serializers: {
          req(req) {
            return { method: req.method, url: req.url };
          },
          res(res) {
            return { statusCode: res.statusCode };
          },
        },
      },
    }),
  ],
})
export class AppModule {}

serializers 개념

pino-http는 HTTP 요청이 들어오면 req, res 객체를 통째로 직렬화해서 로그에 찍습니다.
설정 없이 두면 헤더, 파라미터, 소켓 정보까지 전부 포함된 긴 JSON이 됩니다.

실행 결과
무차별 JSON 직렬화 공격

serializers는 “이 객체를 로그에 찍을 때 이렇게 변환해라”를 직접 정의하는 함수입니다.

요청 들어옴

pino-http가 req 객체 직렬화 시도

serializers.req 함수가 있으면 → 해당 함수 결과로 대체

로그에 찍힘

methodurl만 남기도록 정의하면, 불필요한 필드 없이 핵심 정보만 로그에 남습니다.


main.ts에서 Logger 교체

// src/main.ts
import { Logger } from "nestjs-pino";

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

  app.useLogger(app.get(Logger));

  await app.listen(port);
}

bufferLogs: true를 쓰는 이유가 있습니다.
NestJS 부팅 초반에 발생하는 로그(모듈 초기화 등)가 Pino가 준비되기 전에 출력되면 포맷이 섞입니다.
이 옵션을 켜두면 그 로그들을 버퍼에 모아뒀다가 Pino가 준비된 시점에 한꺼번에 출력합니다.

실행 결과

Service와 Controller에서 Logger 쓰기

Built-in Logger에서 쓰던 new Logger() 방식에서 DI 주입 방식으로 바뀝니다.

// ❌ Built-in Logger 방식 (기존)
import { Logger } from "@nestjs/common";

export class TodosService {
  private readonly logger = new Logger(TodosService.name);

  async findAll() {
    this.logger.log("투두 목록 조회");
  }
}
// ✅ Pino 방식 (DI 주입)
import { InjectPinoLogger, PinoLogger } from "nestjs-pino";

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

  async findAll(userId: number) {
    this.logger.log({ userId, action: "findAll" }, "투두 목록 조회");
  }

  async deleteMe(userId: number) {
    this.logger.warn({ userId }, "회원 탈퇴 요청");
  }
}

@InjectPinoLogger(컨텍스트명)을 쓰면 어떤 클래스에서 발생한 로그인지 자동으로 context 필드에 기록됩니다.
로그를 보자마자 어느 서비스에서 찍힌 건지 알 수 있습니다.

ExceptionFilter 연동 — DI가 핵심이다

기존 필터에서는 new Logger()로 Built-in Logger를 직접 생성했습니다. 이를 Pino DI 방식으로 교체합니다.

HttpExceptionFilter 업데이트

// src/common/filters/http-exception.filter.ts
import { PinoLogger, InjectPinoLogger } from "nestjs-pino";

@Catch(HttpException)
export class HttpExceptionFilter implements ExceptionFilter {
  constructor(
    @InjectPinoLogger(HttpExceptionFilter.name)
    private readonly logger: PinoLogger,
  ) {}

  catch(exception: HttpException, host: ArgumentsHost) {
    const ctx = host.switchToHttp();
    const response = ctx.getResponse<Response>();
    const request = ctx.getRequest<Request>();
    const status = exception.getStatus();

    const exceptionResponse = exception.getResponse();
    const message = typeof exceptionResponse === "string" ? exceptionResponse : (exceptionResponse as Record<string, string>).message;

    if (status >= 500) {
      this.logger.error({ method: request.method, url: request.url, status }, "서버 내부 오류");
    } else {
      this.logger.warn({ method: request.method, url: request.url, status, message }, "HTTP 예외 발생");
    }

    response.status(status).json({ success: false, statusCode: status, message, data: null });
  }
}

AllExceptionsFilter 업데이트

// src/common/filters/all-exceptions.filter.ts
@Catch()
export class AllExceptionsFilter implements ExceptionFilter {
  constructor(
    @InjectPinoLogger(AllExceptionsFilter.name)
    private readonly logger: PinoLogger,
  ) {}

  catch(exception: unknown, host: ArgumentsHost) {
    const ctx = host.switchToHttp();
    const request = ctx.getRequest<Request>();
    const response = ctx.getResponse<Response>();

    this.logger.error(
      {
        method: request.method,
        url: request.url,
        error: exception instanceof Error ? exception.message : String(exception),
        stack: exception instanceof Error ? exception.stack : undefined,
      },
      "처리되지 않은 예외 발생",
    );

    response.status(HttpStatus.INTERNAL_SERVER_ERROR).json({
      success: false,
      statusCode: HttpStatus.INTERNAL_SERVER_ERROR,
      message: "서버 내부 오류가 발생했습니다.",
      data: null,
    });
  }
}

APP_FILTER로 전역 등록

Filter에서 생성자 주입을 쓰기 때문에, 기존처럼 useGlobalFilters에서 new로 만들면 안 됩니다.

// ❌ 이렇게 하면 PinoLogger가 undefined가 됩니다.
app.useGlobalFilters(new HttpExceptionFilter());

new HttpExceptionFilter()는 NestJS DI 컨테이너를 완전히 우회합니다.
Node.js가 직접 메모리에 인스턴스를 올리기 때문에 NestJS는 이 객체의 존재 자체를 모릅니다.
생성자에 선언된 PinoLogger를 어디서 가져와야 하는지 알 방법이 없으니, loggerundefined가 되고 런타임에서 터집니다.

해결책은 APP_FILTER로 전역 등록하는 것입니다.

// src/app.module.ts
import { APP_FILTER } from "@nestjs/core";

@Module({
  providers: [
    { provide: APP_FILTER, useClass: AllExceptionsFilter },
    { provide: APP_FILTER, useClass: HttpExceptionFilter },
  ],
})
export class AppModule {}

APP_FILTER는 NestJS에 “이 클래스를 DI 컨테이너 안에서 전역 필터로 등록해라”라는 의미입니다.
NestJS가 직접 인스턴스를 만들고, 생성자에 필요한 PinoLogger를 주입합니다.

등록 순서가 중요합니다. APP_FILTER는 등록 순서의 역순으로 실행됩니다.
AllExceptionsFilter가 모든 예외의 최종 처리자가 되어야 하므로, 먼저 등록해야 나중에 실행됩니다.


구조화 로깅 설계

환경별 로그 출력 비교

개발 환경에서는 pino-pretty가 JSON을 사람이 읽기 좋은 형태로 변환합니다.
레벨별 색상, 타임스탬프, context 이름이 한눈에 들어옵니다.

실행 결과

운영 환경에서는 transportundefined로 두면 Pino의 기본 JSON 출력이 그대로 나옵니다.
이 형태가 Datadog, CloudWatch, ELK Stack 같은 로그 수집 시스템에서 바로 파싱 가능합니다.

{
  "level": 30,
  "time": 1748044800000,
  "pid": 12345,
  "req": { "method": "POST", "url": "/auth/login" },
  "res": { "statusCode": 401 },
  "context": "HttpExceptionFilter",
  "status": 401,
  "message": "HTTP 예외 발생",
  "responseTime": 5
}

실행 결과

Built-in Logger의 문자열 출력은 이 수준의 파싱이 불가능합니다.
Pino로 전환하면 추가 설정 없이 이 포맷으로 찍힙니다.

무엇을, 어떻게 남길까

Pino로 전환했다고 끝이 아닙니다. 로그를 어떻게 구성하느냐가 나중에 운영 효율을 결정합니다.

단순 문자열 vs 구조화 로깅

// ❌ 단순 문자열 로깅 — 검색/필터링 어려움
this.logger.log(`User ${userId} logged in from ${ip}`);

// ✅ 구조화 로깅 — 메타데이터 분리
this.logger.info({ userId, ip, action: "login" }, "사용자가 로그인했습니다.");

구조화 로깅의 장점은 로그 수집 시스템에서 userId=5인 모든 요청만 필터링하거나, action=login인 이벤트만 집계하거나, 응답 시간 통계 같은 수치 데이터를 분석할 수 있다는 점입니다.


구조화 객체 스키마

매번 Key를 다르게 주기보다는, 성격별로 최상위 Key를 분리하여 규격화하는 것이 Kibana, Grafana, Datadog 등에서 필터를 걸 때 유리합니다.

{
  "context": "인증/유저/시스템 등 대분류",
  "action": "행위_동사형태",
  "user": { "id": "유저 식별자", "role": "권한" },
  "network": { "ip": "접속 IP", "userAgent": "브라우저/앱 정보" },
  "payload": { "추가적인_비즈니스_데이터": "값" }
}

상황별 구조화 예시

1. 비즈니스 이벤트 (로그인, 결제 완료 등)

사용자 행위를 추적할 때는 핵심 식별자(user)와 행위(action)를 분리합니다.

this.logger.info(
  {
    context: "auth",
    action: "login_success",
    user: { id: userId, role: "premium" },
    network: { ip },
  },
  "사용자가 로그인했습니다.",
);

2. 외부 API 호출 (HTTP/gRPC 통신)

외부 시스템과의 연동 로그는 나중에 “누구 책임인가”를 가릴 때 필수적입니다.
대상(target)과 소요 시간(durationMs)을 포함합니다.

this.logger.info(
  {
    context: "payment_gateway",
    action: "request_charge",
    target: "toss_payments",
    payload: { orderId, amount: 50000 },
    durationMs: 142,
  },
  "결제 승인 API를 호출합니다.",
);

3. 에러 발생 (Catch 블록)

에러 로그는 실패한 원인 정보(payload)와 에러의 실체(err)를 분리해야 원인 파악이 쉽습니다.
Pino는 객체 내에 err 또는 error key가 있으면 스택 트레이스를 자동으로 직렬화합니다.

this.logger.error(
  {
    context: "user",
    action: "create_user_failed",
    payload: { email: newUserDto.email },
    err: error,
  },
  "사용자 생성 중 오류가 발생했습니다.",
);

구조화 객체를 짤 때 지켜야 할 3가지 규칙

구조화 객체를 짤 때 지켜야 할 3가지 규칙은 다음과 같습니다.

1. Key 이름은 프로젝트 전체에서 통일

어떤 곳은 userId, 어떤 곳은 user_id라고 쓰면 로그 솔루션에서 통합 검색을 못합니다.
Key 이름 하나가 일관성을 깨뜨립니다.

2. 민감 정보는 마스킹/제외

비밀번호, 주민번호, 신용카드 번호, access_token 등은 절대 구조화 객체에 날것으로 넣으면 안 됩니다.
AppModuleredact 옵션으로 제거하거나, 애초에 로그 변수에서 제외해야 합니다.

3. 데이터 타입 일치시키기

하나의 Key(예: payload.amount)에 숫자 50000과 문자열 '50,000원'이 혼재하면, Elasticsearch 같은 검색 엔진에서 인덱싱 에러가 발생하고 로그가 유실됩니다.


레벨별 사용 기준

레벨사용 시나리오
errorDB 연결 실패, 처리되지 않은 예외, 5xx 에러
warn4xx 에러, 비정상적이지만 서비스는 동작, 느린 쿼리 감지
info로그인 성공, 주요 비즈니스 이벤트, 서버 시작
debug함수 진입/종료, 쿼리 파라미터, 개발 환경 전용

주의사항

redact 설정은 필수

HTTP 요청 자동 로깅이 켜지면 Authorization 헤더와 Cookie도 로그에 찍힙니다.
반드시 redact 설정으로 제거해야 합니다.

pinoHttp: {
  redact: [
    'req.headers.authorization',
    'req.headers.cookie',
    'req.body.password',
  ],
}

redact를 빠뜨리면 토큰과 세션 정보가 로그 파일과 수집 시스템에 평문으로 남습니다.


APP_FILTER 순서

APP_FILTER는 등록 순서의 역순으로 실행됩니다.
AllExceptionsFilter가 마지막 처리자가 되어야 하므로, 먼저 등록해야 나중에 실행됩니다.

// AllExceptions를 먼저 등록 → 실행은 나중에 (최종 안전망)
{ provide: APP_FILTER, useClass: AllExceptionsFilter },
{ provide: APP_FILTER, useClass: HttpExceptionFilter },

순서가 바뀌면 AllExceptionsFilter가 먼저 실행되어 HttpException도 전부 500으로 처리하는 사고가 납니다.


마치며

Built-in Logger의 한계를 확인하고 Pino로 전환했습니다. nestjs-pino로 HTTP 요청 자동 로깅을 얻었고, @InjectPinoLogger로 Service와 Filter 모두에서 DI 방식으로 일관되게 쓸 수 있게 됐습니다.

직접 구현하면서 세 가지가 확실히 와닿았습니다.

  • Built-in Logger는 콘솔 출력 이상을 제공하지 않는다. 운영 환경에서 필요한 JSON 포맷, 구조화 로깅, 외부 수집 연동은 처음부터 외부 모듈로 시작하는 게 낫다.
  • 구조화 로깅은 형식이 아니라 설계다. Key 이름을 통일하고, 민감 정보를 제거하고, 타입을 일치시키는 세 가지 규칙을 지켜야 로그가 운영에서 실제로 쓸모 있어진다.
  • new Logger() 대신 @InjectPinoLogger()를 쓰는 순간, Filter도 useGlobalFilters에서 APP_FILTER로 등록 방식을 바꿔야 한다. 이 둘은 함께 바뀐다.

다음 글에서는 E2E와 단위 테스트로 검증 실패 및 XSS 등 방어 라인을 직접 깨 보고 테스트의 효과를 체감하는 과정을 정리할 예정입니다.