RSS듀오랩스
데이터베이스 설계

소프트 삭제(deletedAt)의 비용과 대안

작성자
듀오랩스 대표·7분 읽기

품목 코드 RM-001 을 잘못 만들어서 지웠다고 해 보겠습니다. 소프트 삭제라 행은 남고 deletedAt 에 시각이 찍힙니다. 이제 같은 코드로 품목을 다시 만들려고 하면 오류가 납니다.

Unique constraint failed on the fields: (`companyId`,`code`)

화면에서는 지워진 품목이 보이지 않는데, 데이터베이스는 그 코드가 아직 쓰이고 있다고 말합니다.

deletedAt 하나면 안전하다는 생각

물리 삭제는 되돌릴 수 없으니, 행을 지우지 않고 deletedAt 칸에 시각을 찍는 소프트 삭제가 흔히 안전한 기본값으로 쓰입니다. 실수로 지워도 되살릴 수 있고, 지난 기록이 가리키는 대상도 사라지지 않습니다.

그 장점은 사실입니다. 다만 소프트 삭제는 칸 하나를 더하는 일로 끝나지 않습니다. 데이터베이스는 deletedAt 이 무슨 뜻인지 모릅니다. 유일 제약, 외래 키, 조회 조건이 모두 「지워진 행도 살아 있는 행」으로 취급합니다. 앞의 오류가 그 첫 증상입니다.

삭제라고 부르는 세 가지 다른 일

삭제 전략을 고르기 전에, 삭제라는 말에 섞여 있는 일을 나눠 보면 좋습니다.

방식 맞는 대상
물리 삭제 행을 없앤다 잘못 만든 초안, 다른 기록이 가리키지 않는 것
사용 중지 행은 그대로, 새 입력의 선택지에서만 뺀다 품목, 거래처, 공통 코드처럼 지난 기록이 가리키는 기준정보
소프트 삭제 행은 그대로, 모든 화면에서 없는 것처럼 다룬다 휴지통과 복원이 기능으로 필요한 것

사용 중지와 소프트 삭제는 비슷해 보이지만 뜻이 다릅니다. 사용 중지된 거래처는 지난 전표에서 이름 그대로 보여야 합니다. 거래를 끊었을 뿐 존재했던 사실은 그대로입니다. 소프트 삭제는 「애초에 없었던 것처럼」에 가깝습니다. 업무 시스템에서 사람들이 「삭제」라고 부르는 것의 상당수는 실제로는 사용 중지라고 봅니다.

유일 제약과 부분 유일 인덱스

앞의 코드 충돌로 돌아가겠습니다. 원하는 규칙은 「지워지지 않은 품목들 사이에서만 코드가 겹치면 안 된다」입니다. 일반 유일 제약으로는 이 규칙을 표현할 수 없습니다.

PostgreSQL 에서는 부분 인덱스를 유일 인덱스로 만들어 표현합니다.

CREATE UNIQUE INDEX item_code_alive ON "Item" ("companyId", code) WHERE "deletedAt" IS NULL;

WHERE 에 맞는 행만 인덱스에 들어가므로, 지워진 행은 유일성 검사에서 빠집니다. 같은 코드의 지워진 품목이 몇 개 있든 살아 있는 품목은 하나만 허용됩니다.

대신 새로운 질문이 생깁니다. 지워진 RM-001 을 복원하려는데 그사이 새 RM-001 이 생겼다면 어떻게 할지입니다. 이 질문에 답이 없으면 복원 버튼이 오류를 냅니다. 소프트 삭제를 고르는 것은 이런 질문들에 답하기로 하는 것입니다.

사용 중지는 이 문제가 없습니다. 사용 중지된 품목도 그 코드를 계속 가지는 것이 맞으므로, 일반 유일 제약이 그대로 맞는 규칙입니다.

외래 키가 보지 못하는 삭제

소프트 삭제된 거래처를 가리키는 전표는 외래 키 입장에서 정상입니다. 행이 있으니까요. 그래서 Restrict 로 「전표가 있는 거래처는 못 지운다」를 걸어 두었어도, 소프트 삭제는 그 제약을 통과합니다. 행을 지우지 않고 칸 하나를 고쳤을 뿐이기 때문입니다.

