본문 바로가기

로깅과 감사 추적

NestJS에서 구조화 운영 로그와 감사 증적을 분리하고 요청 문맥, 오류, 트랜잭션 경계, 보존·접근 정책까지 설계합니다.

이 절에서는 NestJS 애플리케이션의 운영 로깅(operational logging)과 감사 추적(audit trail)을 다룹니다.

두 기록은 같은 requestId나 traceId로 연결할 수 있지만 목적과 신뢰 경계가 다릅니다. 운영 로그는 장애와 성능 문제를 찾기 위한 관측 데이터이고, 감사 증적은 중요한 비즈니스 변경을 나중에 재구성하기 위한 기록입니다. 일반 로그를 오래 보관한다고 자동으로 감사 증적이 되지는 않습니다.

운영 사건과 비즈니스 변경이 목적별 구조화 기록으로 흐르는 경로

단계 대상은 아랫행의 업무 변경+audit outbox 동일 TX와 커밋된 사건의 relay·멱등 수락입니다. 윗행 운영 로그는 독립 경로로 전체 관계도와 표·출력에 보존합니다. E1 전달 후 relay 완료 기록 전 중단·재전달에서도 목적지 unique 제약 때문에 증적 한 건이라는 본문 상태 사례가 단계 학습에 적합합니다.

HTTP 요청과 런타임 사건은 요청·추적 문맥, LoggerService 정규화와 민감 필드 제외를 거쳐 구조화 운영 레코드가 되고, 중요한 비즈니스 변경은 같은 데이터베이스 트랜잭션의 audit outbox와 relay를 거쳐 별도 감사 증적 저장소에 기록되는 두 데이터 흐름

Nest · Log and audit data flow

운영 로그와 감사 증적의 두 흐름

운영 사건은 허용 필드와 요청 문맥으로 구조화합니다. 성공한 업무 변경은 audit outbox와 같은 DB 트랜잭션에 기록합니다. relay와 최종 감사 저장소는 추가 구현·운영이 필요한 경계입니다.

운영 로그와 감사 증적의 구조화 데이터 흐름 위쪽 운영 관측 경로는 HTTP 사건에 요청 문맥을 더하고 LoggerService에서 허용 필드와 오류를 구조화해 중앙 로그로 보낸다. 아래쪽 감사 증적 경로는 비즈니스 변경과 audit outbox를 같은 트랜잭션에 기록하고 멱등 relay가 append-only 저장소로 보낸다. 요청 문맥은 두 경로를 상관시키지만 운영 로그가 감사 증적을 대신하지 않는다. OPERATIONAL OBSERVABILITY AUDIT EVIDENCE 정규화 입력 사건 HTTP · error request · error 요청 문맥 request · trace actor · context LoggerService level · allowlist Error · redaction 운영 레코드 event · time 대상 · 결과 Error · stack 중앙 관측 search · alert 접근 · 보존 장애·성능 탐색용 · 샘플링과 레벨 정책 적용 가능 비밀과 불필요한 개인정보 제외 중요 업무 변경 create · update · delete actor · outcome 같은 DB 트랜잭션 domain + outbox eventId · corrId 함께 commit / rollback Outbox relay retry · at-least-once destination dedupes 감사 증적 저장소 append-only · 변조 탐지 조회 통제 · 접근 감사 retention · disposal 감사 이벤트는 일반 로그의 사후 복사가 아니라 성공한 업무 변경의 원자적 동반 기록이다 레코드 전달 상관 문맥 전파
운영 관측과 감사 증적의 서로 다른 전달 경계 운영 관측 영역에서는 입력 사건에 요청 문맥을 전달하고 LoggerService의 필드·값 정책을 거쳐 레코드를 중앙 관측으로 보낸다. 감사 영역에서는 업무 변경과 outbox가 같은 DB 트랜잭션으로 커밋되고 relay가 같은 eventId를 재전달할 수 있으며 목적지가 중복을 거부한다. 최종 증적 저장소의 append-only·변조 탐지·조회 통제는 별도 구성이다. 두 영역의 점선은 요청·추적 상관 문맥이며 운영 로그를 감사 저장소로 복사하는 관계가 아니다. 문자열 안의 비밀은 허용 필드만으로 자동 제거되지 않는다. 운영 관측 영역 감사 증적 영역 입력 사건 HTTP · error 요청 문맥 request · trace LoggerService 필드 · 값 정책 Error · 구조화 운영 레코드 event · time · 결과 중앙 관측 검색 · 접근 · 보존 중요 업무 변경 actor · outcome 같은 DB 트랜잭션 domain + outbox 함께 commit / rollback Outbox relay retry · at-least-once 별도 증적 저장소 eventId 중복 거부 append-only · 접근
운영 관측

사건에 요청 문맥을 더해 구조화 레코드로 만든다

  1. HTTP · 런타임 사건

    요청, 오류, 인증 사건 중 기록할 신호를 명명합니다.

  2. 요청 문맥

    requestId, traceId, actor와 Nest context를 비동기 호출 체인에 전달합니다.

  3. LoggerService

    레벨과 허용 필드를 적용하고 Error의 이름, 메시지, 스택을 구조화합니다.

  4. 운영 레코드

    event·time·service, 대상·결과와 Error·stack을 구조화합니다. 허용 필드와 마스킹 정책으로 개인정보와 비밀 값을 제외합니다.

  5. 중앙 관측

    인덱스·검색·알림과 운영용 보존·접근 정책을 적용합니다.

감사 증적

업무 변경과 audit outbox를 함께 커밋한다

  1. 중요 비즈니스 변경

    행위자·행위·대상·결과와 변경 요약을 사건으로 만듭니다.

  2. 같은 DB 트랜잭션

    domain row와 audit outbox가 함께 커밋되거나 함께 롤백됩니다.

  3. 재시도 가능한 relay

    중복 전달을 허용하고, 목적지가 eventId unique 제약으로 멱등 수락합니다. 순서가 필요하면 key와 sequence를 추가합니다.

  4. 별도 증적 저장소

    append-only 권한, 변조 탐지, 제한된 조회, 보존과 폐기 정책을 둡니다.

