Krong Dev.
Backend Nest.js Logging

NestJS 운영 준비 — 로그 레벨 설계와 환경 변수 분리

NestJS에서 로그 레벨을 환경별로 설계하고, Built-in Logger로 실습한 뒤 @nestjs/config로 환경 변수를 분리하는 흐름까지 정리합니다.

NestJS 운영 준비 — 로그 레벨 설계와 환경 변수 분리

애플리케이션을 운영할 때 에러가 발생하는 경우가 정말 많습니다.

어떤 부분에서 문제가 발생하였고, 어떠한 곳에서 잘못되었는지 어떻게 파악할까?

비밀번호나 API Key 같은 민감한 값을 코드에서 어떻게 분리할까?

원래 로그는 개발하면서 넣는 것이 정석이지만, 배우는 단계에서는 한 번에 많은 개념이 섞인 채로 배우게 되면 학습 포커싱에 우려가 있을 수 있다고 판단하여 로깅을 마지막에 넣는 것으로 하였습니다. 이 글에서는 로그 레벨 설계환경 변수 분리를 직접 구현하며 정리합니다.


로그 남기기

로그의 종류

로그는 애플리케이션의 상태를 기록하는 방식에 따라 다섯 가지로 나뉩니다.

Log (정보 로그)

정상 동작 흐름에서 사실을 기록합니다. 에러가 아닌 시스템이 예상대로 동작하고 있다는 것을 추적하는 용도입니다.

  • 서버 시작 시: Application started on port 3000
  • 사용자 로그인 성공 시: User whtjdals logged in
  • 결제 완료 시: Payment processed for order #12345

이와 같이 ‘누가, 언제, 무엇을 했는지’를 추적해야 할 때 감사(audit) 목적, 트래픽 분석, 비즈니스 이벤트 추적 등으로 쓰입니다.


Warning (경고)

문제는 발생했지만 시스템은 계속 동작할 수 있는 상황입니다.

  • 외부 API 응답이 3초 이상 느려질 때 — 동작은 하지만 timeout 우려
  • 캐시 미스율 증가
  • 디스크 사용률 80% 도달

모니터링 알림의 1차 트리거로 자주 쓰입니다.


Error (에러)

치명적인 문제가 발생해서 정상 동작이 불가능한 상황입니다. 즉시 조치가 필요합니다.

  • DB 연결 실패
  • 외부 결제 API 500 에러
  • 처리되지 않은 예외(unhandled exception)
  • 트랜잭션 롤백

try-catch 블록의 catch 지점에서 거의 항상 사용합니다.


Debug (디버그)

개발자가 코드 흐름을 추적하기 위한 상세 정보입니다. 개발자용

개발 환경에서만 활성화해 두고, 운영 환경에서는 사용하지 않습니다. 운영 환경에서는 로그 양이 폭증하면서 성능 문제가 생기고, 정작 중요한 로그가 묻혀 버리기 때문에 환경별로 로그 레벨을 다르게 설정합니다.


Verbose (상세)

Debug보다 더 세밀한 정보로, 깊은 디버깅이 필요할 때 일시적으로 사용합니다. 개발자용(상세)

  • 내부 큐의 현재 상태
  • 트랜잭션 격리 수준
  • 매 요청마다의 헤더/쿼리 파라미터 전체 덤프

거의 사용하지 않습니다. 정말 깊은 디버깅이 필요할 때 일시적으로 사용하는 용도입니다.

로그 레벨과 환경별 설정

로그 레벨에는 계층 구조가 있습니다.

Verbose < Debug < Log < Warning < Error
(가장 상세)                       (가장 심각)

NestJS에서는 main.tslogger 옵션에 배열로 출력할 레벨을 명시합니다. 예를 들어 ['warn', 'error']로 지정하면 Warning과 Error만 출력되고, 나머지 레벨은 무시됩니다.

환경로그 레벨이유
로컬 개발Debug 또는 Verbose개발자가 코드 흐름을 다 봐야 하니까
테스트(CI)Error 또는 Warning테스트 출력이 깔끔해야 함, 실패 시에만 정보 필요
스테이징Log운영과 비슷하되 약간 더 자세하게
운영(Production)Log 또는 Warning디스크/성능 보호, 필요한 정보만

NestJS에서 main.ts에서의 설정입니다.

