웹 서버 개발을 진행할 때 자원 삭제 요청을 위한 API를 하나 이상은 꼭 만들었던 것 같아요. 예를 들면, 게시글을 삭제한다거나 댓글을 삭제하는 API가 있을 것 같아요.
입문 CRUD 강의를 위한 예제 수준에서는 DELETE 메서드를 활용하여 자원을 삭제하는 것은 어렵지 않아요. 하지만 여러 상황(개발 기간, 비즈니스 특성 등)을 고려하고 더 나은 코드를 작성하려고 하면 궁금한 부분이 생겼던 것 같아요. 항상 정답인 방식은 없겠지만, 더 나은 코드를 작성하기 위해 고민해보려고 해요.
위 이미지는 HTTP Method의 속성표에요. HTTP와 DELETE에 관련된 내용을 다루기 전 이를 제대로 이해하기 위해 Safe
, Idempotent
, Cacheable
속성을 알고 있으면 좋아요.
Safe(안전) 속성은 보안 취약성이 아닌 요청해도 자원이 변경되지 않는 성질을 의미해요.
쉽게 말해 GET 메서드는 단순히 데이터를 조회할 때 사용하기 때문에 자원을 변경하지 않아요. 더 자세히 말하면 요청에 의해 자원(데이터)가 수정되거나 삭제되지 않기 때문에 데이터 일관성으로부터 안전하다는 의미에요.
GET 메서드와 유사한 HEAD 메서드도 이 특성에 대해 안전하다고 할 수 있어요.
HEAD 메서드는 리소스를 GET 메서드로 요청했을 때 응답으로 오는 헤더부분만 요청하는 메서드에요. 즉, 응답에 본문(body)이 없으며, 있더라도 무시해요.
DELETE 메서드는 메서드명부터 안전하지 않다는 것을 알 수 있어요. (요청에 의해 자원이 삭제돼요.)
멱등법칙(冪等法則) 또는 멱등성(冪等性, 영어: idempotent)은 수학이나 전산학에서 연산의 한 성질을 나타내는 것으로, 연산을 여러 번 적용하더라도 결과가 달라지지 않는 성질을 의미한다. f(f(x)) ≡ f(x) 출처
HTTP 요청으로 대입한다면, 쉽게 말해 동일한 요청을 한 번 보내는 것이나 연속해서 보내는 것 모두 결과(상태)가 같은 경우 멱등성을 가진다고 볼 수 있어요. 또한 호출을 실행한 결과가 의미하는 것이 응답 메시지(상태 코드, 본문 등)
가 아닌 서버의 상태
라는 점도 유의해야 해요.
정리하면 멱등성의 범위를 서버의 상태로 봐야 해요. 하지만 아래와 같은 경우도 나타날 수 있어요.
서버 상태 : 식별자가 1인 아이템은 { id: 1, name: "지우개" }
A: GET items/1 요청 - 식별자가 1인 지우개(아이템) 응답
A: GET items/1 요청 - 지우개 응답
A: GET items/1 요청 - 지우개 응답
(B의 난입)
B: PUT items/1 요청 - name을 각도기로 수정
(A는 여전히 동일한 요청)
A: GET items/1 요청 - 각도기 응답
위와 같이 A는 멱등성을 가지는 GET 메서드를 사용하여 동일한 요청을 계속 보내고 있어요. 이때 중간에 B가 나타나 A의 자원을 수정했어요. 덕분에(?) A는 다른 응답을 받았어요. 그러면 멱등하지 않은 것이 아닌가?! 라고 생각할 수 있지만. 이는 외부 요인에 의한 것이기 때문에 멱등성이 깨졌다고 보지 않아요. (서버의 상태를 바꾼 것은 B의 PUT이며 A의 GET 요청은 제 역할을 수행했다고 봐요.)
외부 요인으로 생기는 문제까지 고려하면 무한한 멱등성을 보장할 수 없어요.
다시 DELETE 메서드로 돌아와서 GET이 단순 조회라면, DELETE는 단순 삭제에요. 따라서 DELETE는 멱등성을 가져요.
DELETE 메서드로 처음 요청을 보내면, 서버에서 해당 리소스는 삭제돼요. 이후 계속해서 DELETE 메서드로 요청하더라도 해당 리소스는 삭제된 상태 그대로에요. 즉, 서버의 상태는 변하지 않았어요.
멱등성은 한 번 호출하든 여러 번 호출하든 결과 상태가 같다는 의미이지, 전혀 변경이 일어나지 않거나 응답이 동일하다는 의미는 아니에요.
DELTE의 멱등성으로 인해 DELETE API를 설계할 때에는 정확한 식별자를 통해 자원을 지정해야 해요.
예시를 통해 살펴볼게요.
DELETE items/oldest
억지스럽지만, (재고 관리를 위해) 가장 오래된 아이템부터 삭제하는 API를 만들었다고 가정할게요. 아이템의 식별자를 몰라도 되고, 가장 오래된 물건부터 삭제하니 더 편한 API가 완성된 것 같아요. 하지만 해당 API를 계속해서 사용하면 매 요청마다 서버의 상태가 달라져요. (요청 기준으로 가장 오래된 아이템을 삭제하기 때문이에요.) 즉, 멱등성을 가지지 못해요.
이런 경우에는 다른 API를 만들거나 멱등성을 보장하지 않는 POST 메서드를 사용하는 것이 더 나은 방법이라고 생각해요.
물론 스펙의 모든 사항을 지키지 않아도 작동하지만 여러 사람과 코드를 작성한다면 (혹은 추후 리팩토링을 위해), 최대한 스펙에 맞추는 것이 원활한 소통을 위해 좋을 것 같아요.
Cacheable은 응답 결과 자원을 캐싱해서 효율적으로 사용할 수 있는가에 대한 여부에요.
브라우저도 캐시 공간을 가지고 있는데, 클라이언트가 서버에 한 번 요청했던 데이터에 대해 매 요청마다 다시 전송할 필요가 없도록(비용 절감) 브라우저가 임시로 데이터를 보관하고 있는 공간이에요. 즉, 캐싱이 가능한 HTTP 메소드는 빠르게 저장했던 데이터를 사용할 수도 있다는 말이에요.
다른 메서드들도 구현 내용에 따라 캐싱이 가능하지만 주로 GET, HEAD 메서드들만 사용하는 방법이라고 해요.
DELETE는 기본적으로 데이터가 변경(삭제) 되는 메서드이기 때문에, 만일 호출로 인해 데이터가 변경되게 되면 서버의 데이터가 변경돼요. 즉, 데이터 불일치 문제가 생기기 때문에 캐싱을 사용할 수 없어요.
간단하게 DELETE 메서드에 대해서 알아봤어요. 우선 이 정보를 바탕으로 자원 삭제 방식에 대해 고민해보려고 해요. 자원을 삭제할 때는 물리적 삭제
와 논리적 삭제
두 가지 방식이 있어요. 어떤 상황에서 어떤 방식을 선택해야 하는지 비교하고 고민해보려고 해요.
데이터 레코드를 삭제하는 방식은 크게 2가지가 있어요. 물리적 삭제
, 논리적 삭제
일반적으로 데이터베이스의 레코드를 삭제하는 방식을 물리적 삭제
라고 해요.
반면, 실제로 레코드를 삭제하지 않고 삭제 여부를 컬럼으로 추가하여 삭제되었는지를 값으로 채워두는 방식을 논리적 삭제
라고 해요.
물리적 삭제는 우리가 기본적으로 삭제한다는 말을 들을 때 생각할 수 있는 방식이에요.
위와 같이 주문(Orders) 테이블이 구성되어 있어요. 물리적 삭제로 구현한다면, 홍길동님이 멋진 키보드를 주문한 내용을 삭제할 때, 아래와 같이 실제로 레코드가 데이터베이스에서 제거돼요.
물리적 삭제의 장단점은 아래와 같아요.
논리적 삭제는 앞서 말한 것처럼 삭제 여부라는 이름으로 새로운 컬럼을 추가하고 실제 삭제 여부를 값으로 표현하는 방식이에요.
같은 Orders 테이블에 is_deleted
라는 컬럼을 추가했어요. false라면 아직 삭제되지 않은 레코드, 반대로 true라면 삭제된 레코드를 의미해요. 따라서 논리적 삭제로 멋진 키보드 주문을 삭제한다면 아래와 같이 테이블이 변경돼요.
-- 논리적 삭제 예제
UPDATE Orders
SET is_deleted = TRUE
WHERE order_id = 1;
그렇다면 왜 실제로 삭제하는 물리적 삭제 대신에 논리적 삭제를 사용할까요? 삭제될 레코드(데이터)가 보존 가치
가 있을 때 논리적 삭제를 사용해요. 예시를 보면 더욱 이해하기 쉬울 거에요.
어느 한 대학교의 수강신청 시스템이 있어요. 홍길동이라는 학생이 A라는 수업을 정원이 초과되기 전에 수강 신청에 성공했어요. 하지만 홍길동 학생은 생각해보니 B라는 수업이 더 듣고 싶어서 수강 신청 목록에서 A라는 수업을 삭제하고 B라는 수업을 신청했어요. 하지만 간발의 차로 인원이 초과되어 B 수업을 신청하지 못했어요. 지금이라도 다시 A 수업을 듣기 위해 재신청을 하려 했으나 그새 A도 정원이 초과되어 수강 신청을 실패한 홍길동 학생은 화가 나서 학교에 거짓으로 항의 전화를 하게 돼요.
"제가 A 수업 신청했었는데, 왜 취소되어 있나요? 복구시켜주세요."
만약 이때 삭제 전략이 물리적 삭제였다면, 실제로 테이블에는 데이터가 없기 때문에 증명하지 못하거나 오해를 하게 될 수 있어요. 반대로 논리적 삭제였다면 삭제했다는 내용이 테이블에 존재하기 때문에 신청 취소 내역을 보여주며 복구가 불가능하다고 대처할 수 있어요.
(참고: 해당 예시는 로그, 시간 등 복잡한 요소를 제거한 극단적인 예시에요.)
어떤 고객이 주문한 내역은 해당 고객의 취향 및 필요한 물품 등을 나타내는 중요한 지표에요. 따라서 고객이 주문을 취소하더라도 실제로 데이터를 제거하지 않고 수집하여 데이터 분석에 활용할 수 있을 거에요. (데이터 수집에 관한 동의 항목이 아마 존재할 거에요.)
위 예시들처럼 삭제될 레코드의 보존 가치가 있는 경우 논리적 삭제 방식을 활용할 수 있어요.
대부분의 데이터는 활용 가치가 있기 때문에 기본적으로 논리적 삭제
를 적용하는 것이 좋아보여요.
예상되는 활용 가치는 아래와 같아요.
다만, 개인정보인 데이터가 많기 때문에 활용 및 분석에 대한 동의를 받는 것이 중요해보여요. 추가적으로 디스크 사용량을 줄여야 하는 경우에는 동의한 정보 제공 기간이 지난 데이터이거나 이미 분석에 활용된 데이터는 주기적으로 직접 삭제해준다면 단점도 어느 정도 보완될 것 같아요.
@SQLDelete
, @Where
어노테이션들을 사용하여 논리적 삭제의 단점인 매번 삭제 여부 관련 조건절을 추가해야 하는 불편함도 약간 줄일 수 있을 것 같아요.일단 기본적으로 장점이 크다고 느껴지는 논리적 삭제를 도입하는 것이 좋다고 생각해요. 하지만, 그러면서 궁금한 부분이 생겼어요. 바로 어떤 HTTP 메서드를 사용해야 하는가?
에요.
크게 2가지 방식이 있다고 생각했어요. 우선 기존의 메서드인 DELETE 메서드를 사용하는 방식이에요. 클라이언트 입장에서는 삭제를 요청한 자원은 더 이상 사용할 수 없기 때문에 그대로 가도 될 것 같다고 생각했지만, 실제 수행되는 작업은 특정 필드를 수정하는 것이니 PUT, PATCH와 같이 팀 스타일에 맞게 수정 관련 메서드를 사용하는 방식도 가능할 것 같다고 생각했어요.
즉, DELETE를 그대로 사용하는 방식과 수정 관련 메서드(PUT or PATCH)를 사용하는 방식이에요.
저는 DELETE 메서드를 사용해야 한다고 결론을 지었어요.
자원(레코드)이 실제로 삭제되는 것은 아니지만 표면적으로는 삭제된 것과 마찬가지기 때문에 DELETE 메서드를 사용해야 한다. 즉, "논리적 삭제의 삭제 여부 컬럼 추가와 값 변경"은 삭제에 대한 구현 방식이기 때문에 배제하고 삭제를 위한 API이기 때문에 DELETE 메서드를 사용해야 한다.
추가적으로 다른 개발자가 봤을 때 수정 관련 메서드를 사용한다면, HTTP 메서드로 인해 오해가 생길 수 있으므로 DELETE를 사용하고 내부 구현 내용만 바꾸고 다른 개발자에게는 이를 안내하는 것이 더 좋다고 생각한다. (테이블 별로 삭제 전략을 선택하는 것도 유지보수 측면에서 좋아보이지 않는다고 생각했어요.)
삭제할 자원이 존재하지 않은 경우 어떤 응답을 해야 하는가에 대한 고민을 해봤어요.
고민하던 중 제가 잘못 이해한 부분을 발견했어요. 바로 멱등성의 범위였어요.
여러 번의 요청에도 서버의 상태(결과)가 같아야 한다는 말을 듣고 "결과는 응답(상태코드, 응답 본문 등)이다!" 라고 생각해서 아래와 같은 문제가 발생했어요.
DELETE는 멱등성이 보장되어야 하니까 삭제 요청에 대한 응답(상태 코드)도 항상 동일하게 만들어야지!
존재하지 않은 자원에 대한 삭제 요청 등을 고려하지 않고 항상 204(NO_CONTENT) 상태 코드만을 반환하도록 작성한 적이 있어요. (존재한다면 삭제될 것이며, 삭제를 다시 해도 결국 처리(삭제)되었으며, 없다는 상태는 같고, 응답도 같아야하니 204로 일관되게 응답하자!)
덕분에 프론트엔드 팀원은 실제 삭제 여부를 알지 못해서 어려움을 겪었어요...
위 상황은 응답 상태 코드가 아닌 서버 상태의 변화를 기준으로 멱등성을 고려해야 함을 잘 보여주며, 응답 상태 코드는 실제 서버의 상태 변화와 무관하게 클라이언트가 요청의 결과를 이해하는 데 필요하다고 생각했어요.
삭제 기능에서 상태 코드와는 별개로 서버의 상태(자원의 상태)는 항상 같으니 멱등성이 보장돼요. 그러면 어떻게 응답해야 할까요?
해당 자원이 존재 여부를 응답에 포함시켜야 하는가?
우선 존재 여부를 응답에 포함시킨다면 관련 상태 코드 및 메시지를 응답으로 사용할 수 있어요. 이렇게 하면 클라이언트는 삭제하려는 자원이 존재하지 않음을 명확하게 인지할 수 있고, 그 경우에 어떻게 처리할지 대비할 수 있어요.
하지만 공격자에 입장에서는 어떤 식별자를 가진 자원이 존재하지 않는다! (반대로 어떤 식별자를 가진 자원이 서버에 존재한다.) 라는 정보를 알 수 있게 돼요. (보안 취약성이 되지 않을까.. 라는 고민을 해봤어요.)
삭제된 자원을 응답 본문에 포함시켜야 하는가?
대부분의 삭제는 이미 식별자를 아는 자원을 대상으로 이뤄진다고 생각했어요. 예를 들면 DELETE orders/1
처럼요. 따라서 아래와 같은 삭제된 자원에 대한 정보를 본문에 포함하지 않아도 된다고 생각해요.
마지막으로 응답 코드에 대한 고민이 시작됐어요.
삭제할 자원이 없는 경우는 DELETE orders/1
처럼 식별자 값이 1인 주문을 삭제하려고 할 때 식별자 값이 1인 주문 레코드가 없는 경우(또는 논리적으로 없다고 판단되는 경우)를 의미해요.
RFC9110-DELETE를 번역해가면서 읽어봤어요.
DELETE 메서드가 성공적으로 적용된 경우 아래의 상태 코드를 사용한다고 해요.
202
: 요청이 처리를 위해 수락되었지만 처리가 완료되지 않았음을 나타내요.204
: 서버가 요청을 성공적으로 이행했으며, 응답 내용에 보낼 추가 내용이 없음을 나타내요.200
: 작업이 실행되었고 응답 메시지에 상태를 설명하는 표현이 포함된 경우에요.여러 논쟁도 있고, 실제로 사용한 프로젝트들도 있는 404(NOT_FOUND)에 대해서도 찾아봤어요.
404
: 원본 서버가 대상 리소스에 대한 현재 representation(표현)을 찾지 못했거나 representation이 존재한다는 사실을 공개하지 않으려 한다는 것을 나타낸다고 해요. 404
상태 코드는 이러한 representation 부족이 일시적인지 영구적인지 나타내지 않기 때문에 원본 서버가 아마도 영구적일 가능성이 높다는 것을 알고 있다면, 404
보다 410
상태 코드가 선호된다고 해요.410
: 대상 리소스에 대한 액세스가 원본 서버에서 더 이상 제공되지 않으며 이 상태가 영구적
일 가능성이 있음을 나타내요.궁금해서 찾아봤는데, 이미 비슷한 논쟁이 있어서 가져와봤어요. StackOverFlow
2XX 상태 코드
: 2xx(성공) 상태 코드는 클라이언트의 요청이 성공적으로 수신되고 이해되었으며 수락되었음을 나타내요.4XX 상태 코드
: 4xx(클라이언트 오류) 상태 코드는 클라이언트가 오류를 범한 것으로 보인다는 것을 나타내요.각 상태 코드 사용을 주장한다고 생각하고
여러 의견을 작성해보고 고민해볼게요.
2XX(성공)
상태 코드를 사용하자!DELETE orders/{id}
요청 자체는 성공적으로 수신되고 이해되었다고 보여져요. 따라서 클라이언트는 정상적으로 요청을 전송한 것이며, 2XX(성공) 상태 코드를 사용해야 한다고 생각할 수 있어요.
만약 예약 관련 도메인을 다루지 않는데, DELETE reservations/1
요청을 보낸다면 이때는 4XX 클라이언트 오류를 발생시켜야 해요. 따라서 도메인(엔티티, RESTful API에서는 URI 자체가 자원을 나타내는 경우가 많으니..)가 존재하지 않는 경우를 제외하면 지정한 자원이 없다고 해서 4XX 클라이언트 오류를 발생시키는 것은 오해를 불러일으킬 수 있다고 생각해요.
✅ DO return a 204-No Content without a resource/body for a DELETE operation (even if the URL identifies a resource that does not exist; do not return 404-Not Found) - Azure-API Guidelines/http-return-codes
정리하면 정말 존재하지 않는 URI를 사용한다면 404(NOT_FOUND)
나머지 경우는 클라이언트 입장에서 틀린 요청이 아니며, 항상 삭제된 상태일 것이니 2XX(성공)
을 사용해야 해요. 특히 204(NO_CONTENT)
를 사용하면 좋을 것 같아요.
4XX(클라이언트 오류)
상태 코드를 사용하자!대부분의 삭제 요청은 삭제할 자원을 알고 있는 상태에서 이루어져요. 따라서 대부분의 삭제 요청을 하기 전에 조회(GET) 요청이 선행되는 경우가 많아요. 여기서 핵심 개념은 자원이 있는지 확인하기 위해 DELETE를 사용하면 안된다는 것이에요. 즉, 먼저 GET을 사용하고 이후 응답이 200이면 DELETE를 수행하며, GET 이후 존재하지 않은 리소스를 DELETE 하는 것은 클라이언트 책임이라고 생각해요.
자원이 있는 경우 첫 번째 요청에서는 2XX 상태 코드를 통해 성공적으로 삭제되었음을 알리고, 이후 동일한 요청을 하는 경우에는 자원이 없기 때문에 4XX 상태 코드를 반환해야 해요. 하지만 종종 응답(본문, 상태코드 등)이 다르다는 이유로 DELETE의 멱등성이 보장되지 못했다고 말하는 사람이 있어요. 그러나 서버의 상태를 확인하면 해당 자원이 삭제된 이후 어떠한 상태도 변화되지 않았어요. 즉, 여전히 멱등하다는 것
을 말해요.
따라서 명확하게 클라이언트에게 요청한 자원이 존재하지 않음을 알릴 수 있기 때문에 4XX를 사용해야 해요. 잘못된(없는) 자원을 삭제하려고 해!
만약 삭제할 자원이 없는 경우에 204(NO_CONTENT)
를 사용한다고 가정할게요. 식별자 값이 1인 주문이 존재하지 않는 경우 DELETE orders/1
로 요청하면 204
를 반환하고, GET orders/1
로 요청하면 404(NOT_FOUND)
를 반환할 것이에요. 이는 표면적으로 어색하게 보이며, 혼란을 야기할 수 있어요.
정리하면 대부분의 삭제 요청은 GET 이후에 이루어져요. 따라서 존재하지 않는 자원을 삭제하는 경우 클라이언트 오류로 볼 수 있으며, 이를 명확히 알려야 해요. 멱등성은 서버의 상태를 고려하기 때문에 상태 코드가 달라져도 멱등성은 사라지지 않아요.
각기 다른 HTTP 스펙에 대한 이해, 비즈니스 로직, 다른 사람의 의견을 듣는 순간 등 여러 요인으로 명확한 답변은 없는 것 같아요.
Idempotency of DELETE
The DELETE method is idempotent. This implies that the server must return response code 200 (OK) even if the server deleted the resource in a previous request. But in practice, implementing DELETE as an idempotent operation requires the server to keep track of all deleted resources. Otherwise, it can return a 404 (Not Found) RESTful Web Services Cookbook: Solutions for Improving Scalability and Simplicity 11p
여러 개의 포스팅과 논쟁을 찾아보며 압축할 수 있는 단 하나의 의견은 아래와 같아요.
결국 가장 중요한 것은 가이드라인을 정의하고 그것을 사용할 팀과 일치시키는 것이에요. 핵심은 공통된 패턴을 갖고 그것을 고수하는 것이에요.
실제로 요기요 테크블로그에서는 건설적인 토론을 통해 가이드라인을 설정하는 과정을 볼 수 있었어요. (물론 자원이 없는 경우의 DELETE는 아니지만..)
추가로 2024년 7월 기준 깃허브에서 약 3.6K Star를 받은 HTTP Decision Diagram도 한번 읽어보면 도움이 될 것 같아요.
언제든 마음이 바뀔 수 있지만, 그래도 제가 직접 가이드라인을 생각해보는 것은 도움이 될 것 같아서 간단하게 정리해보려고 해요.
논리적 삭제(Soft Delete)
를 사용한다.DELETE
메서드를 사용한다.204(NO_CONTENT)
를 사용한다. (본문을 포함하지 않음)200(OK)
를 사용하고 이후에는 204(NO_CONTENT)
를 사용한다.404(NOT_FOUND)
를 사용하여 클라이언트 오류임을 명확히 표현한다.400(BAD_REQUEST)
를 사용한다.쓰다보니 설명이나 예시가 늘어진 감이 있지만.. 스펙을 번역해서 보거나 여러 아티클을 읽고 고민하다 보니 제가 어떤 것이 명확히 답이라고 판단하기 어려워서 그렇게 된 것 같아요.. 이를 계기로 여러 사람과 이야기 해보면 좋을 것 같아요. 감사합니다!