correlate

requestId와 traceId는 두 기록을 연결한다

운영 로그와 감사 증적을 같은 저장 정책으로 합치지 않고, 안정적인 상관 ID로 장애 흐름과 책임 흐름을 함께 조회합니다.

logs ≠ evidence

일반 로그만으로 감사 무결성을 주장하지 않는다

샘플링·회전·관리자 수정이 가능한 운영 로그는 최종 증적이 아닙니다. 업무 변경의 트랜잭션 경계와 별도 무결성·접근 통제가 필요합니다.

위 경로는 관측을 위한 구조화, 아래 경로는 책임 있는 변경의 재구성을 위한 증적입니다. 상관 ID는 연결 고리이지 두 저장소의 목적과 보존 규칙을 같게 만드는 근거가 아닙니다.

이 예의 조건

  • 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
  • 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
  • 위쪽 운영 관측 경로는 HTTP 사건에 요청 문맥을 더하고 LoggerService에서 허용 필드와 오류를 구조화해 중앙 로그로 보낸다. 아래쪽 감사 증적 경로는 비즈니스 변경과 audit outbox를 같은 트랜잭션에 기록하고 멱등 relay가 append-only 저장소로 보낸다. 요청 문맥은 두 경로를 상관시키지만 운영 로그가 감사 증적을 대신하지 않는다.
  • 운영 관측 영역에서는 입력 사건에 요청 문맥을 전달하고 LoggerService의 필드·값 정책을 거쳐 레코드를 중앙 관측으로 보낸다. 감사 영역에서는 업무 변경과 outbox가 같은 DB 트랜잭션으로 커밋되고 relay가 같은 eventId를 재전달할 수 있으며 목적지가 중복을 거부한다. 최종 증적 저장소의 append-only·변조 탐지·조회 통제는 별도 구성이다. 두 영역의 점선은 요청·추적 상관 문맥이며 운영 로그를 감사 저장소로 복사하는 관계가 아니다. 문자열 안의 비밀은 허용 필드만으로 자동 제거되지 않는다.
관계도의 보충 설명
원래 전체 그림의 표시 문구·조건·주석
순번원문 표시
1운영 사건과 비즈니스 변경이 목적별 구조화 기록으로 흐르는 경로
단계별 상태·값과 전환 이유
장면관찰 값전환 이유
업무와 사건을 같은 TX에 기록

User 변경과 audit outbox 사건 E1을 같은 manager transaction에 기록합니다.

TX: 업무+outbox · 사건: E1 · 운영 로그: 별도 경로업무와 전달할 증적의 원자적 근거가 생깁니다.
commit 뒤 첫 전달

relay가 커밋된 E1만 읽어 목적지에 보냅니다.

입력: 커밋된 E1 · 목적지: eventId unique·멱등 수락 · 증적: 1건커밋된 사건이 최종 증적 저장소에 도달합니다.
완료 기록 전 중단

E1 전달 뒤 relay가 완료 상태를 적기 전에 중단됩니다.

증적: 1건 · relay 완료 상태: 미기록 · 재전달: 가능전달 성공과 relay의 완료 관찰이 달라집니다.
동일 E1 재전달

relay가 E1을 다시 보내도 unique·멱등 수락이 같은 사건으로 처리합니다.

전달 시도: 2회 · 동일 사건: E1 · 증적: 1건시도 수만 늘고 동일 증적은 중복되지 않습니다.

운영 사건과 비즈니스 변경이 목적별 구조화 기록으로 흐르는 경로

단계 대상은 아랫행의 업무 변경+audit outbox 동일 TX와 커밋된 사건의 relay·멱등 수락입니다. 윗행 운영 로그는 독립 경로로 전체 관계도와 표·출력에 보존합니다. E1 전달 후 relay 완료 기록 전 중단·재전달에서도 목적지 unique 제약 때문에 증적 한 건이라는 본문 상태 사례가 단계 학습에 적합합니다.

HTTP 요청과 런타임 사건은 요청·추적 문맥, LoggerService 정규화와 민감 필드 제외를 거쳐 구조화 운영 레코드가 되고, 중요한 비즈니스 변경은 같은 데이터베이스 트랜잭션의 audit outbox와 relay를 거쳐 별도 감사 증적 저장소에 기록되는 두 데이터 흐름

Nest · Log and audit data flow

운영 로그와 감사 증적의 두 흐름

운영 사건은 허용 필드와 요청 문맥으로 구조화합니다. 성공한 업무 변경은 audit outbox와 같은 DB 트랜잭션에 기록합니다. relay와 최종 감사 저장소는 추가 구현·운영이 필요한 경계입니다.

운영 로그와 감사 증적의 구조화 데이터 흐름 위쪽 운영 관측 경로는 HTTP 사건에 요청 문맥을 더하고 LoggerService에서 허용 필드와 오류를 구조화해 중앙 로그로 보낸다. 아래쪽 감사 증적 경로는 비즈니스 변경과 audit outbox를 같은 트랜잭션에 기록하고 멱등 relay가 append-only 저장소로 보낸다. 요청 문맥은 두 경로를 상관시키지만 운영 로그가 감사 증적을 대신하지 않는다. OPERATIONAL OBSERVABILITY AUDIT EVIDENCE 정규화 입력 사건 HTTP · error request · error 요청 문맥 request · trace actor · context LoggerService level · allowlist Error · redaction 운영 레코드 event · time 대상 · 결과 Error · stack 중앙 관측 search · alert 접근 · 보존 장애·성능 탐색용 · 샘플링과 레벨 정책 적용 가능 비밀과 불필요한 개인정보 제외 중요 업무 변경 create · update · delete actor · outcome 같은 DB 트랜잭션 domain + outbox eventId · corrId 함께 commit / rollback Outbox relay retry · at-least-once destination dedupes 감사 증적 저장소 append-only · 변조 탐지 조회 통제 · 접근 감사 retention · disposal 감사 이벤트는 일반 로그의 사후 복사가 아니라 성공한 업무 변경의 원자적 동반 기록이다 레코드 전달 상관 문맥 전파
운영 관측과 감사 증적의 서로 다른 전달 경계 운영 관측 영역에서는 입력 사건에 요청 문맥을 전달하고 LoggerService의 필드·값 정책을 거쳐 레코드를 중앙 관측으로 보낸다. 감사 영역에서는 업무 변경과 outbox가 같은 DB 트랜잭션으로 커밋되고 relay가 같은 eventId를 재전달할 수 있으며 목적지가 중복을 거부한다. 최종 증적 저장소의 append-only·변조 탐지·조회 통제는 별도 구성이다. 두 영역의 점선은 요청·추적 상관 문맥이며 운영 로그를 감사 저장소로 복사하는 관계가 아니다. 문자열 안의 비밀은 허용 필드만으로 자동 제거되지 않는다. 운영 관측 영역 감사 증적 영역 입력 사건 HTTP · error 요청 문맥 request · trace LoggerService 필드 · 값 정책 Error · 구조화 운영 레코드 event · time · 결과 중앙 관측 검색 · 접근 · 보존 중요 업무 변경 actor · outcome 같은 DB 트랜잭션 domain + outbox 함께 commit / rollback Outbox relay retry · at-least-once 별도 증적 저장소 eventId 중복 거부 append-only · 접근
운영 관측

