OPEN HYPER STEP
← 목록으로 (stack-analysis)
STACK-ANALYSIS · 68 / 90
stack-analysis
CHAPTER 68 / 90
읽기 약 2
FUNCTION

감사 로그: 누가 뭘 했는지 추적


핵심 개념

audit log 패턴·diff 저장·검색·법적 요구사항 — 컴플라이언스 + 디버깅.

본문

데이터 모델

SQL📋 코드 (19줄)
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);

헬퍼 함수

TYPESCRIPT📋 코드 (40줄)
// 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 미들웨어로 자동 기록

TYPESCRIPT📋 코드 (34줄)
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 (로직 의도 포함)

TYPESCRIPT📋 코드 (17줄)
// 미들웨어로 자동 기록 + 의미 있는 액션은 명시적
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;
}

조회 + 필터

TYPESCRIPT📋 코드 (30줄)
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 — 타임라인

TSX📋 코드 (27줄)
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>
  );
}

보존 정책

📋 코드 (17줄)
법적 요구사항:
- 한국 개인정보보호법: 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