const app = await NestFactory.create(AppModule, {
  logger:
    process.env.NODE_ENV === "production"
      ? ["log", "warn", "error"] // 운영
      : ["log", "warn", "error", "debug", "verbose"], // 개발
});

로그를 처리하기 위해 사용하는 모듈

로깅 라이브러리는 크게 세 가지를 고려할 수 있습니다.

NestJS Built-in Logger

NestJS가 기본으로 제공하는 Logger 클래스입니다.

실행 결과

서버 부팅하면 나오는 형식과 동일합니다. NestJS 내부도 이 Logger를 사용하고, 같은 형식으로 로그를 남길 수 있습니다.

  • 장점: 별도 설치가 필요 없으며, NestJS 생태계와 자연스럽게 통합되기 때문에 학습 단계에 적합합니다.
  • 단점: 콘솔 출력만 가능하고, 파일로 저장하거나 JSON 형태로 직렬화하거나, 외부 시스템(CloudWatch, Datadog 등)으로 전송하는 기능이 없습니다.

Winston

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

NestJS Built-in Logger에서 콘솔 출력만으로는 운영 환경에서 많이 부족하기 때문에 다음과 같은 기능들이 필요합니다.

  1. 파일 저장(Transport): 로그를 파일로 저장하고, 일별/크기별로 자동 로테이션
  2. 여러 출력 대상 동시 지원: 콘솔 + 파일 + 외부 서비스에 동시 전송
  3. JSON 포맷: 로그 수집 시스템(ELK, Datadog 등)이 파싱 가능한 형태
  4. 구조화된 로깅(structured logging): 단순 문자열이 아니라 키-값 메타데이터 포함
  5. 필터링과 변환: 민감 정보 마스킹, 환경별 다른 포맷

Pino

성능을 최우선으로 설계한 Node.js 로깅 라이브러리입니다.

  1. 기본 출력이 JSON 문자열: Winston은 다양한 포맷을 지원하느라 내부적으로 객체 변환과 포맷팅 단계를 여러 번 거칩니다. 반면, Pino는 처음부터 바로 JSON으로 반환하기 때문에 빠릅니다.
  2. 구조화 로깅 친화적: Pino의 진짜 강점은 로그가 처음부터 구조화된 데이터로 설계되었다는 점입니다. 메시지와 메타데이터가 분리되어 있어, 로그 수집 시스템(Datadog, Elasticsearch, CloudWatch 등)에서 필터링과 검색이 강력합니다.
// 메시지 + 메타데이터 분리
this.logger.info({ userId: 5, action: "login", ip: "1.2.3.4" }, "User logged in");

이렇게 찍으면 ‘user_id가 5인 사용자의 모든 액션과 검색’, ‘특정 IP에서 발생한 모든 로그 검색’ 같은 쿼리가 자유로워집니다.

  1. 비동기 로깅 적극 활용: 로그를 쓰는 작업이 메인 이벤트 루프를 막지 않도록 설계되어 있어, 로그가 많이 발생해도 애플리케이션 응답 속도에 영향이 거의 없습니다.
  2. HTTP 요청 자동 로깅: nestjs-pino는 모든 HTTP 요청을 자동으로 로깅하는 기능을 기본으로 제공합니다. 별도 인터셉터를 작성하지 않아도 요청 메서드, URL, 응답 상태 코드, 처리 시간이 자동으로 기록됩니다.
  3. 사람이 읽기 좋은 컬러 출력 기능(pino-pretty)도 있어 별도 도구 사용이 가능합니다.
  • 장점: 압도적인 성능(Winston 대비 5~10배), 기본 JSON 출력으로 운영 환경에 바로 적합, HTTP 요청 자동 로깅, 구조화 로깅이 자연스러움, 최신 Node.js 베스트 프랙티스를 반영한 설계
  • 단점: Winston보다 자료/예제가 적음(한국어 자료는 특히 부족), 사람이 읽기 좋은 콘솔 출력을 위해 pino-pretty라는 별도 도구가 필요, 커스터마이징 유연성은 Winston이 더 높음, 데코레이터 기반 DI 패턴이 NestJS Built-in Logger보다 살짝 번거로움

