로깅과 감사 추적
NestJS에서 구조화 운영 로그와 감사 증적을 분리하고 요청 문맥, 오류, 트랜잭션 경계, 보존·접근 정책까지 설계합니다.
이 절에서는 NestJS 애플리케이션의 운영 로깅(operational logging)과 감사 추적(audit trail)을 다룹니다.
두 기록은 같은 requestId나 traceId로 연결할 수 있지만 목적과 신뢰 경계가 다릅니다. 운영 로그는 장애와 성능 문제를 찾기 위한 관측 데이터이고, 감사 증적은 중요한 비즈니스 변경을 나중에 재구성하기 위한 기록입니다. 일반 로그를 오래 보관한다고 자동으로 감사 증적이 되지는 않습니다.
NestJS · event to structured record · data flow
사건을 구조화하되 운영 로그와 감사 증적은 다른 경계에 쓴다
요청 문맥과 명명된 필드가 검색 가능한 레코드를 만듭니다. 운영 사건은 LoggerService 경계에서 정규화하고, 중요한 비즈니스 변경은 성공한 변경과 audit outbox를 한 트랜잭션에 묶은 뒤 별도 증적 저장소로 전달합니다.
사건에 요청 문맥을 더해 구조화 레코드로 만든다
- HTTP · 런타임 사건
요청, 오류, 인증 사건 중 기록할 신호를 명명합니다.
- 요청 문맥
requestId,traceId, actor와 Nest context를 비동기 호출 체인에 전달합니다. LoggerService레벨과 허용 필드를 적용하고
Error의 이름, 메시지, 스택을 구조화합니다.- 중앙 관측
인덱스·검색·알림과 운영용 보존·접근 정책을 적용합니다.
업무 변경과 audit outbox를 함께 커밋한다
- 중요 비즈니스 변경
행위자·행위·대상·결과와 변경 요약을 사건으로 만듭니다.
- 같은 DB 트랜잭션
domain row와 audit outbox가 함께 커밋되거나 함께 롤백됩니다.
- 재시도 가능한 relay
중복 전달을 허용하고, 목적지가
eventIdunique 제약으로 멱등 수락합니다. 순서가 필요하면 key와 sequence를 추가합니다. - 별도 증적 저장소
append-only 권한, 변조 탐지, 제한된 조회, 보존과 폐기 정책을 둡니다.
requestId와 traceId는 두 기록을 연결한다
운영 로그와 감사 증적을 같은 저장 정책으로 합치지 않고, 안정적인 상관 ID로 장애 흐름과 책임 흐름을 함께 조회합니다.
일반 로그만으로 감사 무결성을 주장하지 않는다
샘플링·회전·관리자 수정이 가능한 운영 로그는 최종 증적이 아닙니다. 업무 변경의 트랜잭션 경계와 별도 무결성·접근 통제가 필요합니다.
위 경로는 관측을 위한 구조화, 아래 경로는 책임 있는 변경의 재구성을 위한 증적입니다. 상관 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) 데이터입니다. 메트릭 레이블이나 대시보드 그룹 키로 무제한 사용하지 않습니다.
비밀번호, 세션·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가 됩니다.
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()만 남기지 말고 이름, 메시지, 스택을 구조화하여 보존해야 합니다.
전역 레벨과 내장 콘솔 출력 형식은 부트스트랩에서 정할 수 있습니다.
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는 비동기 호출 체인에 요청 문맥을 묶는 데 사용할 수 있습니다.
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 winstonNestJS의 현재 LoggerService 계약은 각 메서드가 message 뒤에 가변 인자를 받는 형태입니다. 타입 계약에서 debug, verbose, fatal은 선택적일 수 있지만 Nest 표준 레벨에는 fatal이 포함되므로 어댑터는 이를 명시적으로 구현합니다. Winston 기본 npm 레벨에는 fatal이 없기 때문에 아래 예시는 Nest 레벨을 별도로 정의해 fatal을 error 플래그로 축소하지 않습니다.
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에서 사라지는 문제를 막습니다. 예시의 허용 목록은 출발점일 뿐이며 실제 서비스는 사건별 스키마로 필수 필드와 길이를 검증해야 합니다.
DI 인스턴스를 Nest 시스템 로거로 연결합니다.
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를 공유할 수 있지만 저장 규칙은 분리합니다.
NestJS · operational log vs audit evidence · semantic table
같은 사건을 보더라도 묻는 질문과 보장 수준이 다르다
운영 로그는 “왜 느리거나 실패했는가”를, 감사 증적은 “누가 어떤 대상에 무엇을 했고 결과가 무엇인가”를 재구성합니다. 상관 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를 성공과 구분해 남깁니다. |
원인 탐색과 책임 재구성을 구분한다
운영: 장애·지연·재시도·이상 징후를 찾습니다.
감사: 누가 어떤 대상에 무엇을 했고 결과가 어땠는지 재구성합니다.
관측 정책과 지정 업무 행위를 구분한다
운영: 레벨과 샘플링 정책에 따라 요청·오류·상태 사건을 기록합니다.
감사: 권한 변경·생성·수정·삭제·결제 같은 지정 행위와 실제 결과를 기록합니다.
관측 필드와 책임 필드의 중심이 다르다
운영: event · level · service · duration · structured Error
감사: UTC 사건·수신 시각 · actor · action · resource · outcome · 변경 요약
공유 ID는 연결 수단이지 저장소 통합 근거가 아니다
운영: requestId와 W3C traceId로 요청 흐름을 검색합니다.
감사: 고유 eventId에 같은 상관 ID를 더해 업무 변경과 운영 흐름을 연결합니다.
감사 성공 이벤트는 업무 변경과 함께 커밋한다
운영: 비동기 transport와 샘플링을 사용할 수 있습니다.
감사: domain row + audit outbox를 같은 트랜잭션에 두고 relay를 멱등 처리합니다.
로그 보관만으로 감사 무결성이 생기지 않는다
운영: 전송 보호와 인덱스 접근, 수집 중단·삭제 감지가 필요합니다.
감사: append-only 권한, 변조 증거, 역할 분리와 조회 및 내보내기 감사가 필요합니다.
목적별 기간과 조회자를 따로 정한다
운영: 검색 비용과 운영 필요 기간에 맞추고 민감 필드를 최소화합니다.
감사: 법적·계약상 기간, 승인된 조사자, 정기 권한 검토와 기한 후 폐기를 적용합니다.
중단·누락·중복·거짓 결과를 각각 감시한다
운영: transport 오류와 디스크·큐 용량을 별도 경보로 연결합니다.
감사: 성공 사건 누락과 relay 중복·지연을 감시하고, 순서 계약이 있으면 sequence 누락·역전을 확인하며 DENIED·FAILED를 구분합니다.
공유하는 것: 사건 이름과 상관 ID
같은 요청의 오류와 업무 변경을 함께 찾을 수 있도록 이름과 ID 계약을 맞춥니다. actorId나 resourceId는 고카디널리티 필드이므로 메트릭 레이블에는 쓰지 않습니다.
분리하는 것: 원자성·무결성·보존·접근
운영 로그의 회전·샘플링·관리자 권한을 감사 증적에 그대로 적용하지 않습니다. 최종 증적의 신뢰성은 별도 통제와 검증 결과로 설명해야 합니다.
감사 저장소가 append-only라고 해서 완전한 부인 방지가 자동으로 성립하지는 않습니다. 행위자 인증, 시계 신뢰, 원자적 기록, 변조 탐지와 독립적인 접근 검토를 함께 운영해야 합니다.
감사 대상은 “모든 함수 호출”이 아니라 권한 변경, 사용자·결제·주문 상태 변경, 삭제, 내보내기, 보안 정책 변경처럼 책임과 재구성이 필요한 행위입니다.
한 건의 감사 이벤트에는 다음 메타데이터를 명시합니다.
- 시간: UTC 사건 시각과 저장소 수신 시각, 동기화된 시계와 원본 시간의 신뢰도
- 행위자: 사용자·서비스·관리자 같은 주체 종류와 안정적인 ID
- 행위와 대상:
action,resourceType,resourceId - 결과:
SUCCEEDED,DENIED,FAILED처럼 실제 결과를 나타내는 값 - 상관관계:
eventId,requestId,traceId - 변경 요약: 허용된 필드 이름과 필요한 이전·이후 값. 비밀번호와 토큰은 제외
비즈니스 변경과 같은 트랜잭션에 기록
업무 데이터를 먼저 커밋한 다음 다른 저장소에 감사 로그를 쓰면 둘 중 하나만 성공하는 이중 쓰기 문제가 생깁니다. 관계형 데이터베이스에서는 업무 변경과 감사 이벤트용 outbox 행을 같은 트랜잭션에 넣을 수 있습니다.
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[] };
}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는 전달 경계이지 그 자체만으로 최종 감사 무결성을 보장하지 않습니다. 최종 저장소에는 append-only 쓰기 권한, 수정·삭제 탐지, 필요 시 WORM 보존이나 서명·해시 같은 변조 증거, 전송 암호화, 읽기와 쓰기 역할 분리, 모든 조회·내보내기 감사가 필요합니다.
보존 기간은 법적·계약상 최소 기간보다 짧아서도 안 되고 목적이 끝난 뒤 무기한 길어서도 안 됩니다. 운영 로그와 감사 증적에 별도 정책을 두고, 접근 권한을 정기 검토하며, 수집 중단·지연·삭제 시도를 경보로 연결합니다.
점검 목록
- 사건 이름과 필드 스키마가 서비스마다 일관적인가?
Error의 이름·메시지·스택이 구조화되어 남는가?- 요청·추적 ID가 비동기 작업과 다음 HTTP 호출까지 이어지는가?
- 고카디널리티 필드를 메트릭 레이블로 사용하지 않는가?
- 비밀 값과 불필요한 개인정보가 허용 목록 밖에서 제거되는가?
- 처리되지 않은 예외·거부 뒤에 프로세스를 정상 상태처럼 계속 실행하지 않는가?
- 중요한 업무 변경과 audit outbox가 같은 데이터베이스 트랜잭션에 있는가?
- 목적지가
eventId로 중복을 거부하고, 순서가 필요하면 key와 sequence로 누락·역전을 검증하는가? - 감사 저장소가 append-only, 변조 탐지, 제한된 접근, 정해진 보존·폐기 정책을 갖는가?
로깅은 관측 가능성을 만들고 감사 추적은 중요한 행위의 재구성 가능성을 만듭니다. 두 경로를 목적에 맞게 분리하고 상관 ID로 연결해야 장애 조사와 보안 조사가 모두 신뢰할 수 있습니다.