습관 관리 프로젝트에서 "습관 삭제" 기능을 구현하다가 뜻밖의 고민에 빠졌다. 처음엔 당연히 DELETE 쿼리를 쓰면 된다고 생각했는데, 막상 구현하려고 보니 두 가지 문제가 걸렸다.
첫 번째. 습관을 삭제하면 연결된 체크 기록(habitLogs)이 cascade로 함께 날아간다. 사용자가 한 달 동안 쌓아온 기록이 습관 하나 삭제로 전부 사라지는 건 말이 안 됐다.
두 번째. UX 문제였다. 오늘 아침에 이미 체크한 습관을 낮에 삭제했는데 목록에서 즉시 사라지면 어색하다. "삭제했으니 오늘은 유지되고, 내일부터 안 보이는" 동작이 훨씬 자연스럽다.
이 두 가지 이유로 Soft Delete를 선택했다.
먼저 개념부터 정리하자.
Hard Delete는 DELETE 쿼리로 DB에서 행을 완전히 제거하는 방식이다. 구현이 단순하고 DB를 깔끔하게 유지할 수 있다. 하지만 한 번 지우면 복구가 어렵고, 연관 데이터까지 함께 사라질 수 있다.
Soft Delete는 실제로 삭제하지 않고 "삭제됐다"는 표시만 남기는 방식이다. 행은 DB에 그대로 있고, deletedAt이나 isDeleted 같은 필드로 상태를 관리한다.
Soft Delete를 구현할 때 가장 먼저 결정해야 할 건 어떤 필드로 상태를 표현할 것인가다.
흔히 isDeleted: Boolean 방식을 많이 쓴다. 직관적이고 간단하다. 그런데 이 프로젝트에선 endAt: DateTime?을 선택했다.
이유는 하나다. 삭제 여부뿐만 아니라 삭제 시점도 함께 남길 수 있기 때문이다.
isDeleted: true → "지워졌다"는 사실만 알 수 있다endAt: 2024-11-15 → "언제 지워졌는지"까지 알 수 있다게다가 이 프로젝트에서 핵심 요구사항이 "삭제 당일은 목록에 유지되고, 다음 날부터 제외"였기 때문에, 날짜 기반 필드가 훨씬 다루기 쉬웠다.
model Habit {
id Int @id @default(autoincrement())
studyId Int @map("study_id")
habitName String @map("habit_name")
startAt DateTime @map("start_at")
endAt DateTime? @map("end_at") // null이면 활성, 값이 있으면 종료
// ...
}
null이면 현재 활성 상태, 값이 있으면 해당 날짜에 종료된 습관이다.
실제 삭제 로직은 단순하다. DELETE 대신 endAt을 오늘 날짜로 업데이트한다.
jsexport const deleteHabit = async (studyId, habitId) => {
const habit = await prisma.habit.findFirst({
where: { id: habitId, studyId },
});
if (!habit) throw new HabitNotFoundError();
// 이미 종료된 습관은 다시 삭제 불가
if (habit.endAt !== null) {
throw new BadRequestError("이미 종료된 습관은 삭제할 수 없습니다.");
}
// 실제 삭제 대신 endAt을 오늘 날짜로 설정
await prisma.habit.update({
where: { id: habitId },
data: { endAt: getTodayKST() },
});
};
한 가지 주의할 점은 if (habit.endAt !== null) 체크다. 이미 종료된 습관에 다시 삭제 요청이 들어왔을 때 멱등하게 처리하거나(아무것도 안 함), 아니면 에러를 던질 수 있다. 이 프로젝트에서는 "이미 종료된 습관"이라는 상태를 명확히 알려주는 게 낫다고 판단해서 에러를 던졌다.
getTodayKST()는 UTC 기준 날짜 밀림 문제를 방지하기 위해 KST 기준으로 오늘 날짜를 반환하는 유틸 함수다. 한국 서비스라면 이 부분이 꽤 중요하다. (별도 포스팅으로 정리 예정)
Soft Delete에서 가장 신경 써야 할 부분이 조회 쿼리다. 조건을 빠뜨리면 삭제된 데이터가 그대로 노출된다.
이 프로젝트의 요구사항은 이랬다:
삭제 당일은 목록에 유지되고, 다음 날부터 자동으로 제외된다.
이를 구현한 조회 조건은 다음과 같다.
const habits = await prisma.habit.findMany({
where: {
studyId,
startAt: { lte: now },
OR: [
{ endAt: null }, // 종료일이 없는 활성 습관
{ endAt: { gt: today } }, // 종료일이 오늘보다 미래인 습관 (삭제 당일 포함)
],
},
include: {
habitLogs: {
where: { logDate: today },
},
},
});
endAt: { gt: today } 조건이 핵심이다. endAt이 오늘과 같은 날짜라면 이미 gt 조건에서 걸러지기 때문에, 삭제 당일은 목록에 포함되고 그 다음 날부터 제외된다.
조건을 정리하면 이렇다:
| 상태 | endAt 값 | 오늘 목록에 포함? |
|---|---|---|
| 활성 중 | null | ✅ |
| 오늘 삭제 | 오늘 날짜 | ✅ (삭제 당일 유지) |
| 이전에 삭제 | 과거 날짜 | ❌ |
처음 고민의 시작이었던 cascade 문제로 돌아가보자.
Soft Delete를 쓰면 부모 행(Habit)이 실제로 삭제되지 않기 때문에, DB에 설정된 onDelete: Cascade가 동작하지 않는다. 덕분에 habitLogs는 안전하게 보존된다.
다만 여기서 한 가지 확인해야 할 게 있다.
Soft Delete 상태의 부모에 연결된 자식 데이터를 조회할 때, 자식 쿼리에서도 부모의 soft delete 상태를 체크하는지 확인해야 한다. 이 프로젝트에서는 항상 habitId를 기준으로 조회하고, 습관 자체의 활성 여부는 습관 목록 조회 단계에서 필터링하기 때문에 문제가 없었다.
장점만 있는 건 아니다. Soft Delete를 선택할 때 감수해야 할 것들도 있다.
DB에 데이터가 계속 쌓인다. 삭제해도 행이 남아있으니 테이블이 계속 커진다. 서비스 규모가 커지면 일정 기간이 지난 종료 데이터를 주기적으로 정리하는 배치 작업이 필요하다.
조회 쿼리마다 조건을 추가해야 한다. 깜빡하고 endAt 조건을 빠뜨리면 삭제된 데이터가 그대로 노출된다. 팀 전체가 이 규칙을 공유하고 있어야 하고, ORM 레벨에서 글로벌 스코프로 자동 적용하는 방법도 고려해볼 수 있다. Prisma에서는 middleware 또는 $extends를 활용할 수 있다.
Soft Delete가 항상 정답은 아니다.
| 상황 | 선택 |
|---|---|
| 삭제 후 복구 가능성이 있어야 할 때 | Soft Delete |
| 연관 기록(로그, 히스토리)을 보존해야 할 때 | Soft Delete |
| 삭제 시점을 추적해야 할 때 | Soft Delete |
| 단순 임시 데이터, 복구 불필요 | Hard Delete |
| DB 용량이 민감한 환경 | Hard Delete |
이 프로젝트처럼 사용자의 행동 기록이 핵심 가치인 서비스라면 Soft Delete가 더 안전한 선택이다. 데이터는 복구할 수 있지만, 한 번 날린 신뢰는 복구하기 어렵다.