사건에 요청 문맥을 더해 구조화 레코드로 만든다

  1. HTTP · 런타임 사건

    요청, 오류, 인증 사건 중 기록할 신호를 명명합니다.

  2. 요청 문맥

    requestId, traceId, actor와 Nest context를 비동기 호출 체인에 전달합니다.

  3. LoggerService

    레벨과 허용 필드를 적용하고 Error의 이름, 메시지, 스택을 구조화합니다.

  4. 운영 레코드

    event·time·service, 대상·결과와 Error·stack을 구조화합니다. 허용 필드와 마스킹 정책으로 개인정보와 비밀 값을 제외합니다.

  5. 중앙 관측

    인덱스·검색·알림과 운영용 보존·접근 정책을 적용합니다.

감사 증적

업무 변경과 audit outbox를 함께 커밋한다

  1. 중요 비즈니스 변경

    행위자·행위·대상·결과와 변경 요약을 사건으로 만듭니다.

  2. 같은 DB 트랜잭션

    domain row와 audit outbox가 함께 커밋되거나 함께 롤백됩니다.

  3. 재시도 가능한 relay

    중복 전달을 허용하고, 목적지가 eventId unique 제약으로 멱등 수락합니다. 순서가 필요하면 key와 sequence를 추가합니다.

  4. 별도 증적 저장소

    append-only 권한, 변조 탐지, 제한된 조회, 보존과 폐기 정책을 둡니다.

correlate

requestId와 traceId는 두 기록을 연결한다

운영 로그와 감사 증적을 같은 저장 정책으로 합치지 않고, 안정적인 상관 ID로 장애 흐름과 책임 흐름을 함께 조회합니다.

logs ≠ evidence

일반 로그만으로 감사 무결성을 주장하지 않는다

샘플링·회전·관리자 수정이 가능한 운영 로그는 최종 증적이 아닙니다. 업무 변경의 트랜잭션 경계와 별도 무결성·접근 통제가 필요합니다.

위 경로는 관측을 위한 구조화, 아래 경로는 책임 있는 변경의 재구성을 위한 증적입니다. 상관 ID는 연결 고리이지 두 저장소의 목적과 보존 규칙을 같게 만드는 근거가 아닙니다.

이 예의 조건

  • 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
  • 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
  • 위쪽 운영 관측 경로는 HTTP 사건에 요청 문맥을 더하고 LoggerService에서 허용 필드와 오류를 구조화해 중앙 로그로 보낸다. 아래쪽 감사 증적 경로는 비즈니스 변경과 audit outbox를 같은 트랜잭션에 기록하고 멱등 relay가 append-only 저장소로 보낸다. 요청 문맥은 두 경로를 상관시키지만 운영 로그가 감사 증적을 대신하지 않는다.
  • 운영 관측 영역에서는 입력 사건에 요청 문맥을 전달하고 LoggerService의 필드·값 정책을 거쳐 레코드를 중앙 관측으로 보낸다. 감사 영역에서는 업무 변경과 outbox가 같은 DB 트랜잭션으로 커밋되고 relay가 같은 eventId를 재전달할 수 있으며 목적지가 중복을 거부한다. 최종 증적 저장소의 append-only·변조 탐지·조회 통제는 별도 구성이다. 두 영역의 점선은 요청·추적 상관 문맥이며 운영 로그를 감사 저장소로 복사하는 관계가 아니다. 문자열 안의 비밀은 허용 필드만으로 자동 제거되지 않는다.

관계도의 보충 설명

원래 전체 그림의 표시 문구·조건·주석
순번원문 표시
1운영 사건과 비즈니스 변경이 목적별 구조화 기록으로 흐르는 경로
단계별 상태·값과 전환 이유
장면관찰 값전환 이유
업무와 사건을 같은 TX에 기록

User 변경과 audit outbox 사건 E1을 같은 manager transaction에 기록합니다.

TX: 업무+outbox · 사건: E1 · 운영 로그: 별도 경로업무와 전달할 증적의 원자적 근거가 생깁니다.
commit 뒤 첫 전달

relay가 커밋된 E1만 읽어 목적지에 보냅니다.

입력: 커밋된 E1 · 목적지: eventId unique·멱등 수락 · 증적: 1건커밋된 사건이 최종 증적 저장소에 도달합니다.
완료 기록 전 중단

E1 전달 뒤 relay가 완료 상태를 적기 전에 중단됩니다.

증적: 1건 · relay 완료 상태: 미기록 · 재전달: 가능전달 성공과 relay의 완료 관찰이 달라집니다.
동일 E1 재전달

relay가 E1을 다시 보내도 unique·멱등 수락이 같은 사건으로 처리합니다.

