본문 바로가기

로깅과 감사 추적

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건시도 수만 늘고 동일 증적은 중복되지 않습니다.

위 그림은 두 기록이 서로 다른 길로 흐르는 모습을 보여 줍니다. HTTP 요청과 실행 중의 사건은 요청·추적 정보를 붙이고 LoggerService에서 형식을 맞추며 민감한 필드를 뺀 뒤 구조화된 운영 로그가 됩니다. 반면 중요한 업무 변경은 같은 데이터베이스 트랜잭션 안에서 audit outbox에 기록되고, relay를 거쳐 별도의 감사 저장소에 쌓입니다. 두 길은 상관 ID로 연결되지만 저장소와 보존 규칙은 따로 둡니다.


구조화 로깅의 기본

문자열 한 줄만 남기면 검색 시스템은 그 의미를 다시 추측해야 합니다. 애플리케이션에서 사건의 이름과 필드 형식을 먼저 정하고 JSON 레코드로 기록합니다. 운영 로그 한 건에는 보통 다음 필드가 필요합니다.

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

requestId와 traceId는 검색 필드로는 유용하지만 값의 종류가 끝없이 늘어나는 고카디널리티(high cardinality) 데이터입니다. 지표의 label이나 대시보드의 그룹 키로 쓰지 않습니다.

비밀번호, 세션과 API 토큰, Authorization·Cookie 헤더, 결제 정보, 요청 본문 전체는 기본적으로 기록하지 않습니다. 마스킹 정규식 하나에 기대기보다, 기록해도 되는 필드를 먼저 정하고 길이와 형식을 제한합니다.

NestJS 로그 레벨

NestJS의 표준 레벨은 심각도가 높은 순서로 fatal, error, warn, log, debug, verbose입니다. log는 일반 정보성 사건에 해당합니다. Nest 공식 로거 문서에 따르면 레벨은 배열에 적은 가장 심각한 레벨부터 아래로 이어지므로, ['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 {
    // 메시지 뒤의 객체는 같은 로그 레코드의 구조화된 params가 됩니다.
    this.logger.log('인사말을 반환했습니다.', {
      event: 'greeting.requested',
      requestId,
      resource: 'GET /',
      outcome: 'SUCCEEDED',
    });

    return 'Hello World!';
  }
}

공식 문서에 따르면 NestJS 12부터 첫 번째 메시지 뒤에 넘긴 일반 객체는 별도의 로그 레코드가 아니라 같은 로그 레코드의 구조화된 params로 붙습니다. JSON 모드에서는 이 값이 params 아래에 들어갑니다. 그래서 문자열 보간으로 값을 메시지에 이어 붙이지 않고 필드를 객체로 넘깁니다. 오류도 error.toString()만 남기지 말고 이름, 메시지, 스택을 구조화해 보존합니다.

이 예제의 getHello(requestId)는 호출하는 쪽이 ID를 넘기는 경우입니다. 앞 장처럼 인자 없이 getHello()를 호출하면 ID가 저절로 생기지 않으므로, 호출하는 곳에서 만든 ID를 넘기거나 아래의 요청 문맥 저장소와 middleware를 연결해 로그를 남기는 시점에 문맥을 읽습니다.

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

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 호출에도 전달해야 한 요청의 span이 끊기지 않습니다.

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도 같은 방식으로 전달하되, 로그 주입을 막고 카디널리티가 지나치게 늘지 않게 합니다.

더 깊이: AsyncLocalStorage의 문맥이 끊기는 경우

이 교재가 기준으로 삼는 Node.js 24에서는 AsyncLocalStorage의 내부 구현이 바뀌었습니다. Node.js 24.0.0 변경 기록에 따르면 이 버전부터 AsyncLocalStorage는 기본으로 AsyncContextFrame을 써서 비동기 문맥을 추적하며, 변경 기록은 이것이 더 효율적인 구현이라고 설명합니다. 사용하는 API는 그대로이므로 위의 run() 코드를 바꿀 필요는 없습니다.

Node.js 문서는 대부분의 경우 문제없이 동작하지만, 드물게 어떤 비동기 작업에서 문맥이 사라질 수 있다고 안내합니다. 콜백 방식의 API라면 util.promisify()로 바꿔 네이티브 Promise를 쓰게 하면 되고, 콜백 API를 그대로 써야 하거나 직접 만든 thenable을 쓰는 코드라면 AsyncResource로 비동기 작업을 올바른 문맥에 묶습니다. 원인을 찾을 때는 의심되는 호출 뒤마다 getStore()의 값을 기록해, 처음으로 undefined가 나온 지점의 직전 콜백을 살펴봅니다. 문맥에 들어가는 방법으로 enterWith()도 있지만, 이 메서드는 실험 단계이고 현재 동기 실행이 끝날 때까지 문맥을 바꿔 뒤에 실행되는 다른 이벤트 처리기까지 같은 문맥에 들어가게 할 수 있으므로, 문서도 특별한 이유가 없다면 run()을 쓰라고 권합니다.


