민감 데이터 접근 추적: Audit Logging 구현하기

개인정보보호법과 GDPR 준수를 위해 민감 데이터 접근 이력을 추적해야 했습니다. Context 기반 감사 로깅과 Fire-and-forget 패턴으로 구현한 경험을 공유합니다.

1. 문제 상황

개인정보보호법과 GDPR 준수를 위해 민감 데이터 접근/변경 이력을 추적해야 했습니다. 단순히 로그를 남기는 것이 아니라, 다음을 만족해야 했습니다.

요구사항

  1. 5W1H 추적: 누가(who), 언제(when), 무엇을(what), 어디서(where), 왜(why), 어떻게(how)
  2. 변경 diff: 수정 전/후 값 비교
  3. 비즈니스 로직 무영향: 로깅 실패가 메인 로직을 막으면 안 됨
  4. 성능 고려: 동기 로깅으로 인한 지연 최소화

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 토큰 관리도 보안의 중요한 부분입니다.

시리즈 목차:

  1. Prisma Extension으로 민감 데이터 암호화 자동화하기
  2. Next.js에서 2FA/TOTP 인증 구현하기
  3. API 에러 처리 표준화: withErrorHandler 패턴
  4. 민감 데이터 접근 추적: Audit Logging 구현 ← 현재 글
  5. Next.js App Router에서 CSRF 토큰 관리하기