본문으로 바로가기

6단계

감사로그 — logAdminAction

4회 조회

목차

"어제 저 유저가 왜 차단됐지?" 의 첫 답이 감사로그. 관리자 허브에서는 모든 mutationlogAdminAction 을 거치는 것을 원칙으로.

1. 테이블 스키마

CREATE TABLE IF NOT EXISTS audit_logs (
  id          BIGSERIAL PRIMARY KEY,
  user_id     UUID,                                    -- NULL 허용: 시스템/cron
  user_email  TEXT,
  action      VARCHAR(40) NOT NULL,                    -- CREATE/UPDATE/DELETE/...
  resource    VARCHAR(80) NOT NULL,                    -- 'blog.post' 등 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 의 dot-namespacing (blog.post · market.user) 은 ILIKE 'blog.%' 로 도메인별 뷰 만들기에 편리.

2. 위험도에 따른 두 가지 헬퍼

// src/shared/lib/audit.ts
import { cookies, headers } from 'next/headers';
import { centralPool } from './db';           // audit 전용 풀 (예: operations)
import { verifySession } from './auth/session';
import { logger } from './logger';

interface LogInput {
  action: string;
  resource: string;
  resourceId?: string;
  details?: Record<string, unknown>;
  request?: Request;
}

export function logAdminAction(input: LogInput): void {
  const run = async () => {
    const session = input.request
      ? await verifySessionFromRequest(input.request)
      : await verifySession();                          // Server Action fallback
    const ip = resolveIp(input.request);

    await centralPool.query(
      `INSERT INTO audit_logs
         (user_id, user_email, action, resource, resource_id, details, ip_address)
       VALUES ($1,$2,$3,$4,$5,$6,$7)`,
      [
        null,                                            // DB 에 user_id 컬럼 쓰면 session 에서 추출
        session?.email ?? 'system@internal',
        input.action,
        input.resource,
        input.resourceId ?? null,
        input.details ?? {},
        ip,
      ]
    );
  };
  run().catch((e) => logger.error('audit_log_failed', e));
}

function resolveIp(req?: Request): string | null {
  const xf = req?.headers.get('x-forwarded-for');
  if (xf) return xf.split(',')[0]?.trim() ?? null;
  return null;
}

이 비동기 경로는 조회·일반 변경처럼 복구 가능한 작업에 적합합니다. 삭제·권한 변경·초기화에는 같은 정책을 쓰지 않습니다. 고위험 작업은 logAdminActionDurable로 intent 저장을 기다리고 실패하면 mutation을 시작하지 않습니다.

const audited = await logAdminActionDurable({
  action: 'DELETE_INTENT', resource: 'blog.post', resourceId: id,
  details: { reason }, request: req,
});
if (!audited) return NextResponse.json(
  { ok: false, mutationApplied: false, error: 'audit_unavailable' },
  { status: 503 },
);

작업 후 결과 감사가 실패했다면 이미 반영됐을 가능성을 숨기지 않습니다. mutationApplied: true와 “재실행 금지·운영 확인”을 반환해야 같은 삭제나 초기화를 반복하지 않습니다.

3. 호출 위치 — API 라우트

// src/app/api/blog/posts/[id]/route.ts
export async function DELETE(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
  const { id } = await params;
  const body = await req.json();
  const { reason } = body;

  if (!reason || reason.trim().length < 30) {
    return NextResponse.json({ error: 'reason 30자+ 필요' }, { status: 400 });
  }

  const audited = await logAdminActionDurable({
    action: 'DELETE_INTENT',
    resource: 'blog.post',
    resourceId: id,
    details: { reason },
    request: req,
  });
  if (!audited) return NextResponse.json({ error: 'audit unavailable' }, { status: 503 });

  await queryBlog(`DELETE FROM posts WHERE id = $1`, [id]);
  logAdminAction({ action: 'DELETE', resource: 'blog.post', resourceId: id, details: { reason }, request: req });

  return NextResponse.json({ ok: true });
}

reason 30 자 강제는 API 레이어에서 reject 가 원칙. DB 트리거 · 프론트 검증만으로는 우회 가능.

4. 호출 위치 — Server Action

// src/app/admin/blog/posts/[id]/actions.ts
'use server';

export async function deletePostAction(id: string, reason: string) {
  if (reason.trim().length < 30) throw new Error('reason 30자+');
  const audited = await logAdminActionDurable({
    action: 'DELETE_INTENT', resource: 'blog.post', resourceId: id, details: { reason },
  });
  if (!audited) throw new Error('감사 시스템을 확인하지 못해 삭제하지 않았습니다.');
  await queryBlog(`DELETE FROM posts WHERE id = $1`, [id]);
  logAdminAction({
    action: 'DELETE',
    resource: 'blog.post',
    resourceId: id,
    details: { reason },
  });                                         // request 미전달 → cookies() fallback
  revalidatePath('/admin/blog/posts');
}

5. 뷰어 페이지

// src/app/admin/system/audit/page.tsx
import { queryCentral } from '@/shared/lib/central-db';

export default async function Page({ searchParams }) {
  const sp = await searchParams;
  const resource = sp.resource ?? '';
  const action = sp.action ?? '';
  const q = sp.q ?? '';
  const page = Number(sp.page ?? 1);

  const logs = await queryCentral<AuditLog>(
    `SELECT id, created_at, user_email, action, resource, resource_id, details
       FROM audit_logs
      WHERE ($1 = '' OR resource LIKE $1 || '%')
        AND ($2 = '' OR action = $2)
        AND ($3 = '' OR user_email ILIKE '%' || $3 || '%')
      ORDER BY created_at DESC
      LIMIT 50 OFFSET $4`,
    [resource, action, q, (page - 1) * 50]
  );

  return <AuditLogView initialRows={logs} />;
}

도메인별 뷰는 resource 쿼리 고정 (WHERE resource LIKE 'blog.%') + URL 경로 분기.

6. 테스트 회귀

// src/shared/lib/audit.test.ts
import { vi, describe, it, expect } from 'vitest';

const { mockQuery } = vi.hoisted(() => ({ mockQuery: vi.fn() }));
vi.mock('./db', () => ({ centralPool: { query: mockQuery } }));

describe('logAdminAction', () => {
  it('Server Action 경로에서도 user_email 채움 (cookies fallback)', async () => {
    mockQuery.mockResolvedValueOnce({ rows: [] });
    logAdminAction({ action: 'DELETE', resource: 'blog.post', resourceId: '1' });
    await new Promise((r) => setImmediate(r));            // fire-and-forget flush
    expect(mockQuery).toHaveBeenCalled();
  });
});

7. 자주 걸리는 자리

  • user_id NOT NULL 제약 → 시스템 액션에서 실패. NULL 허용 또는 sentinel 이메일로.
  • details 에 raw 비밀번호 · 토큰 저장 → 감사 테이블이 잠재 유출원.
  • 모든 감사 실패를 무시 → 고위험 작업이 추적 증거 없이 성공.
  • 결과 감사 실패를 일반 500으로만 반환 → 적용 여부를 모른 채 같은 작업을 재실행.
  • 1 년치 수백만 행 → 월별 파티셔닝 계획.

하고픈 말

감사로그는 사고 때만 쓰는 장치가 아니라 평소에 "왜 그랬지" 의 기본 답. reason 을 초기부터 필수 컬럼으로 취급하면 1 년 뒤 가치가 확실히 커집니다.

Next

  • 07-backup-automation

이 글에서 만나는 용어