Winston으로 LoggerService 구현하기

Winston은 구조화된 형식과 여러 출력 대상(transport)을 제공합니다. 현재 버전은 3.19입니다. 컨테이너 환경에서는 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 };
}

// Winston의 log(entry)는 문자열 message가 있는 객체를 받습니다.
type SafeRecord = { event: string; message: string; [key: string]: unknown };

function toSafeRecord(message: unknown): SafeRecord {
  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?) 형식의 스택을 따로 보존합니다. 앞의 Logger 예제처럼 메시지 뒤에 넘긴 객체는 params로 들어가며, toSafeParam()이 허용한 필드만 복사합니다. 그래서 Nest 시스템 로그의 부가 정보도 조용히 버려지지 않습니다.

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

위에서 정의한 요청 문맥과 로거를 같은 모듈에 등록합니다. @Injectable()은 의존성 주입에 필요한 메타데이터를 붙일 뿐, 모듈 등록이나 middleware 적용까지 해 주지는 않습니다.

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()의 '/{*path}'는 NestJS 12의 Express 5 경로 규칙에서 루트 경로를 포함한 모든 경로를 뜻합니다. class middleware는 consumer.apply()로 연결되며, 이 middleware와 WinstonLogger는 같은 모듈의 RequestContextStore를 주입받습니다. 다른 모듈에서 Store를 providers에 다시 등록해 별도 인스턴스를 만들지 않습니다.

이제 모듈이 만든 인스턴스를 Nest 시스템 로거로 연결합니다. WinstonLogger를 찾지 못한다면 루트 모듈의 LoggingModule import부터 확인합니다. 처리 중인 HTTP 요청의 로그에 requestId가 없다면 middleware의 적용 범위와, context.run() 안에서 로그를 남겼는지 확인합니다. 시작 로그나 요청 밖에서 도는 작업에는 요청 문맥이 없을 수 있으며, 이 등록이 RPC나 분산 추적까지 자동으로 연결해 주지는 않습니다.

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는 처리되지 않은 Promise 거부를 잡히지 않은 예외로 올릴 수 있고, 잡히지 않은 예외 뒤에 정상 처리를 이어 가는 것은 안전하지 않습니다. 기록하고 필요한 정리만 한 뒤 프로세스를 끝내고, 프로세스 관리자나 오케스트레이터가 새 인스턴스를 띄우게 합니다.

파일 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라고 해서 완전한 부인 방지가 자동으로 성립하지는 않습니다. 행위자 인증, 시계 신뢰, 원자적 기록, 변조 탐지와 독립적인 접근 검토를 함께 운영해야 합니다.

위 표는 운영 로그와 감사 기록을 목적, 기록 시점, 핵심 메타데이터, 상관 ID, 일관성 범위, 무결성, 보존과 접근, 실패 처리 기준으로 나란히 비교합니다. 운영 로그는 장애와 성능의 원인을 찾기 위해 사건이 일어날 때 남기며 일부 손실을 감수할 수 있지만, 감사 기록은 업무 변경과 같은 트랜잭션으로 남기고 수정할 수 없게 보존하며 접근 자체도 기록해야 한다는 차이가 드러납니다.

감사 대상은 “모든 함수 호출”이 아니라 권한 변경, 사용자·결제·주문 상태 변경, 삭제, 데이터 내보내기, 보안 정책 변경처럼 누가 무엇을 했는지 나중에 재구성해야 하는 행위입니다. 감사 이벤트 한 건에는 다음 정보를 명시합니다.

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

업무 변경과 같은 트랜잭션에 기록하기

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

아래의 timestamptz와 jsonb는 PostgreSQL을 쓰는 엔티티 예시입니다. 연결된 DataSource에 User와 AuditOutbox를 모두 등록하고 테이블과 제약은 migration으로 준비해야 합니다. 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[] };
}

엔티티의 열은 앞 목록의 감사 정보에 하나씩 대응합니다. occurredAt은 코드가 사건 시각을 직접 넣는 열이고, recordedAt은 @CreateDateColumn이라 행이 삽입될 때 자동으로 채워지므로 두 시각을 따로 남길 수 있습니다. changes에는 바뀐 필드의 이름만 담아 값 자체가 감사 행에 복사되지 않게 합니다. 다음 서비스 코드에서는 transaction 콜백 안의 manager.save와 manager.insert가 한 트랜잭션으로 묶이는 부분을 봅니다.

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만 읽어 감사 저장소로 전달합니다. 전달한 뒤 완료 상태를 기록하기 전에 중단되면 같은 사건을 다시 보낼 수 있으므로, 목적지는 eventId에 고유 제약을 두고 같은 사건을 멱등하게 받아야 합니다. relay는 전달 상태와 재시도를 추적하고, 사건 순서가 중요하다면 aggregate·partition key와 계속 증가하는 sequence를 함께 기록해 소비하는 쪽이 누락이나 순서 뒤바뀜을 검증하게 합니다. 권한 거부나 업무 트랜잭션 자체의 실패는 성공 outbox와 다른 보안 사건 경로에서 실제 DENIED나 FAILED 결과로 남깁니다.