전달 시도: 2회 · 동일 사건: E1 · 증적: 1건시도 수만 늘고 동일 증적은 중복되지 않습니다.

구조화 로깅의 기본

문자열 한 줄만 남기면 검색 시스템은 의미를 다시 추측해야 합니다. 애플리케이션에서 사건 이름과 필드 계약을 먼저 정하고 JSON 레코드로 기록합니다.

운영 로그 한 건에는 보통 다음 필드가 필요합니다.

  • timestamp, service, environment: 언제 어느 실행 환경에서 발생했는지
  • event, level, message: 어떤 사건이며 심각도가 무엇인지
  • requestId, traceId, context: 같은 요청과 분산 추적, NestJS 발생 위치
  • resource, outcome, durationMs: 대상과 결과, 처리 시간
  • error.name, error.message, error.stack: 오류의 구조화된 정보

requestId와 traceId는 검색 필드로는 유용하지만 값 종류가 계속 늘어나는 고카디널리티(high-cardinality) 데이터입니다. 메트릭 레이블이나 대시보드 그룹 키로 무제한 사용하지 않습니다.

비밀번호, 세션·API 토큰, Authorization·Cookie 헤더, 결제 정보, 전체 요청 본문은 기본 제외 대상입니다. 마스킹 정규식 하나에 의존하기보다 허용할 필드를 먼저 정하고 길이와 형식을 제한합니다.

NestJS 로그 레벨

NestJS의 표준 레벨은 심각도가 높은 순서로 fatal, error, warn, log, debug, verbose입니다. log는 일반적인 정보성 사건에 대응합니다. 레벨은 계층적으로 적용되므로 log까지 켜면 그보다 심각한 warn, error, fatal도 포함됩니다.

  • fatal: 프로세스가 정상 서비스를 계속할 수 없는 치명적 상태
  • error: 요청 또는 작업이 실패해 조사와 조치가 필요한 상태
  • warn: 현재 요청은 처리했지만 임계치 접근이나 재시도처럼 주의가 필요한 상태
  • log: 시작, 종료, 중요 업무 흐름처럼 정상 운영을 설명하는 사건
  • debug, verbose: 개발·진단용 상세 정보. 운영에서는 비용과 노출 위험을 검토해 제한

레벨은 업무 결과와 같지 않습니다. 예를 들어 로그인 거부는 정상적으로 처리된 HTTP 응답일 수 있지만 보안 사건의 outcome은 DENIED이고 정책에 따라 warn으로 기록할 수 있습니다.


NestJS 내장 Logger

@nestjs/common의 Logger는 Nest 시스템 로그와 애플리케이션 로그에 사용할 수 있습니다. 인스턴스를 만들 때 전달한 이름은 로그의 context가 됩니다.

src/app.service.ts
import { Injectable, Logger } from '@nestjs/common';

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

  getHello(requestId: string): string {
    this.logger.log({
      event: 'greeting.requested',
      message: '인사말을 반환했습니다.',
      requestId,
      resource: 'GET /',
      outcome: 'SUCCEEDED',
    });

    return 'Hello World!';
  }
}

문자열 보간으로 객체를 한 줄에 합치지 않고 필드를 객체로 전달합니다. 오류도 error.toString()만 남기지 말고 이름, 메시지, 스택을 구조화하여 보존해야 합니다.

이 첫 예제의 getHello(requestId)는 호출자가 ID를 넘기는 부분 예입니다. 앞 장의 인자 없는 getHello() 호출을 그대로 두면 ID가 자동으로 생기지 않습니다. 호출부에서 생성한 ID를 전달하거나, 아래의 요청 문맥 저장소와 미들웨어를 연결해 로그 시점에 문맥을 읽는 방식을 선택합니다.

전역 레벨과 내장 콘솔 출력 형식은 부트스트랩에서 정할 수 있습니다.

src/main.ts
import { ConsoleLogger } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';

async function bootstrap(): Promise<void> {
  const logger = new ConsoleLogger({
    json: true,
    colors: false,
    logLevels: ['fatal', 'error', 'warn', 'log'],
  });

  const app = await NestFactory.create(AppModule, {
    logger,
  });

  await app.listen(process.env.PORT ?? 3000);
}

void bootstrap();

운영에서 DI가 필요한 커스텀 로거를 사용할 때는 bufferLogs: true로 초기 로그를 버퍼링하고, 모듈이 만든 단일 인스턴스를 app.useLogger(app.get(...))로 연결합니다.


요청·추적 문맥 전파

HTTP 요청마다 신뢰할 수 있는 첫 진입점에서 requestId를 생성하거나 제한된 형식의 값을 받아들입니다. 이 값은 사용자 신원이 아니므로 권한 판단에 사용하지 않습니다.

서비스 사이의 분산 추적에는 임의 헤더 규칙보다 W3C Trace Context의 traceparent를 지원하는 OpenTelemetry 같은 계측 도구를 사용합니다. 수신한 추적 문맥은 다음 HTTP 호출에도 전달해야 한 요청의 스팬이 끊기지 않습니다.

Node.js의 AsyncLocalStorage는 비동기 호출 체인에 요청 문맥을 묶는 데 사용할 수 있습니다.

src/logging/request-context.ts
import { AsyncLocalStorage } from 'node:async_hooks';
import { randomUUID } from 'node:crypto';
import { Injectable, NestMiddleware } from '@nestjs/common';
import type { NextFunction, Request, Response } from 'express';

interface RequestContext {
  readonly requestId: string;
}

@Injectable()
export class RequestContextStore {
  private readonly storage = new AsyncLocalStorage<RequestContext>();

  run(context: RequestContext, next: () => void): void {
    this.storage.run(context, next);
  }

  get(): RequestContext | undefined {
    return this.storage.getStore();
  }
}

@Injectable()
export class RequestContextMiddleware implements NestMiddleware {
  constructor(private readonly context: RequestContextStore) {}

  use(request: Request, response: Response, next: NextFunction): void {
    const requestId = randomUUID();
    response.setHeader('x-request-id', requestId);
    this.context.run({ requestId }, next);
  }
}

