codingstairs
노트에듀라이프연락
⌕검색⌘K
koen

Navigation

  • Intro
  • Blog
  • Life

연락하기

로그인 없이도 보낼 수 있어요. 답변이 필요하면 이메일을 함께 적어 주세요.

  • 익명 폼으로 의견 남기기 →
  • ✉ warragon112@gmail.com
  • 카카오톡 오픈채팅 ↗

© 2026 codingstairs

  • 노트
  • 에듀
  • 검색
  • 라이프
  • 연락
  • 약관
  • RSS
  • GitHub
노트›backend

감사로그 — logAdminAction 패턴

2026-05-06 게시· 2026-05-18 갱신·0회 조회

감사로그 — 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 · 개인정보보호위원회 — 개인정보 안전성 확보조치 기준 를 참고합니다.

backend 카테고리의 다른 글

카테고리 전체 보기 →
  • 공공 OpenAPI 는 자체 BFF 로 한 번 감싼다
  • 이메일 발송과 OTP — SMTP
  • WebSocket · SSE — 실시간 통신
  • REST API 입문
  • OpenAPI 사양
  • 크롤러 윤리와 도구