--- id: db-soft-delete-patterns title: Soft Delete — deleted_at / 일관성 / 인덱스 category: Coding status: draft source_trust_level: B verification_status: conceptual created_at: 2026-05-09 updated_at: 2026-05-09 tags: [database, soft-delete, vibe-coding] tech_stack: { language: "SQL / ORM", applicable_to: ["Backend"] } applied_in: [] aliases: [soft-delete, tombstone, partial index, deleted_at] --- # Soft Delete > 행 자체 삭제 X — `deleted_at` 컬럼에 timestamp. 복구 / audit / FK 안 깨짐. 단 모든 query 에 `WHERE deleted_at IS NULL` 안 붙이면 leak. ORM scope / view / 부분 인덱스로 강제. ## 📖 핵심 개념 - Hard delete: 행 사라짐 — FK 깨짐, 복구 불가. - Soft delete: 보존 — query 마다 필터. - Partial index: `deleted_at IS NULL` 만 인덱싱 — 빠름 + 작음. - Unique constraint: 유니크 컬럼은 `(email, deleted_at IS NULL)` 처럼 부분 unique. ## 💻 코드 패턴 ### 스키마 ```sql CREATE TABLE users ( id UUID PRIMARY KEY, email TEXT NOT NULL, deleted_at TIMESTAMPTZ, created_at TIMESTAMPTZ DEFAULT NOW() ); -- partial unique: 살아있는 행 사이에서만 유니크 CREATE UNIQUE INDEX users_email_active ON users(email) WHERE deleted_at IS NULL; -- partial index: 활성 lookup 빠름 CREATE INDEX users_active ON users(id) WHERE deleted_at IS NULL; ``` ### Prisma — 자동 필터 ```ts // extension 으로 모든 query 에 자동 필터 const prisma = new PrismaClient().$extends({ query: { user: { async findMany({ args, query }) { args.where = { ...args.where, deletedAt: null }; return query(args); }, async findUnique({ args, query }) { args.where = { ...args.where, deletedAt: null }; return query(args); }, }, }, }); ``` ### Drizzle — 명시적 helper ```ts const activeUsers = () => db.select().from(users).where(isNull(users.deletedAt)); // 삭제 await db.update(users).set({ deletedAt: new Date() }).where(eq(users.id, id)); // 복구 await db.update(users).set({ deletedAt: null }).where(eq(users.id, id)); ``` ### View 로 강제 ```sql CREATE VIEW v_users AS SELECT * FROM users WHERE deleted_at IS NULL; -- 앱은 v_users 만 사용, 직접 users 접근 금지 ``` ### Cascade soft delete ```sql -- user 삭제 시 그의 posts 도 soft delete WITH deleted_user AS ( UPDATE users SET deleted_at = NOW() WHERE id = $1 RETURNING id ) UPDATE posts SET deleted_at = NOW() WHERE user_id IN (SELECT id FROM deleted_user) AND deleted_at IS NULL; ``` ### 진짜 영구 삭제 (GDPR) ```sql -- 1년 전 soft-deleted 는 hard delete DELETE FROM users WHERE deleted_at < NOW() - INTERVAL '1 year'; ``` ### Audit log 와 함께 ```sql CREATE TABLE user_deletions ( user_id UUID, deleted_at TIMESTAMPTZ, deleted_by UUID, reason TEXT ); ``` ## 🤔 의사결정 기준 | 상황 | 추천 | |---|---| | 사용자 / 게시물 (복구 필요) | Soft delete | | 일시적 데이터 (세션, 캐시) | Hard delete | | GDPR 영구삭제 요청 | Hard delete (또는 anonymize) | | 큰 audit / compliance | Soft + audit log | | FK 가 자주 끊김 우려 | Soft delete (FK 유지) | | 매우 큰 테이블 (10M+) | Hard + 테이블 이력화 | ## ❌ 안티패턴 - **모든 query 에 필터 누락**: 삭제된 행 노출. - **Unique constraint 그대로**: 같은 email 재등록 막힘. partial unique. - **인덱스에 deleted 행 포함**: 인덱스 비대. - **deleted_at 만 — by 누구 / 왜 모름**: audit 필드 같이. - **Cascade 안 함**: 삭제된 user 의 post 가 살아있음. - **GDPR 무시**: 영구 삭제 정책 + 일정 시간 후 hard delete. - **`is_deleted` boolean**: timestamp 보다 정보 적음. ## 🤖 LLM 활용 힌트 - `deleted_at` timestamp + partial unique + partial index 3종. - ORM extension 또는 view 로 자동 필터. - GDPR = soft → 일정 시간 후 hard. ## 🔗 관련 문서 - [[DB_Audit_Log_Patterns]]