프록시나 API 게이트웨이가 ID를 만들도록 했다면 애플리케이션은 신뢰 경계를 명확히 하고 길이·문자 집합을 검증해야 합니다. traceId, actorId, resourceId도 같은 방식으로 전파하되 로그 주입을 막고 과도한 카디널리티를 제어합니다.


Winston으로 LoggerService 구현

Winston은 구조화 포맷과 여러 transport를 제공합니다. 컨테이너 환경에서는 JSON을 표준 출력으로 보내고 플랫폼의 수집기가 중앙 저장소로 전달하는 구성이 단순합니다.

npm install winston

NestJS의 현재 LoggerService 계약은 각 메서드가 message 뒤에 가변 인자를 받는 형태입니다. 타입 계약에서 debug, verbose, fatal은 선택적일 수 있지만 Nest 표준 레벨에는 fatal이 포함되므로 어댑터는 이를 명시적으로 구현합니다. Winston 기본 npm 레벨에는 fatal이 없기 때문에 아래 예시는 Nest 레벨을 별도로 정의해 fatal을 error 플래그로 축소하지 않습니다.

src/logging/winston.logger.ts
import { Injectable, LoggerService } from '@nestjs/common';
import { createLogger, format, transports } from 'winston';
import { RequestContextStore } from './request-context';

const nestLevels = {
  fatal: 0,
  error: 1,
  warn: 2,
  log: 3,
  debug: 4,
  verbose: 5,
} as const;

type NestLevel = keyof typeof nestLevels;

function readLogLevel(value: string | undefined): NestLevel {
  if (value === undefined) return 'log';
  if (!Object.hasOwn(nestLevels, value)) throw new Error(`Invalid LOG_LEVEL: ${value}`);
  return value as NestLevel;
}

function isStack(value: unknown): value is string {
  return typeof value === 'string' && /^(.)+\n\s+at .+:\d+:\d+/.test(value);
}

function splitOptionalParams(level: NestLevel, optionalParams: unknown[]) {
  const params = [...optionalParams];
  let context: string | undefined;
  let stack: string | undefined;

  if (level === 'error') {
    const last = params.at(-1);
    if (isStack(last)) {
      stack = params.pop() as string;
    } else if (typeof last === 'string') {
      context = params.pop() as string;
      const stackCandidate = params.at(-1);
      if (isStack(stackCandidate)) stack = params.pop() as string;
    }
  } else if (typeof params.at(-1) === 'string') {
    context = params.pop() as string;
  }

  return { context, stack, params };
}

function toSafeRecord(message: unknown): Record<string, unknown> {
  if (message instanceof Error) {
    return {
      event: 'application.error',
      message: message.message,
      error: {
        name: message.name,
        message: message.message,
        stack: message.stack,
      },
    };
  }

  if (typeof message === 'string') {
    return { event: 'application.message', message };
  }

  const value = message && typeof message === 'object'
    ? message as Record<string, unknown>
    : {};

  // 객체 전체를 펼치지 않고 승인된 필드만 복사합니다.
  return {
    event: typeof value.event === 'string' ? value.event : 'application.event',
    message: typeof value.message === 'string' ? value.message : '구조화 이벤트',
    resource: typeof value.resource === 'string' ? value.resource : undefined,
    outcome: typeof value.outcome === 'string' ? value.outcome : undefined,
    durationMs: typeof value.durationMs === 'number' ? value.durationMs : undefined,
  };
}

function toSafeParam(value: unknown): unknown {
  if (value instanceof Error || (value !== null && typeof value === 'object')) {
    return toSafeRecord(value);
  }
  if (typeof value === 'bigint') return value.toString();
  if (typeof value === 'function' || typeof value === 'symbol') return String(value);
  return value;
}

@Injectable()
export class WinstonLogger implements LoggerService {
  private readonly logger = createLogger({
    levels: nestLevels,
    level: readLogLevel(process.env.LOG_LEVEL),
    defaultMeta: { service: 'user-api' },
    format: format.combine(
      format.timestamp(),
      format.errors({ stack: true }),
      format.json(),
    ),
    transports: [new transports.Console()],
    exceptionHandlers: [new transports.Console()],
    rejectionHandlers: [new transports.Console()],
    exitOnError: true,
  });

  constructor(private readonly requestContext: RequestContextStore) {}

  log(message: any, ...optionalParams: any[]): void {
    this.write('log', message, optionalParams);
  }

  fatal(message: any, ...optionalParams: any[]): void {
    this.write('fatal', message, optionalParams);
  }

  error(message: any, ...optionalParams: any[]): void {
    this.write('error', message, optionalParams);
  }

  warn(message: any, ...optionalParams: any[]): void {
    this.write('warn', message, optionalParams);
  }

  debug(message: any, ...optionalParams: any[]): void {
    this.write('debug', message, optionalParams);
  }

  verbose(message: any, ...optionalParams: any[]): void {
    this.write('verbose', message, optionalParams);
  }

  private write(level: NestLevel, message: unknown, optionalParams: unknown[]): void {
    const { context, stack, params } = splitOptionalParams(level, optionalParams);

    this.logger.log({
      level,
      ...toSafeRecord(message),
      ...this.requestContext.get(),
      context,
      errorStack: stack,
      params: params.filter((value) => value !== undefined).map(toSafeParam),
    });
  }
}

splitOptionalParams()는 현재 ConsoleLogger처럼 일반 메서드의 마지막 문자열을 context로 보고, error(message, stack?, context?)의 스택 형식을 따로 보존합니다. 그 밖의 가변 인자도 허용 목록을 거친 params로 남기므로 Nest 시스템 로그의 부가 메시지를 조용히 버리지 않습니다.

format.errors({ stack: true })와 명시적인 오류 객체는 Error의 비열거 속성인 stack이 JSON에서 사라지는 문제를 막습니다. 예시의 허용 목록은 출발점일 뿐이며 실제 서비스는 사건별 스키마로 필수 필드와 길이를 검증해야 합니다.

예시