이번에는 로깅에 대한 전반적인 개념을 다루기 위해 NestJS Built-in Logger를 사용해 보고자 합니다. Winston, Pino처럼 운영 환경에서 중요한 로깅 라이브러리는 TodoList에서 다뤄 보도록 합니다.

Built-in Logger로 실습하기

main.ts에서 부팅 로그

main.ts에 NestJS에 내장된 Logger로 애플리케이션이 실행 중이라는 메시지와 실행 중인 포트 번호를 출력합니다.

import { Logger } from "@nestjs/common";

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  const port = 3000;
  await app.listen(port);
  Logger.log(`Application is running on port ${port}`);
}

결과

실행 결과


GET /boards에서 verbose 로깅

GET /boards로 전체 보드를 조회하고, getAllBoards()에서 verbose로 로깅합니다.

// boards.controller.ts
import { Logger } from '@nestjs/common';

@Controller('boards')
export class BoardsController {
  private logger = new Logger('BoardsController');

  ...
  @Get()
  getAllBoards(@GetUser() user: User): Promise<Board[]> {
    this.logger.verbose(`User ${user.username} trying to get all boards`);
    return this.boardsService.findAllBoards(user);
  }
}

결과

실행 결과

[BoardsController]라고 출력된 이유는 new Logger('BoardsController')로 Logger의 생성자가 첫 번째 인자로 받는 문자열을 ‘컨텍스트(context)’로 저장해 두기 때문입니다. 즉, 어느 클래스/모듈에서 발생한 로그인지 식별할 수 있게 해 주는 역할입니다.


POST /boards로 보드 생성 시 로깅

POST /boards로 보드를 생성하고, createBoard()에 로깅합니다.

// boards.controller.ts
import { Logger } from '@nestjs/common';

export class BoardsController {
  private logger = new Logger('BoardsController');

  ...
  @Post()
  @UsePipes(ValidationPipe)
  createBoard(
    @Body() createBoardDto: CreateBoardDto,
    @GetUser() user: User,
  ): Promise<Board> {
    this.logger.verbose(`User ${user.username} creating a new board.
      Payload: ${JSON.stringify(createBoardDto)}`);
    return this.boardsService.createBoard(createBoardDto, user);
  }
}

보드 생성

실행 결과

결과

실행 결과


설정(Configuration)

소스 코드 안에서 어떠한 코드들은 개발 환경이나 운영 환경에 따라서 다르게 코드를 넣어 주어야 할 때, 남들에게 노출되지 않아야 하는 코드들도 있습니다. 이러한 코드들을 위해서 설정 파일을 따로 만들어 보관합니다.

설정 파일은 런타임 도중에 바뀌는 것이 아니라 애플리케이션이 시작할 때 로드가 되어서 그 값들을 정의합니다. XML, JSON, YAML, 환경 변수 등 여러 파일 형식을 사용할 수 있습니다.

환경 변수와 Codebase의 분리

Codebase vs Environment Variables

  • Codebase (XML, JSON, YAML): Port 같이 노출되어도 상관없는 정보
  • 환경 변수: 비밀번호, API Key 같이 노출되면 안 되는 정보

비밀번호와 API Key 같은 민감한 정보를 주로 환경 변수를 이용해서 처리합니다.

강의에서는 config라는 npm 패키지를 설치해서 설정 파일을 분리하는 방식을 소개합니다. 참고로 윈도우에서는 NODE_ENV=production command 같은 유닉스 셸 문법이 기본으로 지원되지 않기 때문에 win-node-env 같은 보조 패키지가 필요합니다. 다만 이 블로그에서는 아래 이유로 @nestjs/config 방식만 다룹니다.

⚠️ 강의는 config npm 패키지를 사용하지만, 이는 현재 권장되지 않는 방식입니다. NestJS 공식 권장 방식은 @nestjs/config + .env 파일이며, 이는 Twelve-Factor App 원칙과 컨테이너 환경에 더 적합합니다.

강의에서 배우는 “환경별 설정 분리”, “민감 정보는 환경 변수로” 같은 개념은 그대로 유효합니다. 다만 구현 도구만 config 모듈 → @nestjs/config로 바꿔서 적용하면 됩니다.

.env 파일과 @nestjs/config 등록

우선 세 가지 환경 변수 파일을 생성합니다.

  • .env: 개발 환경 (기본)
  • .env.test: 테스트 환경
  • .env.example: 템플릿 (git에 올림)

