Prisma Extension으로 민감 데이터 암호화 자동화하기

개인정보보호법 준수를 위해 DB 민감 데이터를 암호화해야 했습니다. Prisma Extension으로 투명한 암호화를 구현하고, Blind Index로 검색 문제까지 해결한 경험을 공유합니다.

1. 문제 상황

개인정보보호법과 GDPR 준수를 위해 데이터베이스에 저장되는 민감 정보(이메일, 전화번호, 이름 등)를 암호화해야 했습니다. 단순히 암호화만 하면 될 것 같았지만, 실제로는 여러 문제에 부딪혔습니다.

마주한 문제들

  1. 투명성 부재: 모든 CRUD 로직에서 수동으로 encrypt()/decrypt() 호출 필요
  2. 검색 불가: 암호화된 필드는 WHERE email = ? 검색이 불가능
  3. 코드 중복: 암호화 로직이 여러 파일에 흩어져 유지보수 어려움
  4. 마이그레이션: 기존 평문 데이터를 암호화로 전환하는 방법
// ❌ Before: 매번 수동으로 암호화/복호화
const user = await prisma.user.create({
  data: {
    email: encrypt(input.email),  // 직접 호출
    name: encrypt(input.name),
  },
});

// 조회 시에도 복호화 필요
const decryptedUser = {
  ...user,
  email: decrypt(user.email),
  name: decrypt(user.name),
};

2. 해결 방법: Prisma Extension

Prisma 4.16+에서 도입된 Client Extensions를 활용하면 Query Interceptor를 통해 모든 쿼리에 암호화/복호화 로직을 자동으로 주입할 수 있습니다.

2.1 아키텍처 개요

┌─────────────────────────────────────────────────────────────┐
│                     Application Layer                        │
│  prisma.user.create({ data: { email: "[email protected]" }}) │
└─────────────────────────────────────────────────────────────┘
                              │
                              ▼
┌─────────────────────────────────────────────────────────────┐
│                   Prisma Extension Layer                     │
│  ┌───────────────┐    ┌───────────────┐                     │
│  │ encrypt()     │    │ decrypt()     │                     │
│  │ + blindIndex()│    │               │                     │
│  └───────┬───────┘    └───────┬───────┘                     │
│          │ create/update       │ findMany/findFirst         │
└──────────┼────────────────────┼─────────────────────────────┘
           │                    │
           ▼                    ▼
┌─────────────────────────────────────────────────────────────┐
│                       Database                               │
│  email: "ENC:base64..."  │  emailHash: "5a3f8b2c..."        │
└─────────────────────────────────────────────────────────────┘

2.2 암호화 유틸리티 구현

AES-256-GCM을 사용한 암호화 유틸리티입니다. GCM 모드는 인증된 암호화(Authenticated Encryption)를 제공하여 데이터 무결성까지 보장합니다.

// src/lib/core/encryption.ts
import crypto from 'crypto';

const ALGORITHM = 'aes-256-gcm';
const IV_LENGTH = 12;      // GCM 권장 96비트
const AUTH_TAG_LENGTH = 16; // 128비트 인증 태그
const KEY_LENGTH = 32;      // AES-256
const ENCRYPTED_PREFIX = 'ENC:';  // ← 암호화 여부 식별

function getEncryptionKey(): Buffer {
  const keyBase64 = process.env.ENCRYPTION_KEY;

  if (!keyBase64) {
    throw new Error('ENCRYPTION_KEY 환경변수가 설정되지 않았습니다.');
  }

  const key = Buffer.from(keyBase64, 'base64');

  if (key.length !== KEY_LENGTH) {
    throw new Error(`ENCRYPTION_KEY는 ${KEY_LENGTH}바이트여야 합니다.`);
  }

  return key;
}

/**
 * 문자열 암호화
 * 출력 형식: ENC:base64(IV + AuthTag + EncryptedData)
 */
export function encrypt(plaintext: string): string {
  if (!plaintext) return plaintext;

  const key = getEncryptionKey();
  const iv = crypto.randomBytes(IV_LENGTH);  // ← 매번 새로운 IV

  const cipher = crypto.createCipheriv(ALGORITHM, key, iv);
  let encrypted = cipher.update(plaintext, 'utf8');
  encrypted = Buffer.concat([encrypted, cipher.final()]);

  const authTag = cipher.getAuthTag();  // ← GCM 인증 태그
  const combined = Buffer.concat([iv, authTag, encrypted]);

  return ENCRYPTED_PREFIX + combined.toString('base64');
}

