현재 데이터:
상담 상태 = CANCELED
운영 추적:
누가
언제
어떤 상태에서
어떤 상태로
왜
변경했는가?
상담 상태가 왜 바뀌었는지 모름
상품 가격/지원금 변경 원인을 모름
고객에게 알림톡이 갔는지 확인 어려움
엑셀 파일을 누가 다운로드했는지 모름
권한 변경 이력을 추적할 수 없음
장애 발생 시 원인 파악이 늦어짐
| 구분 | 목적 | 예시 |
|---|---|---|
| Audit Log | 누가 중요한 작업을 했는지 추적 | 관리자가 상품 가격 수정 |
| Event Log | 시스템에서 어떤 사건이 발생했는지 기록 | 상담 신청 생성 이벤트 발생 |
| Application Log | 개발/장애 분석용 로그 | API error stack trace |
| Access Log | 요청 접근 기록 | Nginx access log |
Audit Log:
관리자/사용자 행위 중심
Event Log:
도메인 사건 중심
Application Log:
시스템 동작/에러 중심
상담 상태 변경
Audit Log:
관리자 3번이 상담 100번 상태를 CALLING → CALLED로 변경
Event Log:
ConsultStatusChanged 이벤트 발생
Application Log:
updateConsultStatusUseCase completed in 142ms
상담 상태 변경
상담 메모 추가/수정/삭제
상담 담당자 변경
상담 개인정보 열람
상담 엑셀 다운로드
상담 데이터 익명화
상품 등록
상품 수정
상품 가격/지원금 변경
상품 노출 상태 변경
상품 삭제/복구
배너 등록/수정/삭제
배너 노출 순서 변경
관리자 계정 생성
관리자 계정 비활성화
관리자 권한 변경
관리자 로그인 실패 반복
관리자 강제 로그아웃
비밀번호 초기화
엑셀 Export 요청
엑셀 파일 다운로드
알림톡 수동 재발송
Webhook 수동 재처리
외부 API Secret 변경
S3 파일 삭제
model AuditLog {
id Int @id @default(autoincrement())
actorType String
actorId Int?
actorName String?
action String
targetType String
targetId String?
beforeValue Json?
afterValue Json?
metadata Json?
requestId String?
ipAddress String?
userAgent String?
createdAt DateTime @default(now())
@@index([actorType, actorId, createdAt])
@@index([targetType, targetId, createdAt])
@@index([action, createdAt])
@@index([requestId])
}
| 컬럼 | 의미 |
|---|---|
actorType | 작업 주체 종류 |
actorId | 작업 주체 ID |
action | 수행한 작업 |
targetType | 대상 도메인 |
targetId | 대상 ID |
beforeValue | 변경 전 값 |
afterValue | 변경 후 값 |
metadata | 추가 정보 |
requestId | 요청 추적 ID |
ipAddress | 요청 IP |
userAgent | 요청 환경 |
ADMIN
CUSTOMER
SYSTEM
WORKER
WEBHOOK
CRON
CONSULT
PRODUCT
PRODUCT_OPTION
BANNER
ADMIN_USER
EXPORT_JOB
NOTIFICATION_JOB
WEBHOOK_EVENT
targetType + targetId 구조가 있으면 어느 대상의 이력인지 쉽게 찾을 수 있습니다.action은 Audit Log의 핵심입니다.CONSULT_STATUS_UPDATE
CONSULT_MEMO_CREATE
CONSULT_MEMO_UPDATE
CONSULT_EXPORT_REQUEST
CONSULT_ANONYMIZE
PRODUCT_CREATE
PRODUCT_UPDATE
PRODUCT_PRICE_UPDATE
PRODUCT_SOFT_DELETE
PRODUCT_RESTORE
BANNER_UPDATE
BANNER_DISPLAY_ORDER_UPDATE
NOTIFICATION_RESEND
EXPORT_FILE_DOWNLOAD
ADMIN_USER_CREATE
ADMIN_USER_DISABLE
ADMIN_ROLE_UPDATE
ADMIN_PERMISSION_GRANT
ADMIN_PERMISSION_REVOKE
UPDATE
CHANGE
SAVE
DELETE
PROCESS
HANDLE
WORK
문제:
무엇을 했는지 불명확
검색/필터가 어려움
운영자가 이해하기 어려움
장애 분석에 도움 부족
도메인_행위 형태
위험 작업은 action을 분리
운영자가 이해 가능한 이름
권한 코드와 어느 정도 대응 가능하게 설계
CONSULT_EXPORT 권한과 CONSULT_EXPORT_REQUEST action처럼 의미가 연결되면 관리하기 좋습니다.{
"beforeValue": {
"status": "CALLING"
},
"afterValue": {
"status": "CALLED"
}
}
{
"beforeValue": {
"supportAmount": 300000
},
"afterValue": {
"supportAmount": 350000
}
}
상담 row 전체 저장
고객 전화번호 원본 저장
상담 메모 전체 저장
관리자 passwordHash 저장
외부 API token 저장
변경된 필드 중심으로 저장
개인정보 원본 제외
Secret/Token 제외
긴 텍스트는 요약 또는 별도 정책
민감한 필드는 마스킹
@Injectable()
export class AuditLogRepository {
constructor(private readonly prisma: PrismaService) {}
create(
params: {
actorType: string;
actorId?: number;
actorName?: string;
action: string;
targetType: string;
targetId?: string;
beforeValue?: Prisma.InputJsonValue;
afterValue?: Prisma.InputJsonValue;
metadata?: Prisma.InputJsonValue;
requestId?: string;
ipAddress?: string;
userAgent?: string;
},
tx: PrismaTx = this.prisma,
) {
return tx.auditLog.create({
data: {
actorType: params.actorType,
actorId: params.actorId,
actorName: params.actorName,
action: params.action,
targetType: params.targetType,
targetId: params.targetId,
beforeValue: params.beforeValue,
afterValue: params.afterValue,
metadata: params.metadata,
requestId: params.requestId,
ipAddress: params.ipAddress,
userAgent: params.userAgent,
},
});
}
}
Repository는 저장만 담당
무엇을 저장할지는 Use Case에서 결정
민감정보 필터링 책임을 명확히 해야 함
const SENSITIVE_KEYS = [
'password',
'passwordHash',
'token',
'accessToken',
'refreshToken',
'authorization',
'cookie',
'phone',
'phoneNormalized',
'recipientEncrypted',
'secret',
'apiKey',
];
export function sanitizeAuditValue(value: unknown) {
if (!value || typeof value !== 'object') {
return value;
}
return Object.fromEntries(
Object.entries(value as Record<string, unknown>).map(([key, val]) => {
if (SENSITIVE_KEYS.some((sensitive) =>
key.toLowerCase().includes(sensitive.toLowerCase()),
)) {
return [key, '[REDACTED]'];
}
return [key, val];
}),
);
}
export class AuditLogFactory {
static productPriceUpdated(params: {
adminId: number;
productId: number;
beforeSupportAmount: number;
afterSupportAmount: number;
requestId?: string;
}) {
return {
actorType: 'ADMIN',
actorId: params.adminId,
action: 'PRODUCT_PRICE_UPDATE',
targetType: 'PRODUCT',
targetId: String(params.productId),
beforeValue: {
supportAmount: params.beforeSupportAmount,
},
afterValue: {
supportAmount: params.afterSupportAmount,
},
requestId: params.requestId,
};
}
}
action별 저장 값 표준화
민감정보 저장 위험 감소
Use Case 코드 간결화
테스트 가능
Transaction 시작
↓
상품 가격 변경
↓
Audit Log 저장
↓
commit
@Injectable()
export class UpdateProductPriceUseCase {
constructor(
private readonly prisma: PrismaService,
private readonly productRepository: ProductRepository,
private readonly auditLogRepository: AuditLogRepository,
) {}
async execute(command: UpdateProductPriceCommand) {
return this.prisma.$transaction(async (tx) => {
const product = await this.productRepository.findByIdOrThrow(
command.productId,
tx,
);
const updated = await this.productRepository.updatePrice(
{
productId: command.productId,
supportAmount: command.supportAmount,
},
tx,
);
await this.auditLogRepository.create(
{
actorType: 'ADMIN',
actorId: command.adminId,
action: 'PRODUCT_PRICE_UPDATE',
targetType: 'PRODUCT',
targetId: String(command.productId),
beforeValue: {
supportAmount: product.supportAmount,
},
afterValue: {
supportAmount: updated.supportAmount,
},
requestId: command.requestId,
ipAddress: command.ipAddress,
userAgent: command.userAgent,
},
tx,
);
return updated;
});
}
}
DB 변경과 Audit Log는 같은 transaction
외부 API 호출 결과 로그는 Worker 작업 결과와 함께 저장
조회성 작업은 위험도에 따라 Audit Log 여부 결정
상담 상세 개인정보 열람
전화번호 원본 보기
엑셀 다운로드
Audit Log 조회
관리자 계정 상세 조회
권한 변경 이력 조회
일반 상품 목록 조회
일반 상담 목록 조회
배너 목록 조회
통계 대시보드 조회
일반 목록 조회:
Audit Log 생략 가능
개인정보 상세 열람:
필요 시 기록
파일 다운로드:
반드시 기록
전화번호 원본 복호화:
반드시 기록
ConsultCreated
ConsultStatusChanged
ProductPriceChanged
NotificationSent
ExportJobCompleted
WebhookReceived
후속 작업 연결
장애 발생 시 흐름 추적
도메인 사건 기록
비동기 처리의 근거
시스템 간 데이터 동기화
관리자가 상담 상태 변경
↓
Audit Log:
관리자 A가 상태 변경
Event Log:
ConsultStatusChanged 이벤트 발생
model EventLog {
id Int @id @default(autoincrement())
eventType String
aggregateType String
aggregateId String
payload Json?
metadata Json?
status EventLogStatus @default(RECORDED)
processedAt DateTime?
errorCode String?
errorMessage String?
requestId String?
occurredAt DateTime @default(now())
createdAt DateTime @default(now())
@@index([eventType, occurredAt])
@@index([aggregateType, aggregateId, occurredAt])
@@index([status, createdAt])
@@index([requestId])
}
enum EventLogStatus {
RECORDED
PROCESSING
PROCESSED
FAILED
IGNORED
}
| 컬럼 | 의미 |
|---|---|
eventType | 발생한 이벤트 종류 |
aggregateType | 대상 도메인 |
aggregateId | 대상 ID |
payload | 이벤트 내용 |
metadata | 추가 정보 |
status | 처리 상태 |
occurredAt | 실제 발생 시각 |
requestId | 요청 추적 ID |
CONSULT_CREATED
CONSULT_STATUS_CHANGED
PRODUCT_PRICE_CHANGED
EXPORT_JOB_REQUESTED
EXPORT_JOB_COMPLETED
NOTIFICATION_SENT
NOTIFICATION_FAILED
WEBHOOK_RECEIVED
Transaction 시작
↓
consults.status 변경
↓
event_outbox row 생성
↓
commit
↓
Outbox Worker가 이벤트 처리
DB 변경은 성공했는데 이벤트 발행 실패
이벤트 발행은 성공했는데 DB 변경 rollback
외부 시스템과 내부 상태 불일치
model EventOutbox {
id Int @id @default(autoincrement())
eventType String
aggregateType String
aggregateId String
payload Json?
status OutboxStatus @default(PENDING)
retryCount Int @default(0)
nextRetryAt DateTime?
errorCode String?
errorMessage String?
createdAt DateTime @default(now())
processedAt DateTime?
@@index([status, nextRetryAt])
@@index([aggregateType, aggregateId, createdAt])
}
enum OutboxStatus {
PENDING
PROCESSING
PROCESSED
FAILED
}
초기:
notification_jobs / export_jobs로 충분
확장:
외부 시스템 동기화가 많아지면 Outbox 검토
주의:
처음부터 과하게 도입하지 않기
requestId 또는 correlation ID를 사용합니다.HTTP 요청
↓
requestId 생성
↓
Use Case
↓
Audit Log
↓
Event Log
↓
Worker Job
↓
Application Log
req_20260830_abc123
@Injectable()
export class RequestIdMiddleware implements NestMiddleware {
use(req: Request, res: Response, next: NextFunction) {
const requestId =
req.headers['x-request-id']?.toString() ?? randomUUID();
req.headers['x-request-id'] = requestId;
res.setHeader('x-request-id', requestId);
next();
}
}
API 로그
Audit Log
Event Log
Job metadata
Webhook 처리 로그
에러 응답
{
"level": "error",
"requestId": "req_123",
"message": "Failed to send alimtalk",
"jobId": 10,
"errorCode": "TIMEOUT"
}
{
"actorType": "ADMIN",
"actorId": 3,
"action": "NOTIFICATION_RESEND",
"targetType": "NOTIFICATION_JOB",
"targetId": "10",
"createdAt": "2026-08-30T09:00:00.000Z"
}
Application Log:
시스템 상태/오류 분석
Audit Log:
누가 어떤 운영 작업을 했는지 기록
둘 다 필요하지만 목적이 다름
전화번호 원본
주민등록번호/생년월일
주소
상담 메모 전체
access token
refresh token
Authorization header
cookie
DATABASE_URL
API key
Webhook secret
S3 pre-signed URL 전체
requestId
jobId
consultId
productId
adminId
providerMessageId
errorCode
durationMs
status
식별자는 내부 ID 중심
개인정보는 마스킹
Secret은 절대 출력 금지
외부 API request/response 전체 로그 금지
알림톡 발송 실패함
{
"level": "error",
"event": "NOTIFICATION_SEND_FAILED",
"requestId": "req_123",
"jobId": 10,
"targetType": "CONSULT",
"targetId": "532",
"provider": "ALIMTALK",
"errorCode": "TIMEOUT",
"retryCount": 1,
"durationMs": 5000
}
CloudWatch에서 검색 쉬움
errorCode별 집계 가능
jobId/requestId로 추적 가능
장애 분석 속도 향상
actorType
actorId
action
targetType
targetId
requestId
dateFrom
dateTo
GET /admin/audit-logs?page=1&limit=50&action=PRODUCT_PRICE_UPDATE
GET /admin/consults/:id/audit-logs
GET /admin/products/:id/audit-logs
findAdminList(
params: {
where: Prisma.AuditLogWhereInput;
skip: number;
take: number;
},
tx: PrismaTx = this.prisma,
) {
return tx.auditLog.findMany({
where: params.where,
skip: params.skip,
take: params.take,
orderBy: [
{ createdAt: 'desc' },
{ id: 'desc' },
],
select: {
id: true,
actorType: true,
actorId: true,
actorName: true,
action: true,
targetType: true,
targetId: true,
requestId: true,
createdAt: true,
},
});
}
AUDIT_LOG_READ
AUDIT_LOG_DETAIL_READ
AUDIT_LOG_EXPORT
일반 상담 직원:
본인 작업 이력 일부만
매니저:
상담/상품 관련 이력 조회
최고 관리자:
관리자 계정/권한 변경 이력까지 조회
beforeValue/afterValue 상세 노출 제한
권한 변경 로그는 더 민감
Audit Log export는 별도 권한 필요
ConsultStatusChanged 이벤트 기록
↓
Event Worker가 처리
↓
NotificationJob 생성
↓
Stats 갱신
↓
외부 CRM 동기화 Job 생성
| 방식 | 장점 | 단점 |
|---|---|---|
| Use Case에서 직접 Job 생성 | 단순하고 흐름 명확 | 후속 작업이 늘면 Use Case가 커짐 |
| Event Log/Outbox 기반 | 확장성 좋음 | 구조가 복잡해짐 |
초기:
Use Case에서 필요한 Job 직접 생성
후속 작업 증가:
EventLog 또는 Outbox로 분리
외부 시스템 동기화 증가:
Outbox Pattern 검토
상담 상세에서 알림 실패 확인
↓
notification_jobs에서 jobId 확인
↓
providerMessageId/errorCode 확인
↓
Worker application log에서 jobId 검색
↓
requestId로 상담 신청 요청 로그 확인
↓
필요 시 Audit Log 확인
고객이 가격 오류 제보
↓
product 현재 값 확인
↓
Audit Log에서 PRODUCT_PRICE_UPDATE 검색
↓
beforeValue/afterValue 확인
↓
actorId 확인
↓
requestId로 당시 API 로그 확인
개인정보 파일 다운로드 확인 필요
↓
ExportJob 조회
↓
requestedByAdminId 확인
↓
Audit Log에서 EXPORT_DOWNLOAD_REQUEST 확인
↓
다운로드 완료 로그 확인
↓
fileKey/expiresAt 확인
Application Log:
30~90일
Access Log:
30~90일
Audit Log:
1년 이상 또는 회사 정책 기준
Event Log:
업무 중요도에 따라 6개월~1년
Export 파일:
1~7일
NotificationJob:
운영 추적 기간 기준
장애 분석에 필요한 기간
법적/회사 정책
개인정보 포함 여부
저장 비용
조회 빈도
개인정보 포함 로그 장기 보관 금지
삭제 전 보존 정책 확인
Export 파일은 짧게 보관
Audit Log는 삭제보다 접근 제한 우선
CONSULT_STATUS_UPDATE
CONSULT_MEMO_UPDATE
CONSULT_EXPORT_REQUEST
EXPORT_FILE_DOWNLOAD
PRODUCT_PRICE_UPDATE
PRODUCT_SOFT_DELETE
PRODUCT_RESTORE
ADMIN_ROLE_UPDATE
NOTIFICATION_RESEND
완료 기준:
위험 작업과 주요 운영 작업의 action 코드가 정의됨
권한 코드와 연결되는 작업이 정리됨
Use Case별 Audit Log 필요 여부가 정리됨
AuditLogRepository
AuditLogFactory
sanitizeAuditValue
requestId 연결
완료 기준:
상태 변경/상품 수정/엑셀 요청에서 같은 방식으로 Audit Log 저장
민감정보 필터링 기준 적용
requestId로 추적 가능
Audit Log 목록
action 필터
actor 필터
target 필터
date 필터
상세 보기
완료 기준:
운영자가 누가 어떤 작업을 했는지 확인 가능
상담/상품 상세에서 관련 이력 확인 가능
권한 있는 관리자만 접근 가능
EventLog 테이블
ConsultStatusChanged 이벤트
NotificationJob 연동
WebhookReceived 이벤트
완료 기준:
후속 작업이 많아지는 지점 파악
Outbox 도입 필요성 판단
지금 당장 과한 구조는 피함
NestJS + Prisma 관리자 API에 Audit Log 구조를 추가해줘.
조건:
1. AuditLog Prisma 모델을 추가해줘
2. actorType, actorId, actorName, action, targetType, targetId, beforeValue, afterValue, metadata, requestId, ipAddress, userAgent, createdAt을 포함해줘
3. actorType+actorId+createdAt, targetType+targetId+createdAt, action+createdAt, requestId 인덱스를 추가해줘
4. AuditLogRepository를 만들어줘
5. beforeValue/afterValue에 민감정보가 들어가지 않도록 sanitizeAuditValue 유틸을 만들어줘
6. 상담 상태 변경 Use Case에서 CONSULT_STATUS_UPDATE 로그를 남겨줘
7. 상품 가격/지원금 수정 Use Case에서 PRODUCT_PRICE_UPDATE 로그를 남겨줘
8. 엑셀 Export 요청 Use Case에서 CONSULT_EXPORT_REQUEST 로그를 남겨줘
9. 엑셀 파일 다운로드 시 EXPORT_FILE_DOWNLOAD 로그를 남길 수 있게 구조를 제안해줘
10. phone, phoneNormalized, token, secret, authorization, cookie, passwordHash는 로그에 남기지 마
11. requestId를 Audit Log에 연결해줘
12. 변경 후 migration 주의사항, 테스트 케이스, QA 체크리스트를 정리해줘
Audit Log가 중요한 변경 작업과 같은 transaction인가?
beforeValue/afterValue에 전체 row를 넣지 않았는가?
전화번호/토큰/Secret이 저장되지 않는가?
action 이름이 명확한가?
requestId가 연결되는가?
Audit Log 조회 권한이 제한되는가?
인덱스가 조회 패턴과 맞는가?
기존 API 응답이 깨지지 않는가?
NestJS + Prisma + PostgreSQL 기반 온라인 휴대폰 판매몰 관리자 시스템에서 Audit Log와 Event Log를 설계하려고 해.
서비스 상황:
1. 관리자는 상담 상태 변경, 상담 메모 수정, 상품 가격/지원금 변경, 상품 삭제/복구, 엑셀 Export 요청, 알림톡 재발송, 관리자 권한 변경을 할 수 있음
2. 상담 상태 변경은 상태 이력과 Audit Log를 모두 남기고 싶음
3. 상품 가격/지원금 변경은 beforeValue/afterValue로 변경된 금액만 남기고 싶음
4. 엑셀 다운로드는 개인정보 파일과 연결되므로 요청과 다운로드를 기록하고 싶음
5. Audit Log에는 전화번호 원본, token, Secret, passwordHash, 상담 메모 전체를 저장하면 안 됨
6. requestId로 API 로그, Audit Log, Worker 로그를 연결하고 싶음
7. NotificationJob, ExportJob, WebhookEvent와도 추적 가능하게 만들고 싶음
8. Event Log 또는 Outbox Pattern은 당장 과하게 도입하지 않고 필요성을 검토하고 싶음
9. 관리자 화면에서 Audit Log 목록과 상세를 조회하고 싶음
요청:
- Audit Log와 Event Log 차이
- Audit Log 대상 작업 목록
- AuditLog Prisma schema
- action 이름 설계 기준
- beforeValue/afterValue 저장 기준
- 민감정보 sanitizer 설계
- AuditLogRepository 예시
- Use Case에서 transaction으로 Audit Log 저장하는 방식
- requestId/correlation ID 설계
- Application Log와 Audit Log 차이
- Event Log/Outbox 도입 기준
- Audit Log 조회 API와 권한 기준
- 보존 정책과 보안 체크리스트
를 실무 기준으로 정리해줘.
Audit Log와 Event Log의 목적을 구분하는가?
중요 변경 작업과 Audit Log를 같은 transaction으로 묶는가?
beforeValue/afterValue에 전체 row를 저장하지 않도록 경고하는가?
전화번호/토큰/Secret/passwordHash 저장 금지를 강조하는가?
requestId로 추적성을 연결하는가?
Application Log와 Audit Log를 섞지 않는가?
Audit Log 조회 권한을 제한하는가?
Outbox Pattern을 무조건 도입하라고 하지 않는가?
현재 프로젝트에서는 Audit Log 우선 적용을 권장하는가?
actorType, actorId, action, targetType, targetId, beforeValue, afterValue, requestId, ipAddress, userAgent, createdAt 같은 필드가 필요합니다.action 이름은 CONSULT_STATUS_UPDATE, PRODUCT_PRICE_UPDATE, EXPORT_FILE_DOWNLOAD처럼 도메인과 행위가 드러나게 설계해야 합니다.beforeValue와 afterValue에는 변경된 필드 중심으로 최소한만 저장하고, 전화번호 원본, 토큰, Secret, passwordHash, 상담 메모 전체 같은 민감정보는 넣지 않아야 합니다.