운영 환경은 .env.production 파일로 관리하기보다는, 실제 운영 서버에서 환경 변수를 직접 주입하는 것이 일반적입니다.

프로젝트 루트에 .env를 생성합니다.

# .env.example
DB_HOST=localhost
DB_PORT=5432
DB_USERNAME=postgres
DB_PASSWORD=your_password
DB_NAME=board-app

JWT_SECRET=aaa
JWT_EXPIRES_IN=3600

PORT=3000

GitHub 같은 원격 저장소에 들어간다면 추적되지 않도록 .gitignore에 반드시 추가해야 합니다. 협업 시에는 프로젝트에서 사용해야 할 환경 변수 템플릿 파일(.env.example)도 같이 만들어 주면 좋습니다.

AppModule에 등록합니다.

// src/app.module.ts
import { ConfigModule } from '@nestjs/config';

@Module({
  imports: [
    ConfigModule.forRoot({
      isGlobal: true,  // 다른 모듈에서 import 없이 바로 사용 가능
      envFilePath: '.env',  // 읽을 파일 명시
    }),
    ...
  ],
})

ConfigService 동작 원리

@nestjs/config 모듈에는 ConfigService라는 클래스가 있습니다. ‘DI(Dependency Injection) 가능한 서비스 클래스’라고 하는데, 쉽게 말해서 ‘환경 변수를 꺼내 쓰는 창구’입니다.

process.env에 접근하기 위해서는 항상 process.env.DB_HOST처럼 process.env를 선언해 주어야 하는데, ConfigService 클래스로 선언된 객체는 process.env(환경 변수) 그 자체로 읽는 것이 아니라 ConfigModule이 미리 읽어 둔 값에 접근하는 통로입니다.

전체적인 내부 동작

1. ConfigModule.forRoot()가 .env 파일을 읽음 → envFilePath: '.env'

2. 파싱된 값들을 내부 저장소(Map or Object)에 저장

3. ConfigService가 그 저장소를 감싸는 래퍼(wrapper)

4. configService.get('KEY')를 호출하면 저장소에서 값을 꺼내 반환

get() 메서드

기본 형태입니다.

const value = configService.get<string>("DB_HOST");

<string>처럼 반환할 타입을 명시하면 해당 타입으로 반환됩니다. 반환 타입은 항상 undefined가 포함되는데, 이는 ‘해당 키가 존재하지 않을 수도 있다’는 것을 타입 시스템에 알립니다.

기본값을 지정할 수도 있습니다.

const port = configService.get<string>("PORT", "3000");

get 메서드의 두 번째 인자로 기본값을 넘길 수 있습니다. 기본값을 넘겨 주게 되면 undefined가 반환되지 않는데, 이는 환경 변수가 없을 때 기본값으로 fallback되기 때문입니다.

⚠️ 여기서 주의할 점이 있습니다. credential에는 기본값을 넣으면 안 됩니다.

PORT 같은 비민감 값에는 기본값을 써도 무방하지만, DB_PASSWORD, JWT_SECRET 같은 민감 정보에는 절대 기본값을 넣으면 안 됩니다.

  1. 환경 변수 누락을 숨깁니다. 기본값이 있으면 .envDB_PASSWORD를 깜빡해도 서버가 fallback 값으로 그냥 실행됩니다. 로컬에서는 기본값이 우연히 일치해서 동작할 수 있지만, 이 상태로 운영에 배포되는 순간 사고가 납니다.
  2. 기본값이 실제 값이 되어 버리는 케이스. password: 'postgres' 같은 기본값이 박혀 있으면, 개발자가 .env를 제대로 설정하지 않은 채 그 상태로 운영 DB를 띄우는 실수가 발생합니다. 기본값이 없으면 서버가 부팅 단계에서 즉시 중단됩니다.
  3. 코드베이스에 credential 흔적이 남습니다. 하드코딩된 값이 소스에 들어가면 git 히스토리에 영구히 남습니다. 나중에 비밀번호를 바꿔도 과거 커밋에는 여전히 남아 있고, 보안 감사에서 플래그가 걸리는 패턴입니다.
  4. Twelve-Factor App 원칙 — ‘설정은 환경에, 코드는 환경 독립적으로’. 코드는 환경에 대한 가정을 하면 안 됩니다. credential뿐 아니라 host, port, database 같은 값들도 엄밀히는 기본값을 넣지 않는 것이 더 엄격한 원칙에 부합합니다.

