Soft Delete, 언제 그리고 어떻게 사용할까?

Obebe·2026년 4월 29일

React

목록 보기
11/12
post-thumbnail

왜 이 글을 쓰게 됐나

습관 관리 프로젝트에서 "습관 삭제" 기능을 구현하다가 뜻밖의 고민에 빠졌다. 처음엔 당연히 DELETE 쿼리를 쓰면 된다고 생각했는데, 막상 구현하려고 보니 두 가지 문제가 걸렸다.

첫 번째. 습관을 삭제하면 연결된 체크 기록(habitLogs)이 cascade로 함께 날아간다. 사용자가 한 달 동안 쌓아온 기록이 습관 하나 삭제로 전부 사라지는 건 말이 안 됐다.

두 번째. UX 문제였다. 오늘 아침에 이미 체크한 습관을 낮에 삭제했는데 목록에서 즉시 사라지면 어색하다. "삭제했으니 오늘은 유지되고, 내일부터 안 보이는" 동작이 훨씬 자연스럽다.

이 두 가지 이유로 Soft Delete를 선택했다.


Hard Delete vs Soft Delete

먼저 개념부터 정리하자.

Hard DeleteDELETE 쿼리로 DB에서 행을 완전히 제거하는 방식이다. 구현이 단순하고 DB를 깔끔하게 유지할 수 있다. 하지만 한 번 지우면 복구가 어렵고, 연관 데이터까지 함께 사라질 수 있다.

Soft Delete는 실제로 삭제하지 않고 "삭제됐다"는 표시만 남기는 방식이다. 행은 DB에 그대로 있고, deletedAt이나 isDeleted 같은 필드로 상태를 관리한다.


스키마 설계: isDeleted가 아니라 endAt을 선택한 이유

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 대신 UPDATE

실제 삭제 로직은 단순하다. 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는 어떻게 됐을까?

처음 고민의 시작이었던 cascade 문제로 돌아가보자.

Soft Delete를 쓰면 부모 행(Habit)이 실제로 삭제되지 않기 때문에, DB에 설정된 onDelete: Cascade가 동작하지 않는다. 덕분에 habitLogs는 안전하게 보존된다.
다만 여기서 한 가지 확인해야 할 게 있다.

Soft Delete 상태의 부모에 연결된 자식 데이터를 조회할 때, 자식 쿼리에서도 부모의 soft delete 상태를 체크하는지 확인해야 한다. 이 프로젝트에서는 항상 habitId를 기준으로 조회하고, 습관 자체의 활성 여부는 습관 목록 조회 단계에서 필터링하기 때문에 문제가 없었다.


Soft Delete의 단점도 솔직하게

장점만 있는 건 아니다. Soft Delete를 선택할 때 감수해야 할 것들도 있다.

DB에 데이터가 계속 쌓인다. 삭제해도 행이 남아있으니 테이블이 계속 커진다. 서비스 규모가 커지면 일정 기간이 지난 종료 데이터를 주기적으로 정리하는 배치 작업이 필요하다.

조회 쿼리마다 조건을 추가해야 한다. 깜빡하고 endAt 조건을 빠뜨리면 삭제된 데이터가 그대로 노출된다. 팀 전체가 이 규칙을 공유하고 있어야 하고, ORM 레벨에서 글로벌 스코프로 자동 적용하는 방법도 고려해볼 수 있다. Prisma에서는 middleware 또는 $extends를 활용할 수 있다.


언제 써야 할까?

Soft Delete가 항상 정답은 아니다.

상황선택
삭제 후 복구 가능성이 있어야 할 때Soft Delete
연관 기록(로그, 히스토리)을 보존해야 할 때Soft Delete
삭제 시점을 추적해야 할 때Soft Delete
단순 임시 데이터, 복구 불필요Hard Delete
DB 용량이 민감한 환경Hard Delete

이 프로젝트처럼 사용자의 행동 기록이 핵심 가치인 서비스라면 Soft Delete가 더 안전한 선택이다. 데이터는 복구할 수 있지만, 한 번 날린 신뢰는 복구하기 어렵다.

profile
다른 건 노력의 시간

0개의 댓글