결과적으로 지워진 거래처에 새 전표가 붙을 수 있고, 지워진 품목이 새 수주에 들어갈 수 있습니다. 이것을 막는 일은 데이터베이스가 아니라 앱의 몫이 됩니다. 저장할 때마다 가리키는 대상이 지워지지 않았는지 확인해야 합니다.

물리 삭제와 외래 키의 조합은 반대로 데이터베이스가 지켜 줍니다. Restrict 는 가리키는 기록이 있으면 삭제를 막고, Cascade 는 같이 지웁니다. 어느 쪽을 고를지의 기준은 지워지는 쪽이 밖으로 나간 기록인지입니다.

모든 조회에 붙어야 하는 조건

소프트 삭제의 가장 큰 비용은 조회입니다. 목록, 검색, 드롭다운, 집계, 대시보드, 내보내기까지 모든 조회에 deletedAt IS NULL 이 붙어야 합니다. 하나라도 빠지면 지워진 행이 그 화면에만 나타납니다. 집계에서 빠지면 숫자가 틀리는데, 틀린 줄 알아채기도 어렵습니다.

그래서 소프트 삭제를 쓴다면 조건을 한 곳에서 붙이는 장치를 함께 두는 것이 사실상 필수라고 봅니다. ORM 의 확장 기능으로 모든 조회에 조건을 넣거나, 살아 있는 행만 보여 주는 뷰를 만들어 앱이 그 뷰만 읽게 하는 방법이 있습니다. 어느 쪽이든 원시 SQL 로 짠 조회는 그 장치를 거치지 않는다는 점을 기억해야 합니다.

부분 인덱스는 여기서도 쓰입니다. 조회가 늘 deletedAt IS NULL 을 거는데 인덱스에 지워진 행까지 들어 있으면 그만큼 크고 느립니다. 자주 쓰는 인덱스에 같은 조건을 걸면 살아 있는 행만 담깁니다.

기록이 목적이라면 이력 테이블

소프트 삭제를 고르는 이유가 「누가 언제 지웠는지 남기고 싶어서」라면, 다른 방법이 더 맞을 수 있습니다. 행은 물리 삭제하고, 지우기 직전의 내용을 이력 테이블이나 감사 로그에 남기는 방식입니다.

이렇게 하면 원래 테이블은 살아 있는 행만 가지므로 유일 제약, 외래 키, 조회 조건이 모두 평범하게 동작합니다. 복원은 이력에서 행을 다시 만드는 일이 됩니다. 복원이 드문 기능이라면 모든 조회에 조건을 붙이는 비용보다 이쪽이 싸다고 생각합니다.

대상마다 고르는 기준

저라면 이 순서로 고르겠습니다.

  1. 다른 기록이 이것을 가리키는가. 아니라면 물리 삭제로 충분합니다.
  2. 가리킨다면, 지난 기록에서 이 대상이 계속 보여야 하는가. 그렇다면 사용 중지입니다. 기준정보 대부분이 여기 해당합니다.
  3. 사용자에게 휴지통과 복원이 기능으로 필요한가. 그렇다면 소프트 삭제를 고르고, 부분 유일 인덱스와 조회 조건 장치를 같이 만듭니다.
  4. 남기고 싶은 것이 삭제 기록뿐인가. 그렇다면 물리 삭제와 이력 테이블을 검토합니다.

한 시스템 안에서도 대상마다 답이 다릅니다. 전 테이블에 deletedAt 을 일괄로 붙이는 것은 이 질문을 대상마다 하지 않겠다는 선택이고, 그 비용은 모든 조회가 나눠 냅니다.

여기까지가 확실한 부분

부분 유일 인덱스는 PostgreSQL 문서에 나오는 기능입니다. MySQL 은 부분 인덱스를 지원하지 않아서, 같은 규칙을 표현하려면 생성 칸을 이용하는 등 다른 방법을 써야 합니다. ORM 의 조회 조건 자동화는 도구마다 방식과 한계가 달라서, 원시 SQL 이나 집계 함수까지 적용되는지는 쓰는 도구의 문서에서 확인해야 합니다.

마지막 수정:

공유하실 때는 출처(Duolabs)와 원문 주소를 표시해 주세요.