/**
 * 문자열 복호화
 */
export function decrypt(ciphertext: string): string {
  if (!ciphertext) return ciphertext;

  // 암호화되지 않은 문자열은 그대로 반환 (마이그레이션 호환)
  if (!ciphertext.startsWith(ENCRYPTED_PREFIX)) {
    return ciphertext;  // ← 핵심: 평문 데이터 호환성
  }

  const key = getEncryptionKey();
  const combined = Buffer.from(
    ciphertext.slice(ENCRYPTED_PREFIX.length),
    'base64'
  );

  // IV, AuthTag, EncryptedData 분리
  const iv = combined.subarray(0, IV_LENGTH);
  const authTag = combined.subarray(IV_LENGTH, IV_LENGTH + AUTH_TAG_LENGTH);
  const encrypted = combined.subarray(IV_LENGTH + AUTH_TAG_LENGTH);

  const decipher = crypto.createDecipheriv(ALGORITHM, key, iv);
  decipher.setAuthTag(authTag);

  const decrypted = Buffer.concat([
    decipher.update(encrypted),
    decipher.final()
  ]);

  return decrypted.toString('utf8');
}

/**
 * 암호화된 문자열인지 확인
 */
export function isEncrypted(value: string): boolean {
  return value?.startsWith(ENCRYPTED_PREFIX) ?? false;
}

핵심 포인트:

  • ENC: 접두사로 암호화된 데이터 식별 → 마이그레이션 중 평문/암호문 혼재 시 안전
  • 매 암호화마다 새로운 IV 생성 → 동일 평문도 다른 암호문 생성 (semantic security)
  • GCM 모드 인증 태그 → 데이터 변조 감지

2.3 Blind Index 구현

암호화된 필드는 검색이 불가능합니다. 이를 해결하기 위해 Blind Index 패턴을 사용합니다.

// src/lib/core/encryption.ts (계속)

/**
 * Blind Index 생성
 * - HMAC-SHA256으로 deterministic 해시 생성
 * - 동일 입력 → 동일 해시 (검색 가능)
 * - 해시만으로는 원본 복원 불가
 */
export function blindIndex(value: string): string {
  if (!value) return '';

  const key = getEncryptionKey();
  const hmac = crypto.createHmac('sha256', key);
  hmac.update(value.toLowerCase().trim());  // ← 정규화

  return hmac.digest('hex');  // 64자 hex 문자열
}

/**
 * 이메일용 Blind Index
 */
export function emailBlindIndex(email: string): string {
  return blindIndex(email.toLowerCase().trim());
}

/**
 * 이름용 Blind Index
 */
export function nameBlindIndex(name: string): string {
  return blindIndex(name.trim());
}

데이터베이스 스키마:

model User {
  id        String  @id @default(cuid())
  email     String  // 암호화됨
  emailHash String? @unique  // ← Blind Index (검색용)
  name      String? // 암호화됨
}

model Item {
  id       String  @id @default(cuid())
  name     String  // 암호화됨
  nameHash String? // ← Blind Index (검색용)
  phone    String? // 암호화됨
  email    String? // 암호화됨
}

검색 사용 예:

// 이메일로 사용자 찾기
const user = await prisma.user.findFirst({
  where: {
    emailHash: emailBlindIndex('[email protected]'),  // ← 해시로 검색
  },
});

// user.email은 Prisma Extension이 자동 복호화
console.log(user.email);  // "[email protected]"

2.4 필드별 암호화 헬퍼

모델별로 어떤 필드를 암호화할지 정의합니다.

// src/lib/core/encryption-fields.ts
import { encrypt, decrypt, isEncrypted, emailBlindIndex, nameBlindIndex } from './encryption';

type DataRecord = Record<string, unknown>;

// 암호화 대상 필드 정의
const USER_ENCRYPTED_FIELDS = ['name', 'email'] as const;
const ITEM_ENCRYPTED_FIELDS = ['phone', 'email', 'name'] as const;

/**
 * 문자열 필드 암호화 (이미 암호화된 경우 스킵)
 */