가장 올바른 방식은 Zod 기반 환경 변수 검증 패턴인 validate 옵션으로 부팅 시점에 일괄적으로 검증하는 방식입니다. 이 방식은 앞으로 구현할 TodoList에서 직접 리팩토링해 보겠습니다.

TypeORM 설정을 forRootAsync로

ConfigModule이 먼저 초기화되고, 그다음에 ConfigService가 주입 가능해지는 타이밍을 보장하기 위해서 app.module.ts 파일에서 TypeORM 설정을 forRootAsync로 불러옵니다.

  • forRoot: 설정값이 이미 결정되어 있을 때
  • forRootAsync: 설정값이 다른 서비스로부터 받아와야 할 때
TypeOrmModule.forRootAsync(typeORMConfig);

기존 configs/typeorm.config.ts 파일은 상수로 export하는 구조였지만, ConfigService를 주입받아야 하기 때문에 비동기 옵션 객체로 바꿔야 합니다.

기존 동기 방식(하드코딩)은 파일이 로드되는 순간 값이 즉시 결정되는 상수입니다. 이 시점에는 NestJS의 DI 컨테이너가 아직 존재하지 않습니다.

// 불가능한 예시 (모듈 로드 시점에는 configService가 없음)
export const typeORMConfig: TypeOrmModuleOptions = {
  host: configService.get("DB_HOST"), // configService를 어디서 가져오지?
};

ConfigService를 끼워 넣으려고 하면 문제가 발생합니다. configService가 존재하는 시점은 앱이 부팅되고 DI 컨테이너가 초기화된 후에야 존재하지만, 기존 typeORMConfig는 앱 부팅 전에 동기적으로 로딩되기 때문에 configService가 생성되지 않은 채 실행되어 버립니다.

따라서 다음과 같이 변경합니다.

// src/configs/typeorm.config.ts
import { TypeOrmModuleAsyncOptions } from "@nestjs/typeorm";
import { ConfigService } from "@nestjs/config";

export const typeORMConfig: TypeOrmModuleAsyncOptions = {
  inject: [ConfigService],
  useFactory: (config: ConfigService) => ({
    host: config.get<string>("DB_HOST"),
  }),
};
  1. typeORMConfig의 타입을 TypeOrmModuleAsyncOptions로 바꿔 비동기 옵션 객체로 전환합니다.
  2. inject + useFactory 구조로 전환합니다.
    • inject: [ConfigService]: NestJS DI 컨테이너에 ‘이 팩토리 함수가 생성될 때 ConfigService를 주입해 달라’고 선언합니다.
    • useFactory: (config) => ({...}): 실제 설정 객체를 만드는 함수이며, NestJS가 부팅 과정에서 적절한 시점에 호출합니다.

NestJS 부팅 흐름 동작 원리

1. 파일 로드 단계
   - typeorm.config.ts가 import됨
   - useFactory는 아직 호출되지 않은 상태
   - "이 함수를 나중에 호출하라"는 지시만 등록된 상태

2. ConfigModule 초기화
   - .env 파일 로드
   - ConfigService가 DI 컨테이너에 등록

3. TypeOrmModule 초기화
   - NestJS가 inject 배열을 확인 → ConfigService를 DI 컨테이너에서 꺼냄
   - useFactory를 호출하며 ConfigService를 인자로 전달
   - 반환된 객체가 실제 TypeORM 설정이 됨

마치며

로그 레벨을 환경에 맞게 두고, Built-in Logger로 흐름을 남긴 뒤, 설정은 @nestjs/config로 끌어올리면서 두 가지가 분명해졌습니다.

  • 운영에서는 무엇을 얼마나 찍을지를 레벨로 조절하지 않으면 노이즈와 비용이 함께 커집니다.
  • DB·JWT 같은 값은 코드가 아니라 환경에 두고, 부팅 순서에 맞춰 forRootAsync로 주입받는 편이 안전합니다.

TodoList에서는 Winston/Pino 연동과 Zod 기반 env 검증까지 이어서 정리할 예정입니다.