상품 삭제
↓
상담 신청에서 참조 중
↓
과거 상담의 상품 정보 확인 불가
↓
운영/고객 응대 문제 발생
과거 상담 기록이 깨질 수 있음
주문/상담 이력 추적이 어려워질 수 있음
관리자 실수 복구가 어려움
통계 기준이 바뀔 수 있음
외래키 제약조건 오류가 발생할 수 있음
누가 삭제했는지 알 수 없을 수 있음
| 구분 | 의미 | 예시 |
|---|---|---|
| Hard Delete | DB row를 실제로 삭제 | DELETE FROM products WHERE id = 1 |
| Soft Delete | 삭제 표시만 하고 row는 유지 | deletedAt에 시간 저장 |
DELETE FROM products
WHERE id = 1;
특징:
DB에서 실제로 사라짐
복구가 어려움
참조 관계가 깨질 수 있음
저장 공간은 줄어듦
삭제 버튼 클릭
↓
DB row 삭제 X
↓
deletedAt에 삭제 시간 저장
↓
일반 목록에서는 제외
↓
필요 시 복구 가능
UPDATE products
SET deleted_at = NOW()
WHERE id = 1;
특징:
데이터가 DB에 남아 있음
실수 삭제 복구 가능
과거 참조 기록 유지
조회 조건 관리 필요
products:
상품
product_options:
용량/색상/지원금 옵션
banners:
배너
admin_users:
관리자 계정
consult_memos:
상담 메모
faq_items:
FAQ
lead_sources:
유입 소스 설정
promotion_configs:
프로모션 설정
과거 상담이 상품을 참조할 수 있음
상품을 잠시 숨겼다가 다시 노출할 수 있음
관리자 실수 삭제 복구 가능
통계에서 과거 상품명을 확인해야 함
과거 상태 변경 이력의 changedByAdminId 보존
Audit Log의 actorId 보존
퇴사/비활성 관리자를 기록으로 남김
계정을 실제 삭제하면 이력 추적이 어려워짐
isActive=false, disabledAt 처리하는 것이 더 좋습니다.임시 인증 코드
만료된 세션
만료된 refresh token
짧게 보관하는 debug log
처리 완료 후 오래 지난 임시 파일
중복 생성된 테스트 데이터
운영 데이터와 테스트 데이터 구분
고객/상담/주문 원본 데이터는 신중히 처리
삭제 전 참조 관계 확인
삭제 작업은 Audit Log 남기기
deletedAt 컬럼을 둡니다.deletedAt:
삭제 처리 시간
deletedByAdminId:
삭제한 관리자
deleteReason:
삭제 사유
isDeleted:
삭제 여부 boolean
deletedAt:
nullable DateTime
deletedByAdminId:
nullable Int
deleteReason:
nullable String
model Product {
id Int @id @default(autoincrement())
modelName String
carrier String
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime?
deletedByAdminId Int?
deleteReason String?
deletedByAdmin AdminUser? @relation(fields: [deletedByAdminId], references: [id])
@@index([deletedAt])
}
isDeleted보다 deletedAt이 좋은 이유isDeleted:
삭제 여부만 알 수 있음
deletedAt:
삭제 여부 + 삭제 시각을 함께 알 수 있음
deletedAt IS NULL이면 살아 있는 데이터입니다.deletedAt IS NOT NULL이면 삭제 처리된 데이터입니다.deletedAt: null 조건을 넣어야 합니다.const products = await prisma.product.findMany({
where: {
deletedAt: null,
isActive: true,
},
});
const deletedProducts = await prisma.product.findMany({
where: {
deletedAt: {
not: null,
},
},
orderBy: {
deletedAt: 'desc',
},
});
일반 목록에는 deletedAt null 조건 필수
상세 조회에도 deletedAt 조건 필요
관리자 복구 화면에서는 삭제된 데이터 포함
통계에서 삭제된 데이터를 포함할지 기준 필요
admin_users.email = test@example.com
deletedAt = 2026-08-19
새 관리자 생성:
email = test@example.com
결과:
unique constraint 때문에 실패
삭제된 row를 복구해서 재사용
partial unique index 사용
삭제 시 email에 suffix 추가
새 계정 생성을 막고 복구만 허용
CREATE UNIQUE INDEX uniq_admin_users_email_active
ON admin_users (email)
WHERE deleted_at IS NULL;
의미:
deleted_at이 NULL인 활성 row끼리만 email unique 적용
삭제된 row의 email은 unique 검사에서 제외
consults.productId → products.id
products.deletedAt != null
상담은 과거 상품을 계속 참조 가능
하지만 고객 화면 상품 목록에서는 제외
과거 데이터:
삭제된 상품도 참조 가능
신규 생성:
삭제된 상품으로 상담 신청 불가
관리자 상세:
삭제된 상품명은 표시 가능
고객 화면:
삭제된 상품은 숨김
관리자 복구 요청
↓
삭제된 row 조회
↓
복구 가능 여부 확인
↓
deletedAt = null
↓
deletedByAdminId = null
↓
deleteReason = null
↓
Audit Log 기록
await prisma.$transaction(async (tx) => {
const product = await tx.product.findUniqueOrThrow({
where: { id: productId },
});
if (!product.deletedAt) {
throw new BadRequestException('삭제된 상품이 아닙니다.');
}
await tx.product.update({
where: { id: productId },
data: {
deletedAt: null,
deletedByAdminId: null,
deleteReason: null,
},
});
await tx.auditLog.create({
data: {
actorType: 'ADMIN',
actorId: adminId,
action: 'PRODUCT_RESTORE',
targetType: 'PRODUCT',
targetId: String(productId),
beforeValue: {
deletedAt: product.deletedAt,
},
afterValue: {
deletedAt: null,
},
requestId,
},
});
});
같은 unique 값의 활성 데이터가 있는가?
연결된 옵션도 함께 복구할 것인가?
복구 후 고객 화면에 바로 노출할 것인가?
isActive는 어떤 상태로 둘 것인가?
권한 있는 관리자만 복구 가능한가?
| 구분 | 의미 | 컬럼 |
|---|---|---|
| 노출 중지 | 고객 화면에서 숨김 | isActive=false |
| 삭제 처리 | 운영 데이터에서 삭제 상태 | deletedAt |
| 품절 | 판매 불가 상태 | stockStatus |
| 예약 종료 | 사전예약 종료 | reservationEndedAt |
isActive = false
deletedAt = null
의미:
데이터는 살아 있음
고객 화면에서는 숨김
관리자에서는 수정 가능
deletedAt != null
의미:
삭제함으로 이동
일반 관리자 목록에서도 숨길 수 있음
복구 전까지 신규 사용 제한
잠시 숨기기:
isActive=false
운영에서 제거:
deletedAt 설정
과거 상담 참조:
상품 snapshot으로 보존
완전 삭제:
매우 신중히, 보통 지양
isActive와 deletedAt을 조합하는 것이 좋습니다.개인정보 포함
고객 응대 기록
유입 분석 자료
운영 성과 자료
상태 변경 이력과 연결
알림톡/SMS 발송 이력과 연결
보존 기간이 필요한가?
개인정보 파기가 필요한가?
통계용 익명화가 가능한가?
상담 이력은 남겨야 하는가?
고객 요청 삭제인지 운영자 실수 삭제인지 구분되는가?
Soft Delete:
운영 화면에서 숨김
Anonymization:
개인정보를 마스킹/삭제하고 통계만 남김
Hard Delete:
정책상 완전 삭제가 필요할 때 신중히 수행
customerName:
김고객 → 익명
phone:
01012345678 → null 또는 010****5678
memo:
상담 메모 삭제
ipAddress:
null 또는 일부 마스킹
userAgent:
필요 시 삭제
await prisma.consult.update({
where: { id: consultId },
data: {
customerName: '익명',
phone: null,
phoneNormalized: null,
phoneLast4: null,
memo: null,
ipAddress: null,
anonymizedAt: new Date(),
},
});
익명화 후 복구 불가능하게 볼 것
상담 처리 중인 데이터에 적용 금지
통계에 필요한 값과 개인정보를 구분
상태 이력/Audit Log에도 개인정보가 없는지 확인
데이터 생성
↓
운영 기간 동안 사용
↓
보존 기간 경과
↓
익명화 / 아카이브 / 삭제
개인정보 과다 보관 방지
DB 용량 증가 관리
로그 비용 관리
운영 화면 성능 유지
보안 사고 시 피해 범위 감소
데이터 종류
보존 기간
삭제 방식
익명화 방식
접근 권한
복구 가능 여부
작업 기록 방식
| 데이터 | 보존 방향 | 삭제/정리 방식 |
|---|---|---|
| 상담 신청 | 일정 기간 보관 | 만료 후 익명화 검토 |
| 상담 상태 이력 | 장기 보관 | 개인정보 제외 후 보관 |
| 상품 | 장기 보관 | Soft Delete |
| 관리자 계정 | 비활성화 보관 | 삭제보다 disable |
| Audit Log | 장기 보관 | 개인정보 제외 |
| Access Log | 단기 보관 | 기간 경과 후 삭제 |
| Debug Log | 짧게 보관 | 자동 삭제 |
| 알림톡 로그 | 운영 기준 보관 | 수신자 마스킹 |
| Export 파일 | 짧게 보관 | 만료 후 삭제 |
상품 기본 정보
상태 변경 이력
Audit Log
통계용 집계 데이터
익명화된 상담 통계
원본 전화번호
상담 메모
IP Address
Access Log
임시 Export 파일
인증 토큰
status로 ARCHIVED 처리
archivedAt 컬럼 추가
별도 archive table로 이동
S3 파일로 export 후 DB에서는 요약만 보관
archivedAt
archivedByAdminId
archiveReason
일반 관리자 목록에서는 제외
검색 조건에서 아카이브 포함 옵션 제공
통계에는 포함할지 기준 필요
복구 가능 여부 결정
export_jobs
- id
- status
- fileUrl
- expiresAt
- downloadedAt
- requestedByAdminId
- createdAt
다운로드 파일은 1~7일 후 만료
만료 후 S3 파일 삭제
export_jobs row는 이력으로 보관 가능
fileUrl은 만료 후 null 처리 가능
다운로드 요청자는 Audit Log에 기록
파일 URL 외부 노출 주의
권한 없는 관리자 다운로드 방지
S3 pre-signed URL 만료 시간 설정
개인정보 포함 파일 장기 보관 금지
Application Log
Nginx Access Log
Nginx Error Log
CloudWatch Log
Audit Log
상태 변경 이력
알림 발송 로그
Debug Log
Audit Log:
장기 보관
상태 변경 이력:
장기 보관
Application Error Log:
중기 보관
Access Log:
단기~중기 보관
Debug Log:
짧게 보관
개인정보 포함 가능 로그:
마스킹 또는 짧게 보관
보관 기간 설정 안 하면 비용 증가 가능
민감정보 로그 전송 금지
Log Group별 retention 설정 필요
장애 분석에 필요한 기간은 확보
CASCADE는 부모 row를 삭제하면 자식 row도 함께 삭제합니다.Product 삭제
↓
관련 Consult 삭제
↓
상담 상태 이력 삭제
↓
운영 기록 손실
| 정책 | 의미 |
|---|---|
| CASCADE | 부모 삭제 시 자식도 삭제 |
| RESTRICT | 자식이 있으면 부모 삭제 금지 |
| SET NULL | 부모 삭제 시 자식 FK를 null |
| NO ACTION | 기본 제약 유지 |
상담/주문/이력:
CASCADE 신중히 사용
상품/관리자:
삭제보다 soft delete 또는 disable
임시 데이터:
CASCADE 가능
핵심 운영 데이터:
RESTRICT 또는 SET NULL 검토
PRODUCT_SOFT_DELETE
PRODUCT_RESTORE
PRODUCT_HARD_DELETE
ADMIN_USER_DISABLE
CONSULT_ANONYMIZE
EXPORT_FILE_EXPIRE
BANNER_SOFT_DELETE
{
"actorType": "ADMIN",
"actorId": 1,
"action": "PRODUCT_SOFT_DELETE",
"targetType": "PRODUCT",
"targetId": "10",
"beforeValue": {
"deletedAt": null,
"isActive": true
},
"afterValue": {
"deletedAt": "2026-08-19T02:00:00.000Z",
"isActive": false
}
}
삭제 대상 전체 row를 beforeValue에 넣지 않기
개인정보/Secret 제외
삭제 사유 저장
복구 작업도 Audit Log로 남기기
PRODUCT_DELETE
PRODUCT_RESTORE
CONSULT_DELETE
CONSULT_ANONYMIZE
ADMIN_USER_DISABLE
EXPORT_FILE_DELETE
AUDIT_LOG_VIEW
삭제 버튼은 위험 색상 사용
삭제 전 확인 모달 표시
삭제 대상명 명확히 표시
복구 가능 여부 안내
Hard Delete는 별도 권한 필요
상품을 삭제 처리하시겠습니까?
상품명:
Galaxy S 시리즈
삭제 후 고객 화면과 일반 관리자 목록에서 숨겨집니다.
과거 상담 기록은 유지됩니다.
삭제함에서 복구할 수 있습니다.
DELETE /products/:id로 만들 수도 있지만, soft delete라면 의미를 명확히 하는 것이 좋습니다.DELETE /admin/products/:id
POST /admin/products/:id/restore
POST /admin/consults/:id/anonymize
POST /admin/products/:id/archive
async softDeleteProduct(productId: number, adminId: number, reason?: string) {
return this.prisma.$transaction(async (tx) => {
const product = await tx.product.findUniqueOrThrow({
where: { id: productId },
});
if (product.deletedAt) {
throw new BadRequestException('이미 삭제된 상품입니다.');
}
await tx.product.update({
where: { id: productId },
data: {
deletedAt: new Date(),
deletedByAdminId: adminId,
deleteReason: reason,
isActive: false,
},
});
await tx.auditLog.create({
data: {
actorType: 'ADMIN',
actorId: adminId,
action: 'PRODUCT_SOFT_DELETE',
targetType: 'PRODUCT',
targetId: String(productId),
beforeValue: {
deletedAt: product.deletedAt,
isActive: product.isActive,
},
afterValue: {
deletedAt: new Date(),
isActive: false,
},
},
});
});
}
new Date()를 여러 번 호출하면 값이 미세하게 달라질 수 있음
deletedAt 값을 변수로 만들어 재사용
이미 삭제된 데이터 중복 삭제 방지
삭제와 Audit Log를 transaction으로 묶기
개선:
const deletedAt = new Date();
await tx.product.update({
where: { id: productId },
data: {
deletedAt,
deletedByAdminId: adminId,
deleteReason: reason,
isActive: false,
},
});
await tx.auditLog.create({
data: {
actorType: 'ADMIN',
actorId: adminId,
action: 'PRODUCT_SOFT_DELETE',
targetType: 'PRODUCT',
targetId: String(productId),
afterValue: {
deletedAt,
isActive: false,
},
},
});
만료된 Export 파일
오래된 debug log
만료된 session/token
오래된 임시 인증 코드
처리 완료 후 오래 지난 job payload
매일 새벽 실행
↓
만료 대상 조회
↓
S3 파일 삭제
↓
DB 상태 EXPIRED 처리
↓
cleanup 결과 로그 저장
처음부터 Hard Delete하지 말고 dry-run 가능하게 만들기
삭제 대상 개수 로그 남기기
실패 시 재시도 가능하게 하기
운영 데이터 삭제 조건 명확히 하기
cleanup export files --dry-run
결과:
삭제 예정 파일 124개
만료된 ExportJob 124건
예상 S3 prefix 목록
실제 삭제는 하지 않음
dryRun=true면 삭제하지 않음
대상 count 반환
일부 sample 반환
조건 로그 출력
관리자 승인 후 실제 실행
dry run 결과와 실제 실행 사이 데이터가 바뀔 수 있음
대량 삭제 전 snapshot/backup 확인
권한 있는 관리자만 실행
deletedAt 컬럼이 있는가?deletedByAdminId와 삭제 사유가 필요한가?deletedAt: null 조건이 적용되는가?NestJS + Prisma + PostgreSQL 기반 온라인 휴대폰 판매몰에서 Soft Delete, 복구, 데이터 보존 정책을 설계하려고 해.
서비스 상황:
1. 상품, 상품 옵션, 배너, 상담 신청, 관리자 계정, Audit Log, ExportJob이 있음
2. 상품은 삭제해도 과거 상담 기록에서 참조될 수 있어야 함
3. 관리자 계정은 삭제보다 비활성화가 적합할 수 있음
4. 상담 데이터에는 이름, 전화번호, 메모, IP 같은 개인정보가 포함될 수 있음
5. 엑셀 Export 파일에는 개인정보가 포함될 수 있어 오래 보관하면 위험함
6. 삭제/복구 작업은 Audit Log로 남기고 싶음
7. Soft Delete는 deletedAt, deletedByAdminId, deleteReason으로 관리하려고 함
8. 일부 데이터는 보존 기간 이후 익명화 또는 정리하고 싶음
요청:
- Soft Delete 적용 후보 테이블
- Hard Delete 가능한 데이터 후보
- Prisma schema 예시
- deletedAt 조회 조건 관리 방법
- unique constraint와 soft delete 충돌 해결 방법
- 복구 API 설계
- 상품 삭제와 노출 비활성화 차이
- 상담 데이터 익명화 기준
- Export 파일 보존/만료 정책
- Cascade Delete 주의사항
- Cleanup Job과 dry run 설계
- Audit Log action 예시
를 실무 기준으로 정리해줘.
상담/주문 데이터를 쉽게 hard delete하라고 하지 않는가?
상품 삭제와 노출 비활성화를 구분하는가?
관리자 계정 삭제보다 비활성화를 고려하는가?
Soft Delete 조회 조건 누락 위험을 언급하는가?
partial unique index 같은 PostgreSQL 특성을 고려하는가?
Export 파일 개인정보 보존 위험을 설명하는가?
Cascade Delete 위험을 경고하는가?
삭제/복구 Audit Log를 제안하는가?
Cleanup Job에 dry run을 포함하는가?
deletedAt으로 삭제 상태만 표시해 일반 조회에서 제외하는 방식입니다.deletedAt: null 조건을 넣어야 하며, 조건 누락을 막기 위해 Repository/helper 기준을 만드는 것이 좋습니다.isActive=false, 운영에서 삭제 처리하는 것은 deletedAt을 사용하는 식으로 구분하는 것이 좋습니다.deletedAt=null만 하는 작업이 아니라 unique 충돌, 노출 여부, 옵션 복구, 권한, Audit Log까지 함께 확인해야 하는 운영 작업입니다.