toSafeRecord({ event: 'login.failed', message: '실패', password: 'sample-secret' })에서는 password 필드가 복사되지 않습니다. 그러나 message 문자열 자체에 비밀을 넣거나 Error.message에 비밀을 보간하면 그 값은 위 함수의 출력에도 남습니다. params의 일반 문자열과 별도 스택 문자열도 같은 검토 대상입니다. 이 함수는 허용할 필드를 고르는 예이며, 임의 문자열 속 비밀을 찾아 지우는 일반 살균기가 아닙니다. 로그 호출부부터 값 대신 승인된 사건 이름·상태를 전달하고, 필요한 추가 마스킹·길이 제한을 사건 스키마와 함께 설계합니다.

먼저 위에서 정의한 요청 문맥과 로거를 같은 모듈에 등록합니다. @Injectable()은 의존성 주입에 필요한 메타데이터를 붙이며, 클래스 인스턴스를 사용할 모듈 등록과 요청 미들웨어 적용까지 대신하지 않습니다.

src/logging/logging.module.ts
import {
  MiddlewareConsumer,
  Module,
  NestModule,
  RequestMethod,
} from '@nestjs/common';
import {
  RequestContextMiddleware,
  RequestContextStore,
} from './request-context';
import { WinstonLogger } from './winston.logger';

@Module({
  providers: [RequestContextStore, WinstonLogger],
  exports: [WinstonLogger],
})
export class LoggingModule implements NestModule {
  configure(consumer: MiddlewareConsumer): void {
    consumer
      .apply(RequestContextMiddleware)
      .forRoutes({ path: '/{*path}', method: RequestMethod.ALL });
  }
}

현재 src/app.module.ts에는 다음 import를 추가하고, 기존 @Module()의 imports 배열에 LoggingModule을 추가합니다. ConfigModule, 인증·DB 모듈과 기존 controller/provider 등록은 유지합니다.

src/app.module.ts (import 추가)
import { LoggingModule } from './logging/logging.module';

위 forRoutes()는 본문의 Nest 11·Express HTTP 예제에서 루트와 하위 경로를 대상으로 합니다. class 미들웨어는 consumer.apply()로 연결되고, 그 미들웨어와 WinstonLogger는 같은 모듈의 RequestContextStore를 주입받습니다. 다른 모듈에서 Store를 다시 providers에 등록해 별도 인스턴스를 만들지 않습니다.

이제 모듈이 만든 DI 인스턴스를 Nest 시스템 로거로 연결합니다. WinstonLogger를 찾지 못한다면 루트의 LoggingModule import부터 확인하세요. 처리 중인 HTTP 요청의 로그에 requestId가 없다면 미들웨어 적용 범위와 context.run() 안에서 로그를 남겼는지 확인합니다. 시작 로그나 요청 밖의 배경 작업에는 요청 문맥이 없을 수 있으며, 이 등록이 RPC나 분산 추적까지 자동으로 연결하지는 않습니다. 기존 main.ts의 전역 파이프와 접두사 등 다른 설정도 유지합니다.

src/main.ts
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { WinstonLogger } from './logging/winston.logger';

async function bootstrap(): Promise<void> {
  const app = await NestFactory.create(AppModule, { bufferLogs: true });
  app.useLogger(app.get(WinstonLogger));
  await app.listen(process.env.PORT ?? 3000);
}

void bootstrap();

Winston의 exceptionHandlers와 rejectionHandlers는 처리되지 않은 오류를 마지막으로 기록하는 장치이지 복구 로직이 아닙니다. Node.js는 처리되지 않은 거부를 기본적으로 잡히지 않은 예외로 올릴 수 있고, 잡히지 않은 예외 뒤에는 정상 처리를 계속하는 것이 안전하지 않습니다. 기록과 제한된 종료 정리 후 프로세스를 끝내고 별도 프로세스 관리자나 오케스트레이터가 새 인스턴스를 시작하게 합니다.

파일 transport를 추가한다면 디스크 부족과 권한 오류를 포함한 transport의 error 이벤트도 감시해야 합니다. 로깅 실패가 무한 재귀 로그나 애플리케이션 디스크 고갈로 이어지지 않도록 별도 실패 채널과 용량 경보를 둡니다.


운영 로그와 감사 증적의 경계

운영 로그와 감사 증적은 사건 이름과 상관 ID를 공유할 수 있지만 저장 규칙은 분리합니다.

운영 로그와 감사 증적을 목적, 기록 시점, 핵심 메타데이터, 상관 ID, 일관성 경계, 무결성, 보존과 접근, 실패 처리 기준으로 비교한 의미 표와 모바일 카드

Nest · Log and audit comparison

운영 로그와 감사 증적의 기준

운영 로그는 장애·성능 원인을 찾고 감사 증적은 행위자·대상·결과를 재구성합니다. 같은 상관 ID를 사용하더라도 원자성·무결성·보존·조회 권한은 각각 설계합니다.

설계 기준 × 운영 로그 × 감사 증적
설계 기준 운영 로그 감사 증적
목적 장애 원인, 지연, 재시도, 용량과 보안 이상 징후를 찾습니다. 중요 행위의 주체·대상·결과·순서를 책임 있게 재구성합니다.
대상·시점 요청·오류·상태 변화 중 관측 가치가 있는 사건을 레벨과 샘플링 정책에 따라 기록합니다. 권한 변경, 생성·수정·삭제, 결제·내보내기 같은 지정 행위를 실제 결과와 함께 기록합니다.
핵심 필드 event, level, service, context, duration, 구조화된 Error UTC 사건·수신 시각, actor, action, resource, outcome, 허용된 변경 요약
상관관계 requestId와 W3C traceId로 요청과 서비스 호출을 검색합니다. eventId에 requestId·traceId를 더해 업무 변경과 운영 흐름을 연결합니다.
일관성 비동기 transport와 중앙 수집을 사용할 수 있으며 일부 진단 로그는 샘플링할 수 있습니다. 성공한 업무 변경과 audit outbox를 같은 DB 트랜잭션에 기록하고 relay는 at-least-once로 재시도하며 목적지는 eventId 중복을 거부합니다.
무결성 전송 암호화, 인덱스 권한, 수집 중단·변조·삭제 감지가 필요합니다. append-only 쓰기 권한과 변조 증거, 읽기·쓰기 역할 분리, 조회와 내보내기 자체의 감사가 필요합니다.
보존·접근 검색 비용과 운영 필요 기간에 맞추고 민감 필드를 최소화하며 운영자 접근을 제한합니다. 법적·계약상 목적별 기간, 승인된 조사자, 정기 권한 검토와 기한 후 안전한 폐기를 적용합니다.
실패 처리 transport 오류와 용량 고갈을 별도 경보로 내고 로그 재귀와 서비스 장애 확대를 막습니다. 성공 이벤트 누락·중복과 계약된 sequence의 누락·역전을 감시하고 DENIED·FAILED를 성공과 구분해 남깁니다.
목적

