1. 문제 상황
개인정보보호법과 GDPR 준수를 위해 데이터베이스에 저장되는 민감 정보(이메일, 전화번호, 이름 등)를 암호화해야 했습니다. 단순히 암호화만 하면 될 것 같았지만, 실제로는 여러 문제에 부딪혔습니다.
마주한 문제들
- 투명성 부재: 모든 CRUD 로직에서 수동으로
encrypt()/decrypt()호출 필요 - 검색 불가: 암호화된 필드는
WHERE email = ?검색이 불가능 - 코드 중복: 암호화 로직이 여러 파일에 흩어져 유지보수 어려움
- 마이그레이션: 기존 평문 데이터를 암호화로 전환하는 방법
// ❌ 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-2ms per record)
- 백업: 암호화 키 없이는 복구 불가, 키 별도 백업 필수
- 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. 다음 단계
암호화를 도입했다면, 누가 언제 데이터에 접근했는지 추적하는 감사 로깅도 필수입니다.
🔒 백엔드 보안 시리즈 (5편)
- 민감 데이터 접근 추적: Audit Logging
- Prisma Extension으로 민감 데이터 암호화 (현재 글)
- API 에러 처리 표준화: withErrorHandler
- Next.js CSRF 토큰 관리: Double Submit Cookie
- Next.js 2FA/TOTP 구현