6단계
감사로그 — logAdminAction
30 분
감사로그 — logAdminAction
"어제 저 유저가 왜 차단됐지?" 의 첫 답이 감사로그. 관리자 허브에서는 모든 mutation 이 logAdminAction 을 거치는 것을 원칙으로.
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. fire-and-forget 헬퍼
// src/shared/lib/audit.ts
import { cookies, headers } from 'next/headers';
import { centralPool } from './db'; // audit 전용 풀 (예: dmddksl)
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;
}
에러는 logger.error 만. 메인 요청은 계속.
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 });
}
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자+');
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 → fire-and-forget 원칙 위반.
- 1 년치 수백만 행 → 월별 파티셔닝 계획.
하고픈 말
감사로그는 사고 때만 쓰는 장치가 아니라 평소에 "왜 그랬지" 의 기본 답. reason 을 초기부터 필수 컬럼으로 취급하면 1 년 뒤 가치가 확실히 커집니다.
Next
- 07-backup-automation