민감 데이터 접근 추적: Audit Logging 구현하기
개인정보보호법과 GDPR 준수를 위해 민감 데이터 접근 이력을 추적해야 했습니다. Context 기반 감사 로깅과 Fire-and-forget 패턴으로 구현한 경험을 공유합니다.
1. 문제 상황
개인정보보호법과 GDPR 준수를 위해 민감 데이터 접근/변경 이력을 추적해야 했습니다. 단순히 로그를 남기는 것이 아니라, 다음을 만족해야 했습니다.
요구사항
- 5W1H 추적: 누가(who), 언제(when), 무엇을(what), 어디서(where), 왜(why), 어떻게(how)
- 변경 diff: 수정 전/후 값 비교
- 비즈니스 로직 무영향: 로깅 실패가 메인 로직을 막으면 안 됨
- 성능 고려: 동기 로깅으로 인한 지연 최소화
2. 아키텍처 설계
2.1 감사 로그 스키마
// prisma/schema.prisma
enum AuditAction {
// User 관련
USER_LOGIN
USER_LOGOUT
USER_PASSWORD_CHANGE
USER_2FA_ENABLE
USER_2FA_DISABLE
// Item 관련
ITEM_CREATE
ITEM_UPDATE
ITEM_DELETE
ITEM_VIEW
// 설정 관련
SETTINGS_UPDATE
// 권한 관련
ROLE_CHANGE
PERMISSION_GRANT
PERMISSION_REVOKE
}
model AuditLog {
id String @id @default(cuid())
// Who - 행위자
userId String
userName String? // 암호화됨
userRole String
// What - 행위
action AuditAction
resourceType String // "Item", "User", "Settings"
resourceId String? // 대상 리소스 ID
// Details - 상세 정보
details Json? // { before: {...}, after: {...}, changed: [...] }
// Where - 위치/컨텍스트
companyId String?
ipAddress String? // 암호화됨
userAgent String?
requestId String? // API 요청 추적용
// When - 시간 (자동)
createdAt DateTime @default(now())
@@index([userId])
@@index([action])
@@index([resourceType, resourceId])
@@index([companyId])
@@index([createdAt])
}
2.2 전체 흐름
┌─────────────────────────────────────────────────────────────┐
│ Server Action / API Route │
│ │
│ 1. createAuditContext(session) │
│ → userId, userName, userRole, ipAddress, ... │
│ │
│ 2. 비즈니스 로직 실행 │
│ │
│ 3. audit(context, { action, resourceType, details }) │
│ → Fire-and-forget (비동기, 실패해도 OK) │
│ │
└─────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ AuditLog 테이블 │
│ userId, action, resourceType, details (diff), ipAddress │
└─────────────────────────────────────────────────────────────┘
3. 구현
3.1 타입 정의
// src/lib/core/audit.ts
import type { AuditAction, Prisma } from '@prisma/client';
import type { Session } from 'next-auth';
/**
* 감사 로그 컨텍스트 (요청 정보)
*/
export interface AuditContext {
userId: string;
userName: string | null;
userRole: string;
companyId: string | null;
ipAddress: string | null;
userAgent: string | null;
requestId: string | null;
}
/**
* 변경 diff 타입
*/
export interface AuditDiff {
before: Record<string, unknown>;
after: Record<string, unknown>;
changed: string[]; // 변경된 필드 목록
}
/**
* 감사 로그 항목
*/
export interface AuditEntry {
action: AuditAction;
resourceType: string;
resourceId?: string;
details?: AuditDiff | Record<string, unknown>;
}
3.2 컨텍스트 생성
Server Action과 API Route에서 각각 다른 방식으로 요청 정보를 추출합니다.
// src/lib/core/audit.ts (계속)
import { headers } from 'next/headers';
import { prisma } from '@/lib/core/db';
import { logger } from '@/lib/core/logger';
/**
* Server Action에서 컨텍스트 생성
*/
export async function createAuditContext(session: Session): Promise<AuditContext> {
const headersList = await headers(); // Next.js 15+ 비동기
return {
userId: session.user.id,
userName: session.user.name || null,
userRole: session.user.role,
companyId: session.user.companyId || null,
ipAddress: extractIpAddress(headersList),
userAgent: headersList.get('user-agent') || null,
requestId: headersList.get('x-request-id') || null,
};
}
/**
* API Route에서 컨텍스트 생성
*/
export function createAuditContextFromRequest(
session: Session,
request: Request
): AuditContext {
return {
userId: session.user.id,
userName: session.user.name || null,
userRole: session.user.role,
companyId: session.user.companyId || null,
ipAddress: extractIpAddressFromRequest(request),
userAgent: request.headers.get('user-agent') || null,
requestId: request.headers.get('x-request-id') || null,
};
}
/**
* IP 주소 추출 (프록시 고려)
*/
function extractIpAddress(headers: Headers): string | null {
return (
headers.get('x-forwarded-for')?.split(',')[0]?.trim() ||
headers.get('x-real-ip') ||
null
);
}
function extractIpAddressFromRequest(request: Request): string | null {
return (
request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ||
request.headers.get('x-real-ip') ||
null
);
}
3.3 감사 로그 기록 (Fire-and-forget)
// src/lib/core/audit.ts (계속)
/**
* 감사 로그 기록
*
* Fire-and-forget 패턴:
* - 비동기로 실행, 완료 대기하지 않음
* - 실패해도 비즈니스 로직에 영향 없음
*
* @example
* // 1. Fire-and-forget (권장)
* audit(context, { action: 'ITEM_CREATE', ... }).catch(() => {});
*
* // 2. await로 완료 대기 (필요 시)
* await audit(context, { action: 'ITEM_CREATE', ... });
*/
export async function audit(
context: AuditContext,
entry: AuditEntry
): Promise<void> {
try {
await prisma.auditLog.create({
data: {
userId: context.userId,
userName: context.userName,
userRole: context.userRole,
action: entry.action,
resourceType: entry.resourceType,
resourceId: entry.resourceId || null,
details: entry.details as Prisma.InputJsonValue,
companyId: context.companyId,
ipAddress: context.ipAddress,
userAgent: context.userAgent,
requestId: context.requestId,
},
});
} catch (err) {
// 감사 로깅 실패는 비즈니스 로직에 영향 X
// 대신 구조화된 로그로 기록
logger.error('Audit log failed', {
error: {
code: 'AUDIT_LOG_FAILED',
status: 500,
message: err instanceof Error ? err.message : String(err),
},
auditUserId: context.userId,
auditAction: entry.action,
auditResourceType: entry.resourceType,
});
}
}
3.4 변경 diff 생성
// src/lib/core/audit.ts (계속)
/**
* 두 객체의 변경 diff 생성
*
* @example
* const diff = createDiff(
* { name: '홍길동', phone: '010-1234-5678' },
* { name: '김철수', phone: '010-1234-5678' }
* );
* // {
* // before: { name: '홍길동' },
* // after: { name: '김철수' },
* // changed: ['name']
* // }
*/
export function createDiff(
before: Record<string, unknown>,
after: Record<string, unknown>
): AuditDiff {
const changed: string[] = [];
const beforeDiff: Record<string, unknown> = {};
const afterDiff: Record<string, unknown> = {};
// 모든 키를 합쳐서 비교
const allKeys = new Set([...Object.keys(before), ...Object.keys(after)]);
for (const key of allKeys) {
const beforeVal = before[key];
const afterVal = after[key];
// 값이 다른 경우만 기록 (JSON 비교로 깊은 비교)
if (JSON.stringify(beforeVal) !== JSON.stringify(afterVal)) {
changed.push(key);
beforeDiff[key] = beforeVal;
afterDiff[key] = afterVal;
}
}
return {
before: beforeDiff,
after: afterDiff,
changed,
};
}
3.5 민감 필드 마스킹
// src/lib/core/audit.ts (계속)
/**
* 민감 필드 마스킹
*
* 비밀번호, 급여 등 로그에 남기면 안 되는 정보 마스킹
*/
export function maskSensitiveFields(
data: Record<string, unknown>,
sensitiveKeys: string[] = ['password', 'ssn', 'salary', 'wage', 'secret']
): Record<string, unknown> {
const masked: Record<string, unknown> = {};
for (const [key, value] of Object.entries(data)) {
if (sensitiveKeys.some((sk) => key.toLowerCase().includes(sk))) {
masked[key] = '[MASKED]';
} else if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
masked[key] = maskSensitiveFields(
value as Record<string, unknown>,
sensitiveKeys
);
} else {
masked[key] = value;
}
}
return masked;
}
// 사용 예:
// const safeBefore = maskSensitiveFields(beforeData);
// const safeAfter = maskSensitiveFields(afterData);
// const diff = createDiff(safeBefore, safeAfter);
4. 실제 사용 예시
4.1 Server Action에서 사용
// src/actions/item.ts
'use server';
import { auth } from '@/lib/core/auth';
import { prisma } from '@/lib/core/db';
import {
createAuditContext,
audit,
createDiff,
maskSensitiveFields,
} from '@/lib/core/audit';
export async function updateItem(
id: string,
data: { name: string; phone: string }
) {
const session = await auth();
if (!session?.user) throw new Error('인증 필요');
// 1. 컨텍스트 생성
const auditContext = await createAuditContext(session);
// 2. 기존 데이터 조회 (diff용)
const before = await prisma.item.findUnique({ where: { id } });
if (!before) throw new Error('아이템 없음');
// 3. 업데이트 실행
const after = await prisma.item.update({
where: { id },
data,
});
// 4. 감사 로그 기록 (Fire-and-forget)
audit(auditContext, {
action: 'ITEM_UPDATE',
resourceType: 'Item',
resourceId: id,
details: createDiff(
maskSensitiveFields(before),
maskSensitiveFields(after)
),
}).catch(() => {}); // ← 실패해도 무시
return after;
}
4.2 API Route에서 사용
// src/app/api/items/[id]/route.ts
import { NextResponse } from 'next/server';
import { withErrorHandler, requireAuth } from '@/lib/core/api-handler';
import { auth } from '@/lib/core/auth';
import { prisma } from '@/lib/core/db';
import { createAuditContextFromRequest, audit } from '@/lib/core/audit';
export const DELETE = withErrorHandler(async (request, { params }) => {
const { id } = await params;
const session = await auth();
requireAuth(session);
// 컨텍스트 생성 (Request 객체 사용)
const auditContext = createAuditContextFromRequest(session, request);
// 삭제 전 데이터 조회
const item = await prisma.item.findUnique({ where: { id } });
if (!item) throw new NotFoundError('아이템');
// 삭제 실행
await prisma.item.delete({ where: { id } });
// 감사 로그
audit(auditContext, {
action: 'ITEM_DELETE',
resourceType: 'Item',
resourceId: id,
details: { deletedItem: item },
}).catch(() => {});
return NextResponse.json({ success: true });
});
4.3 로그인 감사
// NextAuth 콜백에서
export const authOptions = {
callbacks: {
async signIn({ user, account }) {
// 로그인 성공 감사
audit(
{
userId: user.id,
userName: user.name,
userRole: user.role,
companyId: user.companyId,
ipAddress: null, // 콜백에서는 request 접근 어려움
userAgent: null,
requestId: null,
},
{
action: 'USER_LOGIN',
resourceType: 'User',
resourceId: user.id,
details: {
provider: account?.provider || 'credentials',
},
}
).catch(() => {});
return true;
},
},
};
5. 감사 로그 조회
5.1 관리자 API
// src/app/api/admin/audit-logs/route.ts
export const GET = withErrorHandler(async (request) => {
const session = await auth();
requireAuth(session);
requireRole(session, ['SUPER_ADMIN']);
const { searchParams } = new URL(request.url);
const userId = searchParams.get('userId');
const action = searchParams.get('action');
const from = searchParams.get('from');
const to = searchParams.get('to');
const page = parseInt(searchParams.get('page') || '1');
const limit = 50;
const where: Prisma.AuditLogWhereInput = {
companyId: session.user.companyId,
...(userId && { userId }),
...(action && { action: action as AuditAction }),
...(from && { createdAt: { gte: new Date(from) } }),
...(to && { createdAt: { lte: new Date(to) } }),
};
const [logs, total] = await Promise.all([
prisma.auditLog.findMany({
where,
orderBy: { createdAt: 'desc' },
skip: (page - 1) * limit,
take: limit,
}),
prisma.auditLog.count({ where }),
]);
return NextResponse.json({
logs,
pagination: {
page,
limit,
total,
totalPages: Math.ceil(total / limit),
},
});
});
6. 핵심 개념 정리
| 개념 | 설명 | 사용 사례 |
|---|---|---|
| AuditContext | 요청자 정보 (userId, IP 등) | 모든 감사 로그의 공통 정보 |
| AuditEntry | 행위 정보 (action, resourceType) | 개별 로그 항목 |
| Fire-and-forget | 비동기 실행, 실패 무시 | 비즈니스 로직 무영향 보장 |
| createDiff | before/after 변경 추적 | 수정 내역 상세 기록 |
| maskSensitiveFields | 민감 정보 마스킹 | 비밀번호, 급여 등 보호 |
7. 베스트 프랙티스
체크리스트
- [ ] 모든 CUD(Create, Update, Delete) 작업에 감사 로그 추가
- [ ] 민감 데이터 조회에도 로그 (VIEW 액션)
- [ ] 로그인/로그아웃 추적
- [ ] 비밀번호 변경, 2FA 설정 변경 추적
- [ ] 역할/권한 변경 추적
- [ ] 민감 필드는 마스킹 후 로깅
- [ ] Fire-and-forget 패턴으로 성능 영향 최소화
로그 보존 정책
// 오래된 로그 정리 (예: 90일 이상)
async function cleanupOldAuditLogs() {
const cutoffDate = new Date();
cutoffDate.setDate(cutoffDate.getDate() - 90);
const result = await prisma.auditLog.deleteMany({
where: {
createdAt: { lt: cutoffDate },
},
});
console.log(`Deleted ${result.count} old audit logs`);
}
8. FAQ
Q: Fire-and-forget이면 로그가 누락될 수 있지 않나요?
A: 네, 드물게 누락될 수 있습니다. 하지만 로그 기록 실패로 비즈니스 로직이 실패하는 것보다 낫습니다. 중요한 로그 누락은 logger.error로 별도 추적됩니다.
Q: 대용량 데이터도 details에 저장해야 하나요?
A: 아니요. details는 변경된 필드만 저장합니다. 대용량 첨부파일 등은 참조 ID만 저장하세요.
Q: IP 주소를 저장해도 되나요?
A: GDPR 관점에서 IP 주소는 개인정보입니다. 암호화하여 저장하고, 보존 기간을 설정하세요.
Q: 로그 테이블이 커지면 성능 문제가 있나요?
A: 인덱스를 적절히 설정하고, 주기적으로 오래된 로그를 아카이브/삭제하세요. 파티셔닝도 고려할 수 있습니다.
Q: 감사 로그도 암호화해야 하나요?
A: userName, ipAddress 등 민감 필드는 암호화합니다. Prisma Extension으로 자동 암호화가 적용됩니다.
9. 참고 자료
10. 다음 단계
감사 로깅을 구현했다면, CSRF 토큰 관리도 보안의 중요한 부분입니다.
시리즈 목차:
- Prisma Extension으로 민감 데이터 암호화 자동화하기
- Next.js에서 2FA/TOTP 인증 구현하기
- API 에러 처리 표준화: withErrorHandler 패턴
- 민감 데이터 접근 추적: Audit Logging 구현 ← 현재 글
- Next.js App Router에서 CSRF 토큰 관리하기