원인 탐색과 책임 재구성을 구분한다

운영 로그
장애 원인, 지연, 재시도, 용량과 보안 이상 징후를 찾습니다.
감사 증적
중요 행위의 주체·대상·결과·순서를 책임 있게 재구성합니다.
기록 시점

관측 정책과 지정 업무 행위를 구분한다

운영 로그
요청·오류·상태 변화 중 관측 가치가 있는 사건을 레벨과 샘플링 정책에 따라 기록합니다.
감사 증적
권한 변경, 생성·수정·삭제, 결제·내보내기 같은 지정 행위를 실제 결과와 함께 기록합니다.
metadata

관측 필드와 책임 필드의 중심이 다르다

운영 로그
event, level, service, context, duration, 구조화된 Error
감사 증적
UTC 사건·수신 시각, actor, action, resource, outcome, 허용된 변경 요약
correlation

공유 ID는 연결 수단이지 저장소 통합 근거가 아니다

운영 로그
requestId와 W3C traceId로 요청과 서비스 호출을 검색합니다.
감사 증적
eventId에 requestId·traceId를 더해 업무 변경과 운영 흐름을 연결합니다.
atomicity

감사 성공 이벤트는 업무 변경과 함께 커밋한다

운영 로그
비동기 transport와 중앙 수집을 사용할 수 있으며 일부 진단 로그는 샘플링할 수 있습니다.
감사 증적
성공한 업무 변경과 audit outbox를 같은 DB 트랜잭션에 기록하고 relay는 at-least-once로 재시도하며 목적지는 eventId 중복을 거부합니다.
integrity

로그 보관만으로 감사 무결성이 생기지 않는다

운영 로그
전송 암호화, 인덱스 권한, 수집 중단·변조·삭제 감지가 필요합니다.
감사 증적
append-only 쓰기 권한과 변조 증거, 읽기·쓰기 역할 분리, 조회와 내보내기 자체의 감사가 필요합니다.
retention · access

목적별 기간과 조회자를 따로 정한다

운영 로그
검색 비용과 운영 필요 기간에 맞추고 민감 필드를 최소화하며 운영자 접근을 제한합니다.
감사 증적
법적·계약상 목적별 기간, 승인된 조사자, 정기 권한 검토와 기한 후 안전한 폐기를 적용합니다.
failure

중단·누락·중복·거짓 결과를 각각 감시한다

운영 로그
transport 오류와 용량 고갈을 별도 경보로 내고 로그 재귀와 서비스 장애 확대를 막습니다.
감사 증적
성공 이벤트 누락·중복과 계약된 sequence의 누락·역전을 감시하고 DENIED·FAILED를 성공과 구분해 남깁니다.
shared

공유하는 것: 사건 이름과 상관 ID

같은 요청의 오류와 업무 변경을 함께 찾을 수 있도록 이름과 ID 계약을 맞춥니다. actorId나 resourceId는 고카디널리티 필드이므로 메트릭 레이블에는 쓰지 않습니다.

separate

분리하는 것: 원자성·무결성·보존·접근

운영 로그의 회전·샘플링·관리자 권한을 감사 증적에 그대로 적용하지 않습니다. 최종 증적의 신뢰성은 별도 통제와 검증 결과로 설명해야 합니다.

감사 저장소가 append-only라고 해서 완전한 부인 방지가 자동으로 성립하지는 않습니다. 행위자 인증, 시계 신뢰, 원자적 기록, 변조 탐지와 독립적인 접근 검토를 함께 운영해야 합니다.

감사 대상은 “모든 함수 호출”이 아니라 권한 변경, 사용자·결제·주문 상태 변경, 삭제, 내보내기, 보안 정책 변경처럼 책임과 재구성이 필요한 행위입니다.

한 건의 감사 이벤트에는 다음 메타데이터를 명시합니다.

  • 시간: UTC 사건 시각과 저장소 수신 시각, 동기화된 시계와 원본 시간의 신뢰도
  • 행위자: 사용자·서비스·관리자 같은 주체 종류와 안정적인 ID
  • 행위와 대상: action, resourceType, resourceId
  • 결과: SUCCEEDED, DENIED, FAILED처럼 실제 결과를 나타내는 값
  • 상관관계: eventId, requestId, traceId
  • 변경 요약: 허용된 필드 이름과 필요한 이전·이후 값. 비밀번호와 토큰은 제외

비즈니스 변경과 같은 트랜잭션에 기록

업무 데이터를 먼저 커밋한 다음 다른 저장소에 감사 로그를 쓰면 둘 중 하나만 성공하는 이중 쓰기 문제가 생깁니다. 관계형 데이터베이스에서는 업무 변경과 감사 이벤트용 outbox 행을 같은 트랜잭션에 넣을 수 있습니다.

아래의 timestamptz와 jsonb는 PostgreSQL을 선택한 엔티티 예입니다. 연결된 DataSource에 User와 AuditOutbox를 모두 등록하고 해당 테이블·제약을 migration으로 준비해야 합니다. 앞 장의 메모리 사용자 배열이나 SQLite/MySQL 설정에 이 타입을 그대로 붙이는 실습은 아닙니다. relay와 최종 증적 저장소는 이어지는 설명의 별도 구현 범위입니다.