function encryptStringFields<T extends DataRecord>(
  data: T,
  fields: readonly string[]
): T {
  const result = { ...data };

  for (const field of fields) {
    const value = result[field];
    if (typeof value === 'string' && value && !isEncrypted(value)) {
      (result as DataRecord)[field] = encrypt(value);
    }
  }

  return result;
}

/**
 * 문자열 필드 복호화
 */
function decryptStringFields<T extends DataRecord>(
  data: T,
  fields: readonly string[]
): T {
  const result = { ...data };

  for (const field of fields) {
    const value = result[field];
    if (typeof value === 'string' && isEncrypted(value)) {
      (result as DataRecord)[field] = decrypt(value);
    }
  }

  return result;
}

/**
 * 이메일 필드 암호화 + Blind Index 생성
 */
function encryptEmailWithHash<T extends DataRecord>(data: T): T {
  const email = data.email;

  if (typeof email === 'string' && email && !isEncrypted(email)) {
    (data as DataRecord).email = encrypt(email);
    (data as DataRecord).emailHash = emailBlindIndex(email);  // ← 자동 생성
  }

  return data;
}

/**
 * User 필드 암호화
 */
export function encryptUserFields<T extends DataRecord>(data: T): T {
  let result = { ...data };
  result = encryptStringFields(result, ['name']);
  result = encryptEmailWithHash(result);
  return result;
}

/**
 * User 필드 복호화
 */
export function decryptUserFields<T extends DataRecord>(data: T): T {
  if (!data) return data;
  return decryptStringFields({ ...data }, USER_ENCRYPTED_FIELDS);
}

/**
 * 배열 복호화 헬퍼
 */
export const decryptUserArray = (items: DataRecord[]) =>
  items.map(decryptUserFields);

2.5 Prisma Extension으로 투명한 암호화

이제 핵심입니다. Prisma Extension을 통해 모든 쿼리에 암호화/복호화를 자동 적용합니다.

// src/lib/core/db/index.ts
import { PrismaClient } from "@prisma/client";
import {
  encryptUserFields,
  decryptUserFields,
  decryptUserArray,
  encryptItemFields,
  decryptItemFields,
  decryptItemArray,
} from "../encryption-fields";

type DataRecord = Record<string, unknown>;

// nullable 결과 복호화 헬퍼
const decryptOrNull = <T>(
  result: T | null,
  decryptFn: (data: DataRecord) => DataRecord
): T | null => result ? (decryptFn(result as DataRecord) as T) : null;

function createPrismaClient() {
  const baseClient = new PrismaClient({
    log: process.env.NODE_ENV === "development"
      ? ["query", "error", "warn"]
      : ["error"],
  });

  return baseClient.$extends({
    query: {
      // User 모델 암호화
      user: {
        async create({ args, query }) {
          // 저장 전 암호화
          if (args.data) {
            args.data = encryptUserFields(args.data as DataRecord) as typeof args.data;
          }
          // 조회 후 복호화
          return decryptUserFields(await query(args) as DataRecord) as Awaited<ReturnType<typeof query>>;
        },

        async update({ args, query }) {
          if (args.data) {
            args.data = encryptUserFields(args.data as DataRecord) as typeof args.data;
          }
          return decryptUserFields(await query(args) as DataRecord) as Awaited<ReturnType<typeof query>>;
        },

        async upsert({ args, query }) {
          if (args.create) {
            args.create = encryptUserFields(args.create as DataRecord) as typeof args.create;
          }
          if (args.update) {
            args.update = encryptUserFields(args.update as DataRecord) as typeof args.update;
          }
          return decryptUserFields(await query(args) as DataRecord) as Awaited<ReturnType<typeof query>>;
        },

        async findUnique({ args, query }) {
          return decryptOrNull(await query(args), decryptUserFields);
        },

        async findFirst({ args, query }) {
          return decryptOrNull(await query(args), decryptUserFields);
        },

        async findMany({ args, query }) {
          return decryptUserArray(await query(args) as DataRecord[]) as Awaited<ReturnType<typeof query>>;
        },
      },

      // Item 모델도 동일 패턴
      item: {
        async create({ args, query }) {
          if (args.data) {
            args.data = encryptItemFields(args.data as DataRecord) as typeof args.data;
          }
          return decryptItemFields(await query(args) as DataRecord) as Awaited<ReturnType<typeof query>>;
        },

        async createMany({ args, query }) {
          // createMany는 배열 처리
          if (args.data) {
            args.data = Array.isArray(args.data)
              ? args.data.map((d) => encryptItemFields(d as DataRecord) as typeof d)
              : encryptItemFields(args.data as DataRecord) as typeof args.data;
          }
          return query(args);  // createMany는 결과에 데이터 없음
        },

        async findMany({ args, query }) {
          return decryptItemArray(await query(args) as DataRecord[]) as Awaited<ReturnType<typeof query>>;
        },
        // ... 나머지 메서드 동일
      },
    },
  });
}

