감사로그 — logAdminAction 패턴
감사로그 — logAdminAction 패턴
관리자 기능을 갖춘 백엔드에서 "누가 · 언제 · 무엇을 · 왜" 의 네 축을 기록하는 감사로그 (audit log) 는 단순한 관행 이상입니다. 개인정보보호법 · GDPR 같은 규정 준수, 사고 조사, 권한 오남용 방지의 실질 수단.
1. 감사로그 가 답하는 네 질문
| 축 | 컬럼 예 | 의미 |
|---|---|---|
| 누가 | user_id · user_email · ip_address |
행위 주체. 내부 사용자 + (있다면) 외부 IP |
| 언제 | created_at TIMESTAMPTZ |
UTC 기준 1초 이상 해상도 |
| 무엇을 | action · resource · resource_id |
DELETE + pryzeet.user + 12345 같은 평면 설명 |
| 왜 | details JSONB.reason |
자유 문자열. 파괴적 · PII 관련은 30~100자 최소 강제 |
표준은 없으나 위 네 축을 모두 가지면 1 차 회귀 · 분쟁 대응은 가능.
2. 최소 스키마 (PostgreSQL)
CREATE TABLE IF NOT EXISTS audit_logs (
id BIGSERIAL PRIMARY KEY,
user_id UUID, -- NULL 허용: 시스템·cron
user_email TEXT,
action VARCHAR(40) NOT NULL,
resource VARCHAR(80) NOT NULL, -- 'pryzeet.user' 같은 dot-namespaced
resource_id TEXT,
details JSONB NOT NULL DEFAULT '{}'::jsonb,
ip_address INET,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_audit_logs_resource_created
ON audit_logs (resource, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_audit_logs_user_created
ON audit_logs (user_id, created_at DESC);
resource 에 도메인 접두사 (pryzeet.user · codingstairs.post · da2ari.announcement) 를 두면 ILIKE 'pryzeet.%' 로 필터한 도메인별 감사 뷰가 편리해집니다.
3. 헬퍼 함수 — fire-and-forget
요청 경로 안에서 감사 INSERT 가 블로킹하면 운영 트래픽이 감사 테이블 쓰기 시간에 묶입니다. 비동기 (void 반환) 로 발사하고 오류는 로그로만 기록.
// audit.ts
export function logAdminAction(input: {
action: string;
resource: string;
resourceId?: string;
details?: Record<string, unknown>;
request?: Request;
}): void {
const run = async () => {
const session = await resolveSession(input.request); // 쿠키에서 user_id·email
const ip = await resolveClientIp(input.request);
await pool.query(
`INSERT INTO audit_logs
(user_id, user_email, action, resource, resource_id, details, ip_address)
VALUES ($1,$2,$3,$4,$5,$6,$7)`,
[session.userId, session.email, input.action, input.resource,
input.resourceId ?? null, input.details ?? {}, ip]
);
};
// intentional: 예외는 logger.error, 메인 요청은 계속
run().catch((e) => logger.error('audit_log_failed', e));
}
request 미전달 시 Next.js App Router 의 cookies() fallback 으로 user_id 을 채우면 Server Action 에서도 NOT NULL (또는 nullable) 제약을 만족할 수 있습니다.
4. reason 을 강제하는 이유
개인정보 삭제 · 권한 변경 · 포인트 수동 가감 같이 사후 검증이 중요한 작업은 사유 필드를 30~100자 최소 강제합니다. 검증은 요청을 거절하는 쪽 (API 레이어) 에서, 저장은 details.reason JSONB 키.
if (destructive && (!reason || reason.length < 30)) {
return NextResponse.json({ error: 'reason 30자+ 필요' }, { status: 400 });
}
logAdminAction({
action: 'DELETE',
resource: 'pryzeet.user',
resourceId: String(userId),
details: { reason, targetEmail },
});
빈 문자열 또는 "삭제" 같은 2자 사유는 사후 분석이 불가능하므로 API 레벨에서 reject 하는 편이 낫습니다.
5. 감사 로그 뷰어 UI
- 필터:
resource·action·user_email·date_range - 정렬:
created_at DESC - 페이지네이션: offset · keyset 둘 다 OK (대량 trace 는 keyset 이 유리)
- 도메인별 뷰:
resource ILIKE 'pryzeet.%'쿼리를 고정하고/admin/pryzeet/audit-log같은 서브 뷰로 분기
6. 자주 걸리는 자리
시스템 액션의 user_id NULL — cron · 백업 · webhook 은 사람 주체가 없습니다. user_id NULL 허용 + user_email = 'system@internal' 같은 상수로 구분.
감사 테이블 과성장 — 1 년치 쌓이면 수백만 행. 파티셔닝 (created_at 월별) 또는 별도 아카이브 테이블로 분리 검토.
JSONB details 의 스키마 없음 — 자유도는 장점이지만 필수 키 (reason · previous_value · new_value) 는 코드 측 헬퍼로 강제하는 편이 검색에 유리.
감사 로그 자체의 삭제 — "감사 기록 삭제 기능" 은 두지 않습니다. 개인정보 삭제 요청 등 법적 의무가 생기면 user_email = NULL 로 익명화하는 쪽.
actor 주입 fallback — request 객체가 항상 주어지는 건 아닙니다 (Server Action · scheduled job). 쿠키 기반 fallback 이 없으면 user_id NOT NULL 제약 위반 사고가 생기므로 fallback 테스트를 회귀로 고정.
7. 운영 체크리스트
- 모든 mutation API · Server Action 에
logAdminAction호출 - 파괴적 · PII 관련 작업은 reason 30자+ 강제
-
resource는 도메인 접두사 규칙 준수 - INSERT 실패해도 메인 요청이 500 나지 않음 (fire-and-forget)
- 감사 로그 뷰어에 권한 (관리자 전용) 적용
- 정기 아카이브 · 파티셔닝 계획
하고픈 말
감사로그는 사고 났을 때만 쓰는 기록이 아니라, 평소에 "이 사용자가 어제 이 포인트 왜 받았지?" 같은 질문의 1 차 답입니다. 사유 필드가 없으면 감사가 "언제 무엇을" 까지만 답하고 "왜" 는 영영 사라지니, 설계 초기부터 reason 을 필수 컬럼 취급하는 쪽이 유리합니다.
Next
- api-handler-pattern
- security/01-jwt-rotation
OWASP Audit Log Cheat Sheet · PostgreSQL JSONB · 개인정보보호위원회 — 개인정보 안전성 확보조치 기준 를 참고합니다.