stack-analysis
CHAPTER 68 / 90
읽기 약 2분
FUNCTION
감사 로그: 누가 뭘 했는지 추적
핵심 개념
audit log 패턴·diff 저장·검색·법적 요구사항 — 컴플라이언스 + 디버깅.
본문
데이터 모델
CREATE TABLE audit_logs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
actor_id UUID, -- NULL = 시스템
actor_type VARCHAR DEFAULT 'user', -- user, api_key, system
action VARCHAR NOT NULL, -- 'project.created', 'user.invited'
resource_type VARCHAR NOT NULL, -- 'project', 'user'
resource_id UUID,
changes JSONB, -- { before, after }
ip VARCHAR,
user_agent VARCHAR,
metadata JSONB,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX ON audit_logs(tenant_id, created_at DESC);
CREATE INDEX ON audit_logs(resource_type, resource_id);
CREATE INDEX ON audit_logs(actor_id, created_at DESC);
CREATE INDEX ON audit_logs USING GIN (changes);헬퍼 함수
// utils/audit.ts
type AuditInput = {
tenantId: string;
actorId?: string;
action: string;
resourceType: string;
resourceId?: string;
before?: any;
after?: any;
metadata?: any;
};
export async function audit(req: Request, input: AuditInput) {
return db.auditLog.create({
data: {
...input,
changes: input.before || input.after
? { before: input.before, after: input.after, diff: diff(input.before, input.after) }
: null,
ip: req.ip,
userAgent: req.headers['user-agent'],
},
});
}
// diff 함수 (변경된 필드만)
function diff(before: any, after: any): any {
if (!before) return { type: 'create', data: after };
if (!after) return { type: 'delete', data: before };
const changes: any = {};
const keys = new Set([...Object.keys(before), ...Object.keys(after)]);
for (const key of keys) {
if (JSON.stringify(before[key]) !== JSON.stringify(after[key])) {
changes[key] = { before: before[key], after: after[key] };
}
}
return { type: 'update', changes };
}Prisma 미들웨어로 자동 기록
const AUDITED_MODELS = new Set(['Project', 'User', 'TenantMember', 'ApiKey']);
prisma.$use(async (params, next) => {
if (!AUDITED_MODELS.has(params.model!)) return next(params);
let before: any = null;
if (['update', 'delete'].includes(params.action)) {
before = await (prisma as any)[params.model!.toLowerCase()].findUnique({
where: params.args.where,
});
}
const result = await next(params);
// 비동기 — 본 작업 차단 안 함
setImmediate(async () => {
const ctx = AsyncLocalStorage.getStore();
if (!ctx) return;
await db.auditLog.create({
data: {
tenantId: ctx.tenantId,
actorId: ctx.userId,
action: `${params.model!.toLowerCase()}.${params.action}`,
resourceType: params.model!.toLowerCase(),
resourceId: result?.id ?? params.args.where?.id,
changes: { before, after: result },
ip: ctx.ip,
},
});
});
return result;
});명시적 audit (로직 의도 포함)
// 미들웨어로 자동 기록 + 의미 있는 액션은 명시적
async function inviteUser(tenantId: string, email: string, role: string, inviterId: string, req: Request) {
const invitation = await db.invitation.create({
data: { tenantId, email, role, invitedBy: inviterId, /* ... */ },
});
await audit(req, {
tenantId,
actorId: inviterId,
action: 'tenant.member_invited',
resourceType: 'invitation',
resourceId: invitation.id,
metadata: { email, role },
});
return invitation;
}조회 + 필터
app.get('/audit-logs', requirePermission('audit:read'), async (req, res) => {
const {
actor, resource, action,
from, to,
page = 1, limit = 50,
} = req.query;
const where: any = { tenantId: req.tenant!.id };
if (actor) where.actorId = actor;
if (resource) where.resourceType = resource;
if (action) where.action = { contains: action };
if (from || to) {
where.createdAt = {};
if (from) where.createdAt.gte = new Date(from as string);
if (to) where.createdAt.lte = new Date(to as string);
}
const [items, total] = await Promise.all([
db.auditLog.findMany({
where,
orderBy: { createdAt: 'desc' },
skip: (Number(page) - 1) * Number(limit),
take: Number(limit),
include: { actor: { select: { id: true, name: true, email: true } } },
}),
db.auditLog.count({ where }),
]);
res.json({ items, total, page: Number(page) });
});UI — 타임라인
function AuditTimeline({ logs }: { logs: AuditLog[] }) {
return (
<ul className="space-y-4">
{logs.map(log => (
<li key={log.id} className="flex gap-3">
<Avatar src={log.actor?.avatarUrl} />
<div className="flex-1">
<p>
<strong>{log.actor?.name ?? '시스템'}</strong>
{' '}
{actionLabel(log.action, log.changes)}
</p>
<time className="text-sm text-gray-500">
{formatDistanceToNow(log.createdAt)} 전 · {log.ip}
</time>
{log.changes && (
<details className="mt-1">
<summary className="text-xs text-gray-500 cursor-pointer">변경사항</summary>
<pre className="text-xs">{JSON.stringify(log.changes, null, 2)}</pre>
</details>
)}
</div>
</li>
))}
</ul>
);
}보존 정책
법적 요구사항:
- 한국 개인정보보호법: 3년 (퇴사 후)
- 금융 (전자금융감독): 5년
- 의료 (의료법): 5년
- GDPR: 사용자 요청 시 삭제 (right to erasure)
[자동 정리]
SELECT cron.schedule('cleanup-audit', '0 4 * * 0',
$$DELETE FROM audit_logs WHERE created_at < NOW() - INTERVAL '3 years'$$
);
[중요 액션은 영구 보존]
- 결제 (모든 거래 기록)
- 권한 변경
- 데이터 삭제다음 챕터
CH.69 "온보딩 UX: 첫 경험 설계".
AI 프롬프트
🤖 AI에게 잘 물어보는 법 — 모델·전략별 프롬프트
Claude
무료: Sonnet 4.6 / Pro $20/mo: Opus 4.6
내 프로젝트의 감사 로그 부분을 분석해서 실전 분석 + 개선 우선순위를 알려줘.
ChatGPT
무료: GPT-5.5 / Plus $20/mo: GPT-5.5 Pro
감사 로그 관련 실제 서비스 5개를 비교 분석해서 패턴 추출를 알려줘.
Gemini
무료: 2.5 Flash / Pro $19.99/mo: 3.1 Pro
내 코드베이스에서 감사 로그 최적화 가능 위치를 보고해줘.
Grok
무료: Grok 4.1 / SuperGrok $30/mo
2026년 한국 풀스택 시장의 감사 로그 트렌드를 솔직히 알려줘.
⭐ 이것만 기억하세요
감사 로그: 누가 뭘 했는지 추적은 이 3가지만 확실히 잡으세요
1.audit log = 누가·언제·무엇을·어떻게 — 컴플라이언스 + 디버깅 표준
2.Prisma 미들웨어로 자동 기록 + 명시적 audit으로 의미 보강
3.비동기 기록 (setImmediate) — 본 작업 차단 안 함
공유하기
진행도 68 / 90