// 싱글톤 패턴
const globalForPrisma = globalThis as unknown as {
  prisma: ReturnType<typeof createPrismaClient> | undefined;
};

export const prisma = globalForPrisma.prisma ?? createPrismaClient();

if (process.env.NODE_ENV !== "production") {
  globalForPrisma.prisma = prisma;
}

2.6 사용 예시

이제 애플리케이션 코드에서는 암호화를 전혀 의식할 필요가 없습니다.

// ✅ After: 평문으로 작업, 암호화는 자동
import { prisma } from '@/lib/core/db';

// 생성 - 자동 암호화 + Blind Index 생성
const user = await prisma.user.create({
  data: {
    email: '[email protected]',
    name: 'John Doe',
  },
});

console.log(user.email);  // "[email protected]" (자동 복호화)

// 조회 - 자동 복호화
const users = await prisma.user.findMany({
  where: { companyId: 'company-123' },
});

users.forEach(u => console.log(u.name));  // 평문으로 표시

// 검색 - Blind Index 사용
import { emailBlindIndex } from '@/lib/core/encryption';

const found = await prisma.user.findFirst({
  where: {
    emailHash: emailBlindIndex('[email protected]'),
  },
});

3. 운영 DB 마이그레이션

기존 평문 데이터를 암호화로 전환하는 것은 까다로운 작업입니다. 배치 처리와 멱등성(idempotency)을 보장해야 합니다.

3.1 마이그레이션 스크립트

// prisma/scripts/migrate-encryption.ts
import { PrismaClient } from "@prisma/client";
import { encrypt, isEncrypted, emailBlindIndex, nameBlindIndex } from "../../src/lib/core/encryption";

// 중요: Extension 없는 Raw Client 사용 (이중 암호화 방지)
const prisma = new PrismaClient();
const BATCH_SIZE = 100;

interface MigrationStats {
  processed: number;
  encrypted: number;
  skipped: number;
}

async function migrateUsers(): Promise<MigrationStats> {
  console.log("\n=== User 암호화 마이그레이션 ===\n");

  const totalCount = await prisma.user.count();
  console.log(`총 User 수: ${totalCount}`);

  const stats: MigrationStats = { processed: 0, encrypted: 0, skipped: 0 };
  let skip = 0;

  while (skip < totalCount) {
    const users = await prisma.user.findMany({
      take: BATCH_SIZE,
      skip,
      select: { id: true, name: true, email: true, emailHash: true },
    });

    for (const user of users) {
      const updates: Record<string, string | null> = {};

      // 이미 암호화되지 않은 경우만 처리 (멱등성)
      if (user.name && !isEncrypted(user.name)) {
        updates.name = encrypt(user.name);
      }

      if (user.email && !isEncrypted(user.email)) {
        updates.email = encrypt(user.email);
        updates.emailHash = emailBlindIndex(user.email);  // ← Blind Index 생성
      } else if (!user.emailHash && user.email) {
        // 이메일은 암호화됐지만 해시가 없는 경우 경고
        console.warn(`  [경고] User ${user.id}: 이메일 암호화됨, 해시 없음`);
      }

      if (Object.keys(updates).length > 0) {
        await prisma.user.update({
          where: { id: user.id },
          data: updates,
        });
        stats.encrypted++;
        console.log(`  [암호화] User ID: ${user.id}`);
      } else {
        stats.skipped++;
      }

      stats.processed++;
    }

    skip += BATCH_SIZE;
    console.log(`  진행: ${stats.processed}/${totalCount}`);
  }

  console.log(`\nUser 완료: 암호화 ${stats.encrypted}건, 스킵 ${stats.skipped}건`);
  return stats;
}