src/audit/audit-outbox.entity.ts
import { Column, CreateDateColumn, Entity, PrimaryColumn } from 'typeorm';

@Entity('audit_outbox')
export class AuditOutbox {
  @PrimaryColumn('uuid')
  eventId!: string;

  @Column({ type: 'timestamptz' })
  occurredAt!: Date;

  @CreateDateColumn({ type: 'timestamptz' })
  recordedAt!: Date;

  @Column()
  actorType!: string;

  @Column()
  actorId!: string;

  @Column()
  action!: string;

  @Column()
  resourceType!: string;

  @Column()
  resourceId!: string;

  @Column()
  outcome!: string;

  @Column()
  requestId!: string;

  @Column({ nullable: true })
  traceId?: string;

  @Column({ type: 'jsonb' })
  changes!: { fields: string[] };
}
src/users/users.service.ts
import { randomUUID } from 'node:crypto';
import { Injectable } from '@nestjs/common';
import { DataSource } from 'typeorm';
import { AuditOutbox } from '../audit/audit-outbox.entity';
import { User } from './user.entity';

interface AuditActor {
  type: 'USER' | 'SERVICE' | 'ADMIN';
  id: string;
}

@Injectable()
export class UsersService {
  constructor(private readonly dataSource: DataSource) {}

  async createUser(
    actor: AuditActor,
    input: Pick<User, 'email'>,
    requestId: string,
    traceId?: string,
  ): Promise<User> {
    return this.dataSource.transaction(async (manager) => {
      const user = await manager.save(User, manager.create(User, input));

      await manager.insert(AuditOutbox, {
        eventId: randomUUID(),
        occurredAt: new Date(),
        actorType: actor.type,
        actorId: actor.id,
        action: 'USER_CREATED',
        resourceType: 'User',
        resourceId: String(user.id),
        outcome: 'SUCCEEDED',
        requestId,
        traceId,
        changes: { fields: ['email'] },
      });

      return user;
    });
  }
}

트랜잭션 안에서는 반드시 콜백으로 전달된 manager를 사용합니다. 업무 행 또는 outbox 삽입이 실패하면 둘 다 롤백됩니다.

별도 relay는 커밋된 outbox만 읽어 감사 저장소로 전달합니다. publish 뒤 전달 상태를 기록하기 전에 중단되면 같은 사건을 다시 보낼 수 있으므로, 목적지는 eventId에 unique 제약을 두고 멱등하게 수락해야 합니다. relay는 전달 상태와 재시도를 추적하고, 사건 순서가 요구되면 aggregate·partition key와 단조 증가 sequence를 함께 기록해 consumer가 누락·역전을 검증하게 합니다. 권한 거부나 업무 트랜잭션 자체의 실패는 성공 outbox와 다른 보안 사건 경로에서 실제 DENIED 또는 FAILED 결과로 남깁니다.

스스로 확인하기

사용자 생성과 outbox 사건 E1이 한 번 커밋되었습니다. relay가 E1을 전달했지만 완료 상태를 적기 전에 중단되어 같은 E1을 다시 보냅니다. 목적지의 eventId unique 제약과 멱등 수락이 정상 동작한다면 증적은 몇 건이어야 할까요? 이 제약이 HTTP 사용자 생성 요청을 두 번 실행하는 것까지 막을까요? 후자는 업무 요청 두 번이 각각 허용되어 커밋되고, 코드가 서로 다른 UUID 사건을 만든 조건으로 판단하세요.

전달 재시도와 업무 실행 재시도를 구분하기

같은 E1의 중복 전달은 목적지에서 기존 사건으로 인식하므로 증적 한 건이어야 합니다. 전달 시도는 두 번이어도 사건이 두 개가 되지는 않습니다. 반면 두 업무 실행이 각각 커밋되어 E1, E2를 만들었다면 UUID가 달라 두 사건 모두 기록됩니다. 업무 요청 중복을 막으려면 별도의 요청 키·업무 unique 제약·트랜잭션 정책을 설계해야 합니다. 실제 User 제약이 두 번째 실행을 거부하는 경우는 문제에서 가정한 두 번의 커밋과 다른 조건입니다.

outbox는 전달 경계이지 그 자체만으로 최종 감사 무결성을 보장하지 않습니다. 최종 저장소에는 append-only 쓰기 권한, 수정·삭제 탐지, 필요 시 WORM 보존이나 서명·해시 같은 변조 증거, 전송 암호화, 읽기와 쓰기 역할 분리, 모든 조회·내보내기 감사가 필요합니다.

보존 기간은 법적·계약상 최소 기간보다 짧아서도 안 되고 목적이 끝난 뒤 무기한 길어서도 안 됩니다. 운영 로그와 감사 증적에 별도 정책을 두고, 접근 권한을 정기 검토하며, 수집 중단·지연·삭제 시도를 경보로 연결합니다.


점검 목록

  • 사건 이름과 필드 스키마가 서비스마다 일관적인가?
  • Error의 이름·메시지·스택이 구조화되어 남는가?
  • 요청·추적 ID가 비동기 작업과 다음 HTTP 호출까지 이어지는가?
  • 고카디널리티 필드를 메트릭 레이블로 사용하지 않는가?
  • 비밀 값과 불필요한 개인정보가 허용 목록 밖에서 제거되는가?
  • 처리되지 않은 예외·거부 뒤에 프로세스를 정상 상태처럼 계속 실행하지 않는가?
  • 중요한 업무 변경과 audit outbox가 같은 데이터베이스 트랜잭션에 있는가?
  • 목적지가 eventId로 중복을 거부하고, 순서가 필요하면 key와 sequence로 누락·역전을 검증하는가?
  • 감사 저장소가 append-only, 변조 탐지, 제한된 접근, 정해진 보존·폐기 정책을 갖는가?

로깅은 관측 가능성을 만들고 감사 추적은 중요한 행위의 재구성 가능성을 만듭니다. 두 경로를 목적에 맞게 분리하고 상관 ID로 연결해야 장애 조사와 보안 조사 결과를 모두 신뢰할 수 있습니다.