outbox는 전달을 위한 장치일 뿐, 그것만으로 최종 감사 기록의 무결성을 보장하지 않습니다. 최종 저장소에는 추가만 가능한(append-only) 쓰기 권한, 수정·삭제 탐지, 필요하면 WORM 보존이나 서명·해시 같은 변조 탐지, 전송 암호화, 읽기와 쓰기 역할의 분리, 그리고 모든 조회와 내보내기에 대한 감사가 필요합니다.

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

로깅은 시스템을 관측할 수 있게 하고, 감사 추적은 중요한 행위를 나중에 재구성할 수 있게 합니다. 두 경로를 목적에 맞게 나누고 상관 ID로 연결해야 장애 조사와 보안 조사의 결과를 모두 믿을 수 있습니다.


정리

  • 운영 로그는 장애와 성능 원인을 찾는 관측 데이터이고, 감사 기록은 중요한 업무 변경을 재구성하기 위한 기록이므로 저장과 보존 규칙을 따로 둡니다.
  • NestJS 12의 Logger는 메시지 뒤에 넘긴 일반 객체를 같은 레코드의 구조화된 params로 붙이며, ConsoleLogger의 json: true로 JSON 출력을 켭니다.
  • 요청 문맥은 AsyncLocalStorage와 middleware로 전달하고, 서비스 사이의 추적에는 W3C Trace Context를 지원하는 OpenTelemetry 같은 도구를 씁니다.
  • 로그에는 허용한 필드만 남기고, 비밀번호·토큰·인증 헤더·요청 본문 전체는 기록하지 않습니다.
  • 감사 이벤트는 업무 변경과 같은 트랜잭션의 outbox에 남기고, 목적지는 eventId로 중복을 거르며 추가만 가능한 저장소에 보존합니다.

연습 문제

1. 다시 전달된 감사 사건

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

해설 보기

같은 E1이 다시 전달되면 목적지가 이미 있는 사건으로 알아보므로 감사 기록은 한 건이어야 합니다. 전달 시도가 두 번이어도 사건이 두 개가 되지는 않습니다.

반면 업무 요청이 두 번 실행되어 각각 커밋되었다면 E1과 E2가 만들어지고 UUID가 다르므로 두 사건 모두 기록됩니다. 업무 요청의 중복을 막으려면 요청 키, 업무 데이터의 고유 제약, 트랜잭션 정책을 따로 설계해야 합니다.

2. 허용 목록이 막지 못하는 비밀

toSafeRecord({ event: 'login.failed', message: '실패', password: 'sample-secret' })를 호출하면 password는 기록될까요? 그렇다면 logger.warn('login failed for token ' + token)처럼 메시지 문자열에 토큰을 넣은 경우는 어떨까요?

해설 보기

toSafeRecord()는 event, message, resource, outcome, durationMs처럼 허용한 필드만 복사하므로 password 필드는 기록되지 않습니다.

하지만 메시지 문자열 자체에 토큰을 넣으면, message는 허용된 필드이므로 토큰이 그대로 기록됩니다. 이 함수는 허용할 필드를 고를 뿐, 임의의 문자열 속에서 비밀을 찾아 지우는 일반적인 살균기가 아닙니다. 로그를 남기는 곳에서부터 값 대신 승인된 사건 이름과 상태를 넘기고, 필요한 마스킹과 길이 제한은 사건 스키마와 함께 설계합니다.

3. 문맥이 따라가는 곳과 끊기는 곳

요청 middleware가 context.run() 안에서 다음 처리를 실행하는 상태입니다. ① handler 안에서 setTimeout(() => this.logger.log('지연 작업 완료'), 100)을 실행했습니다. ② handler가 6장의 BullMQ 큐에 작업을 넣었고, 나중에 Worker가 그 작업을 처리하며 로그를 남겼습니다. 각 로그에 requestId가 들어갈까요?

해설 보기

①은 들어갑니다. AsyncLocalStorage의 문맥은 run() 안에서 만든 타이머와 Promise 같은 비동기 작업으로 이어지므로, 100ms 뒤의 콜백에서도 같은 요청의 문맥을 읽을 수 있습니다.

②는 들어가지 않습니다. Worker는 Redis에서 작업을 꺼내 별도의 흐름으로 처리하므로, 요청을 처리하던 비동기 문맥과 이어져 있지 않습니다. 작업에서도 같은 ID로 추적하고 싶다면 작업 데이터에 requestId나 추적 정보를 명시적으로 담고, Worker가 처리를 시작할 때 그 값으로 문맥을 다시 만들어야 합니다.

다음 글 보안 모범 사례와 취약점 스캐닝에서는 지금까지 다룬 방어를 점검표로 묶고, 의존성과 코드의 취약점을 자동으로 찾는 방법을 다룹니다.