async function main() {
  console.log("==========================================");
  console.log("  민감 데이터 암호화 마이그레이션");
  console.log("==========================================");

  if (!process.env.ENCRYPTION_KEY) {
    console.error("\n[ERROR] ENCRYPTION_KEY 환경변수가 필요합니다.");
    process.exit(1);
  }

  await migrateUsers();
  // await migrateItems();
  // await migrateOtherModels();

  console.log("\n==========================================");
  console.log("  마이그레이션 완료");
  console.log("==========================================");
}

main()
  .catch(console.error)
  .finally(() => prisma.$disconnect());

실행 방법:

# 환경변수 설정 후 실행
cd web && npx tsx prisma/scripts/migrate-encryption.ts

3.2 마이그레이션 핵심 원칙

원칙 설명 구현
멱등성 여러 번 실행해도 같은 결과 isEncrypted() 체크로 이미 암호화된 데이터 스킵
배치 처리 메모리 효율성 BATCH_SIZE = 100으로 분할 처리
Raw Client 이중 암호화 방지 Extension 없는 new PrismaClient() 사용
진행률 표시 대용량 데이터 모니터링 배치마다 진행 상황 출력
트랜잭션 X 부분 실패 허용 건별 처리로 일부 실패 시 재시도 가능

4. 핵심 개념 정리

개념 설명 사용 사례
AES-256-GCM 256비트 키 + 인증된 암호화 민감 데이터 저장 (이메일, 이름, 연락처)
Blind Index 암호화된 필드 검색용 해시 이메일로 사용자 찾기, 이름 검색
Prisma Extension Query Interceptor로 투명한 처리 자동 암호화/복호화
ENC: 접두사 암호화 여부 식별 마이그레이션 중 평문/암호문 구분

5. 베스트 프랙티스

구현 체크리스트

  • [ ] 32바이트 암호화 키 생성 및 환경변수 설정
  • [ ] isEncrypted() 체크로 이중 암호화 방지
  • [ ] Blind Index 컬럼에 unique 인덱스 추가
  • [ ] 마이그레이션 전 DB 백업
  • [ ] 마이그레이션 스크립트 테스트 환경에서 먼저 실행

키 생성 명령어

# 32바이트 랜덤 키 생성 (Base64)
node -e "console.log(require('crypto').randomBytes(32).toString('base64'))"

환경변수 설정

# .env
ENCRYPTION_KEY=YourBase64EncodedKey==

주의사항

  1. 키 로테이션: 키 변경 시 모든 데이터 재암호화 필요
  2. 성능: 암호화/복호화 오버헤드 (~1-2ms per record)
  3. 백업: 암호화 키 없이는 복구 불가, 키 별도 백업 필수
  4. JSON 필드: encryptJson()/decryptJson()으로 별도 처리

6. FAQ

Q: 왜 GCM 모드를 사용하나요?

A: GCM(Galois/Counter Mode)은 암호화와 인증을 동시에 제공합니다. 데이터 변조 시 복호화가 실패하므로 무결성이 보장됩니다. CBC 모드는 패딩 오라클 공격에 취약할 수 있습니다.

Q: Blind Index가 왜 필요한가요?

A: 암호화된 데이터는 WHERE email = '[email protected]' 같은 검색이 불가능합니다. Blind Index는 동일 입력에 동일 해시를 생성하므로 검색이 가능하지만, 해시만으로는 원본을 복원할 수 없습니다.

Q: Extension 대신 Prisma Middleware를 쓰면 안 되나요?

A: Middleware는 Prisma 4.0에서 deprecated 예정입니다. Extension이 타입 안전성도 더 좋고 공식 권장 방식입니다.

Q: 마이그레이션 중 서비스 중단이 필요한가요?

A: ENC: 접두사로 암호화 여부를 식별하므로, 마이그레이션 중에도 평문/암호문 모두 처리 가능합니다. 무중단 마이그레이션이 가능합니다.

Q: JSON 필드도 암호화할 수 있나요?

A: 네, JSON을 문자열로 직렬화 후 암호화합니다. 복호화 시 다시 파싱합니다. 단, 암호화된 JSON은 DB에서 직접 쿼리할 수 없습니다.


7. 참고 자료


8. 다음 단계

암호화를 도입했다면, 누가 언제 데이터에 접근했는지 추적하는 감사 로깅도 필수입니다.

시리즈 목차:

  1. Prisma Extension으로 민감 데이터 암호화 자동화하기 ← 현재 글
  2. 민감 데이터 접근 추적: Audit Logging 구현
  3. Next.js에서 2FA/TOTP 인증 구현하기