포스트

이미 지운 걸 또 지우면 에러여야 할까

DELETE는 멱등해야 한다는 HTTP 규약의 의미, 이미 없는 리소스에 200과 404 중 무엇을 줄지, 그리고 동시 삭제 경쟁을 다룬다.

이미 지운 걸 또 지우면 에러여야 할까

삭제 API를 만들다 보면 사소해 보이지만 답하기 까다로운 질문에 부딪힌다. “이미 삭제된 리소스에 다시 DELETE 요청이 오면 어떻게 응답할 것인가.” 404로 “없다”고 할지, 200으로 “삭제됐다(원하는 상태다)”고 할지. 이 선택은 멱등성이라는 HTTP의 핵심 원칙과 직결된다.

핵심 개념 — 멱등성이란 무엇인가

멱등(idempotent)하다는 건 “같은 요청을 한 번 보내든 여러 번 보내든 서버의 최종 상태가 동일하다”는 뜻이다. HTTP 명세상 GET, PUT, DELETE는 멱등해야 한다. POST는 그렇지 않다.

DELETE가 멱등이라는 건 직관적이다. “리소스 X를 삭제하라”를 한 번 실행하면 X가 없어진다. 두 번째 실행해도 결과는 같다. X는 여전히 없다. 최종 상태가 같다. 멱등성은 응답 코드가 매번 같아야 한다는 뜻이 아니라, 부수 효과로 인한 서버 상태가 같아야 한다는 뜻임에 주의하자.

이 점이 중요한 이유는 네트워크다. 클라이언트가 DELETE를 보냈는데 응답을 못 받으면(타임아웃) 재시도한다. 첫 요청이 사실 성공했다면, 재시도는 “이미 없는 리소스”를 지우려는 요청이 된다. 멱등하지 않게 설계해 두 번째 요청에서 에러를 던지면, 정상 동작인데도 클라이언트는 실패로 인식한다.

코드 예시 — 200이냐 404냐

두 가지 합리적 설계가 있고, 일관성만 지키면 둘 다 정당하다.

설계 A — 리소스 중심(404): “그 리소스가 지금 존재하지 않으면 404.” 멱등성은 서버 상태로 보장되므로, 두 번째 요청이 404를 받아도 규약 위반이 아니다.

1
2
3
4
5
6
7
@DeleteMapping("/orders/{id}")
public ResponseEntity<Void> delete(@PathVariable Long id) {
    boolean removed = orderService.delete(id); // 영향받은 행 수 > 0 ?
    return removed
            ? ResponseEntity.noContent().build()  // 204
            : ResponseEntity.notFound().build();   // 404
}

설계 B — 결과 중심(204/200): “DELETE의 목적은 ‘없는 상태’를 만드는 것이다. 이미 없다면 목적은 달성됐으니 성공이다.” 존재 여부와 무관하게 204를 반환한다.

1
2
3
4
5
@DeleteMapping("/orders/{id}")
public ResponseEntity<Void> delete(@PathVariable Long id) {
    orderService.deleteIfExists(id); // 있으면 지우고, 없어도 조용히 통과
    return ResponseEntity.noContent().build(); // 항상 204
}

설계 B는 재시도 클라이언트에게 더 친절하다. 첫 요청이든 재시도든 항상 204를 받으므로 분기 처리가 단순하다. 설계 A는 “방금 내가 지웠나, 원래 없었나”를 구분해야 하는 감사(audit) 요구가 있을 때 유용하다. 무엇을 택하든 API 전반에서 일관되게 적용하는 게 핵심이다.

동시 삭제 경쟁 — TOCTOU

흔한 안티패턴은 “존재 확인 후 삭제”를 두 단계로 나누는 것이다.

1
2
3
4
// 위험: check-then-act
if (orderRepo.exists(id)) {   // (1) 확인
    orderRepo.delete(id);     // (2) 삭제 — 사이에 다른 요청이 이미 지웠을 수 있다
}

(1)과 (2) 사이에 다른 요청이 같은 행을 지우면, (2)가 0건을 삭제하거나 예외를 던진다. 이를 TOCTOU(time-of-check to time-of-use) 경쟁이라 한다. 해법은 확인과 삭제를 하나의 원자적 쿼리로 합치는 것이다.

1
DELETE FROM orders WHERE id = ?;

DELETE는 그 자체로 “있으면 지운다”를 원자적으로 수행한다. 영향받은 행 수(affected rows)가 1이면 내가 지운 것이고, 0이면 이미 없던 것이다. 별도 SELECT 없이 이 반환값으로 분기하면 경쟁이 사라진다.

운영 함정

소프트 삭제(soft delete, deleted_at 플래그)를 쓰는 시스템에선 멱등성 정의가 더 미묘하다. 이미 deleted_at이 찍힌 행에 다시 DELETE가 오면 타임스탬프를 갱신할지 그대로 둘지 정해야 한다. 보통은 첫 삭제 시각을 보존하는 게 감사 관점에서 맞다. 또 외래 키로 참조되는 행을 하드 삭제하면 제약 위반이 터지므로, 연관 삭제 정책(CASCADE vs RESTRICT)을 명시적으로 설계해야 한다.

핵심 요약

  • 멱등성은 “여러 번 보내도 서버 최종 상태가 같다”는 뜻이다. 응답 코드가 같아야 한다는 뜻이 아니다.
  • 이미 없는 리소스에 204(결과 중심)든 404(리소스 중심)든, 일관되게만 하면 정당하다. 재시도엔 204가 친절하다.
  • “존재 확인 후 삭제”는 TOCTOU 경쟁을 만든다. 단일 DELETE 쿼리의 affected rows로 원자적으로 판단하라.
이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.