Backend 25
PATCH 부분 수정과 DELETE 응답 설계
수정 요청이면 객체 전체를 다시 보내면 될 것 같은데 왜 PATCH와 null 체크가 필요하지?
근거 · 교안 p164-p168
1장: 객체 전체를 보내는 PUT, 정말 최선일까?
솔라는 노트북 화면에 떠 있는 코드를 보며 만족스러운 미소를 지었다. 자신이 만든 작은 북클럽 애플리케이션의 회원 정보 수정 기능이었다. 데이터베이스에 저장된 사용자 정보를 불러와 수정하고, 다시 저장하는 완벽한 흐름.
“언니, 나 회원 정보 수정 기능 다 만들었어. API도 RESTful 원칙에 따라 PUT 메서드를 써서 깔끔하게 구현했다구.”
소파에 앉아 책을 읽던 루나가 고개를 들었다. 그녀의 시선이 솔라의 의기양양한 얼굴과 반짝이는 노트북 화면을 차례로 훑었다.
“그래? 잘 됐네. 그럼 한번 테스트해 볼까?”
“응! 지금 내 계정 정보가 이렇게 저장되어 있거든.”
솔라가 화면에 작은 코드 창 하나를 띄웠다.
// GET /users/1
{
"id": 1,
"name": "루나",
"email": "luna@example.com",
"statusMessage": "조용한 오후"
}
“여기서 내 ‘상태 메시지’만 한번 바꿔줘. ‘코딩은 즐거워!’라고.”
루나의 말에 솔라는 자신 있게 키보드를 두드렸다. “문제없지. statusMessage 필드만 딱 보내면 되지.”
솔라는 수정할 내용만 담은 요청을 정성껏 만들었다. 이름이나 이메일처럼 바뀌지 않는 정보까지 전부 보내는 건 비효율적이라고 생각했다.
PUT /users/1 HTTP/1.1
Host: example.com
Content-Type: application/json
{
"statusMessage": "코딩은 즐거워!"
}
솔라가 엔터 키를 누르자, 터미널에는 200 OK라는 성공 메시지가 깔끔하게 찍혔다. “성공! 완벽해.”
“정말 그럴까?” 루나가 나지막이 물었다. “수정된 정보를 다시 한번 확인해볼래?”
루나의 차분한 목소리에 솔라는 순간 의아했지만, 이내 GET 요청을 다시 보냈다. 잠시 후, 화면에 나타난 결과를 본 솔라의 얼굴이 굳었다.
// GET /users/1
{
"id": 1,
"name": null,
"email": null,
"statusMessage": "코딩은 즐거워!"
}
“어? 이게 뭐야… 이름이랑 이메일이 사라졌어. null이 됐네.”
솔라는 당황해서 코드를 다시 살폈다. “서버 로직에 버그가 있나 봐. statusMessage만 업데이트해야 하는데, 다른 필드를 날려버렸네.”
루나는 조용히 자리에서 일어나 솔라의 곁으로 다가왔다. 그녀는 솔라가 방금 보냈던 PUT 요청이 담긴 창을 손가락으로 가리켰다.
“솔라, 버그는 서버 코드가 아니라, 우리가 방금 보낸 이 요청 안에 담겨있는지도 몰라. 네가 보낸 요청은 ‘statusMessage를 이걸로 바꿔줘’라는 뜻이 아니야.”
“그럼 뭔데? 수정하라고 보낸 거잖아.”
“네가 사용한 PUT 메서드는 ‘전체 교체’를 의미해. 서버는 /users/1이라는 리소스를, 네가 본문에 담아 보낸 이 객체로 완전히 덮어쓰라는 명령으로 받아들인 거야.”
루나는 메모지를 가져와 방금 솔라가 보낸 요청 본문을 적었다. { "statusMessage": "코딩은 즐거워!" }
“서버 입장에서 생각해봐. PUT 요청을 받았는데, 본문에 name과 email 필드가 없네? 그럼 ‘아, 이 사용자의 이름과 이메일은 이제 없는 거구나. null로 만들어야겠다’라고 판단하는 거지.”
솔라는 그제야 자신이 놓친 것을 깨달았다. PUT은 부분 수정이 아니라, 리소스의 상태를 요청 본문의 내용 그대로 완전히 대체하는 것이었다. 데이터의 정합성을 지키려면 항상 완전한 데이터를 보내는 것이 안전하다고 생각했던 자신의 믿음이, 바로 이런 문제를 일으킨 원인이었다.
“아… 그럼 PUT으로 뭔가를 수정하려면, statusMessage 하나만 바꾸고 싶어도 이름, 이메일, 아이디까지 모든 정보를 항상 전부 다 담아서 보내야 한다는 거야?”
솔라의 질문에 루나는 대답 대신 희미한 미소를 지었다. 솔라는 방금 자신이 던진 질문 속에 새로운 문제의 실마리가 숨어있음을 직감했다. 부분적인 정보만 바꾸고 싶을 때, 모든 정보를 보내는 것은 분명 비효율적이고 번거로운 일이었다. 그렇다면 더 나은 방법이 있지 않을까? PUT이 아닌 다른 무언가가.
2장: PATCH의 마법: 보낸 것만 바뀐다?
솔라는 이전의 실패로 검게 변한 터미널 창을 끄지 않았다. 사라져 버린 이름과 이메일, 그리고 덩그러니 남은 상태 메시지. PUT이 남긴 흔적은 마치 ‘부분 수정’이라는 과제를 해결하지 못했다는 오답 노트처럼 보였다. 부분만 바꾸고 싶을 때 모든 정보를 보내야 한다면, 그건 너무 번거롭고 위험한 방식이었다.
솔라는 잠시 생각에 잠겼다가, 조금 전 PUT 요청을 보냈던 코드 편집기로 돌아갔다. 그녀는 망설임 없이 키보드 커서를 움직여 세 글자를 지우고, 그 자리에 다섯 글자를 새로 입력했다.
PATCH /users/1 HTTP/1.1
Host: example.com
Content-Type: application/json
{
"statusMessage": "코딩은 즐거워!"
}
PUT이 PATCH로 바뀌었다. 그게 다였다. 솔라는 코드를 빤히 쳐다보며 중얼거렸다.
“이게 답이 아닐까? 이름은 다르지만, 하는 일은 비슷해 보이는데….”
그녀는 고개를 돌려 소파에 기대앉은 루나를 불렀다.
“언니, 내가 아까 보냈던 요청에서 PUT을 PATCH로만 바꿨어. 이렇게 보내면… 서버가 똑똑하게 statusMessage만 딱 수정해 주지 않을까? 보내지 않은 name이나 email은 그대로 두고 말이야.”
솔라의 목소리에는 기대와 의심이 반쯤 섞여 있었다. 루나는 솔라의 노트북 화면을 흘깃 보더니, 옆에 있던 작은 메모 패드와 펜을 집어 들었다. 그녀는 말없이 패드 중앙에 세로줄을 하나 그어 두 개의 칸을 만들었다. 왼쪽 칸 위에 클라이언트 요청, 오른쪽 칸 위에 서버의 동작이라고 적었다.
먼저 루나는 이전 장에서 문제가 되었던 PUT 요청을 간략하게 적었다.
| 클라이언트 요청 | 서버의 동작 |
|---|---|
PUT /users/1 { "statusMessage": "..." } | /users/1의 모든 데이터를 { "statusMessage": "..." }로 완전히 교체. name, email은 요청에 없으므로 null이 됨. |
“이게 아까 우리가 확인한 PUT의 방식이었지. 전체를 덮어쓰는 것.”
루나는 펜 끝으로 솔라가 새로 고친 PATCH 코드를 가리켰다. “자, 그럼 이건 어떨까? 솔라 네가 생각하는 PATCH의 동작을 여기 오른쪽 칸에 한번 말해볼래?”
| 클라이언트 요청 | 서버의 동작 |
|---|---|
PATCH /users/1 { "statusMessage": "..." } | ? |
솔라는 잠시 숨을 고르고, 머릿속으로 서버의 입장이 되어 상상해 보았다.
“음… PATCH 요청이 들어왔네. 본문을 보니까 statusMessage 필드 하나만 있어. 그럼 이 필드의 값만 데이터베이스에서 찾아서 바꿔주는 거야. 어, 그런데 name이랑 email은 요청에 없네? 그럼… 그냥 내버려 두는 거지. 건드리지 않는 거야.”
솔라의 설명이 끝나자, 루나는 고개를 끄덕이며 오른쪽 빈칸에 솔라의 말을 그대로 요약해 적었다.
| 클라이언트 요청 | 서버의 동작 |
|---|---|
PATCH /users/1 { "statusMessage": "..." } | 요청에 포함된 statusMessage 필드만 수정. 요청에 없는 name, email 필드는 그대로 유지. |
“정확해. 그게 바로 PATCH의 핵심 원칙이야. ‘보낸 필드만 수정하고, 안 보낸 필드는 그대로 둔다.’ PUT이 리소스의 전체 상태를 교체하는 방식이라면, PATCH는 리소스의 일부 상태만 변경하는 거지.”
솔라는 환하게 웃었다. 드디어 안개가 걷히는 기분이었다. 부분 수정을 하고 싶다는 단순한 요구에 이렇게 명확하게 답을 주는 도구가 있었다니.
“와, 그럼 PUT 때문에 골치 아팠던 게 한 번에 해결되네. PATCH만 쓰면 이제 수정 요청은 문제없겠다! 그냥 ‘요청에 필드가 있으면 업데이트하고, 없으면 말고’ 이 규칙만 기억하면 되겠네.”
자신감 넘치는 솔라의 말에 루나는 미소를 지었지만, 이내 펜으로 테이블의 한 부분을 톡톡 두드리며 새로운 질문을 던졌다.
“거의 다 왔어. 그런데 한 가지 경우만 더 생각해 볼까? 만약 사용자가 ‘상태 메시지’를 아예 지워서 비워두고 싶다면, 클라이언트는 어떤 요청을 보내야 할까?”
“그야 간단하지.” 솔라는 즉시 대답했다. “statusMessage 값을 null로 해서 보내면 되잖아. 이렇게.”
솔라가 재빨리 키보드를 두드려 요청 예시를 만들었다.
{
"statusMessage": null
}
“좋아. 그럼 방금 네가 말한 ‘요청에 필드가 있으면 업데이트하고, 없으면 말고’라는 규칙을 여기에 적용해 보자. 서버는 이 요청을 받아서 statusMessage를 null로 업데이트할 거야.”
루나는 잠시 말을 멈췄다. 그녀의 시선이 솔라를 향했다.
“그런데 솔라, 아까 네가 처음 보냈던 요청, 즉 statusMessage 필드 자체가 아예 없던 요청과, 방금 네가 만든 statusMessage 필드 값으로 null을 보낸 요청. 이 둘은 서버 입장에서 어떻게 다르게 보일까? ‘필드가 없다’는 것과 ‘필드 값으로 null이 왔다’는 것은 분명 다른 의도인데, 우리가 세운 ‘필드가 있으면 업데이트한다’는 단순한 규칙만으로 이 둘을 구분해서 처리할 수 있을까?”
솔라의 표정이 다시 미묘하게 굳었다. 분명 다른 상황인데, 자신의 단순한 규칙으로는 두 경우를 똑같이 취급할 수도 있겠다는 생각이 들었다. PATCH의 원칙은 이해했지만, 그것을 실제로 구현하는 것은 생각보다 간단한 문제가 아닐지도 모른다는 새로운 의문이 고개를 들기 시작했다.
3장: 예상치 못한 null 값 처리: 4단계의 이유
솔라는 깨끗한 메모장 페이지를 두 개의 열로 나누었다. 왼쪽에는 상태 메시지 필드 없음, 오른쪽에는 상태 메시지: null. 이전 대화에서 루나가 던진 마지막 질문이 머릿속을 맴돌았다. 클라이언트가 필드를 아예 보내지 않은 의도(‘이 필드는 건드리지 마’)와, 필드에 null 값을 담아 보낸 의도(‘이 필드를 비워줘’). 서버는 이 둘을 어떻게 구분해야 할까?
솔라는 ‘요청에 필드가 있으면 업데이트한다’는 자신의 단순한 규칙을 코드로 표현하려고 애썼다. 그러나 곧 문제의 핵심에 부딪혔다. 많은 웹 프레임워크는 JSON 요청 본문을 서버의 객체(DTO, Data Transfer Object)로 자동 변환해준다. 이 과정에서 요청에 statusMessage 필드가 아예 없어도, statusMessage: null로 와도, 결과적으로 DTO 객체의 statusMessage 속성은 똑같이 null이 될 수 있었다.
솔라는 연필로 두 열 아래에 똑같은 결과를 적었다. -> 서버 DTO 객체의 statusMessage는 null. 그리고 그 옆에 커다란 물음표를 그렸다.
“이러면 안 되는데….”
솔라가 나지막이 중얼거렸다. 서버 입장에서는 두 요청이 똑같이 보이니, ‘필드 값을 null로 업데이트’하거나 ‘아무것도 안 하거나’ 둘 중 하나의 동작밖에 할 수 없었다. 두 가지 다른 의도를 모두 정확하게 처리할 방법이 보이지 않았다.
그때까지 조용히 솔라의 고민을 지켜보던 루나가 그녀의 메모장을 가리켰다. 솔라가 그려놓은 물음표를 펜 끝으로 톡 건드렸다.
“문제를 정확히 짚었네. 우리가 서버에게 너무 적은 정보만 보도록 강요하고 있는 거야. 최종적으로 변환된 DTO 객체의 ‘값’만 봐서는, 원래 요청에 ‘키’가 있었는지 없었는지 알 수 없지.”
루나는 새로운 메모지를 가져와 솔라 앞에 놓았다. 그리고 간결한 질문을 하나 적었다.
서버가 PATCH 요청을 안전하게 처리하기 위한 규칙은?
“우리가 직접 서버가 되어서, 규칙을 하나씩 만들어보자. 아주 깐깐하고 보수적인 서버라고 생각하고.”
솔라는 잠시 생각에 잠겼다가, 첫 번째 규칙을 입에 올렸다.
“일단… 요청에 필드 자체가 포함되지 않았으면, 절대 그 필드의 데이터를 건드리면 안 돼. 이게 제일 중요해.”
“좋아. 1단계 규칙이네.” 루나가 솔라의 말을 받아 적었다.
1단계: 요청에 필드가 포함되었는가? (만약 아니라면, 해당 필드에 대해선 더 이상 아무것도 하지 않는다.)
“그럼, 1단계를 통과했다고 치자. 예를 들어 statusMessage 필드가 요청에 들어왔어. 그다음은?”
“그다음엔… 들어온 값으로 업데이트하면 되지. 아, 잠깐. 만약 이상한 값이 들어오면 어떡하지? 상태 메시지에 10,000자가 넘는 글이 들어온다거나.”
“좋은 지적이야. 업데이트하기 전에 값이 유효한지 확인하는 단계가 필요하겠네.”
루나가 두 번째 규칙을 추가했다.
2단계: 필드 값이 유효성 검사를 통과하는가? (통과하지 못하면, 에러를 반환하고 중단한다.)
“좋아. 이제 유효한 값만 남았어. statusMessage 필드가 요청에 포함됐고, 값도 괜찮아. 그럼 이제 바로 데이터베이스를 업데이트하면 될까?” 루나가 물었다.
솔라는 고개를 끄덕이려다 잠시 멈칫했다. “음… 꼭 그래야만 할까? 만약 원래 데이터베이스에 저장된 값이 ‘안녕하세요’인데, 요청으로 또 ‘안녕하세요’가 들어왔다면? 굳이 똑같은 값으로 또 업데이트할 필요는 없잖아. 데이터베이스에 불필요한 쓰기 작업을 하는 거니까.”
루나의 입가에 옅은 미소가 번졌다. “효율성까지 고려하는구나. 맞아. 불필요한 작업을 줄이는 것도 중요하지.”
3단계: 기존 데이터와 새로운 데이터가 다른가? (만약 같다면, 아무것도 하지 않고 성공으로 처리한다.)
루나가 세 번째 규칙을 적었다. 이제 정말 마지막 단계만 남은 듯했다.
“자, 이제 최종 단계야. 요청에 필드가 포함됐고, 값은 유효하고, 심지어 기존 값과 달라. 이 모든 관문을 통과했다면 서버는 뭘 해야 할까?”
“이제는 확실하지! 데이터베이스의 값을 새로운 값으로 업데이트한다!” 솔라가 자신 있게 외쳤다.
루나는 솔라의 대답을 마지막 규칙으로 완성했다.
4단계: 데이터를 새로운 값으로 업데이트한다.
메모지에는 네 개의 단계가 명확하게 정리되어 있었다.
- 포함 여부 확인: 요청에 필드가 있는가?
- 유효성 검증: 값이 유효한가?
- 변경 여부 확인: 기존 값과 다른가?
- 업데이트 실행: 모든 관문을 통과했다면, 업데이트한다.
솔라는 이 네 단계의 규칙을 물끄러미 바라보았다. 자신이 처음에 생각했던 ‘필드가 있으면 업데이트한다’는 단순한 규칙이 얼마나 많은 허점을 가지고 있었는지 깨달았다. 특히 1단계, ‘포함 여부 확인’이야말로 null을 의도적으로 넣는 것과 필드를 아예 보내지 않는 두 가지 다른 의도를 구분하는, 가장 중요한 첫 관문이라는 것을 말이다.
4장: 삭제 요청의 완성: 204 No Content
솔라의 책상 위, 이전 장에서 루나와 함께 정리했던 메모지가 놓여 있었다. PATCH 요청을 안전하게 처리하기 위한 4단계 규칙. 솔라는 그 메모를 흘깃 보고는 만족스럽게 고개를 끄덕였다. 복잡해 보였던 부분 수정의 원리가 이제는 명확한 지침으로 손에 잡혔다. 그녀는 곧바로 다음 기능 구현으로 넘어갔다. 바로 북클럽 회원의 ‘탈퇴’ 기능이었다.
메모지 옆에 새로 펼친 노트북 화면에는 UserController 파일이 열려 있었다. 솔라의 손가락이 경쾌하게 키보드를 오갔다. DELETE HTTP 메서드에 연결될 API 엔드포인트를 만드는 것은 익숙한 작업이었다. @DeleteMapping("/users/{id}"). 서비스 로직을 호출해 데이터베이스에서 해당 사용자를 삭제하는 코드도 금방 완성했다. 이제 남은 것은 서버가 클라이언트에게 어떤 응답을 돌려줄지 결정하는 마지막 한 줄이었다.
// ... user delete logic ...
return new ResponseEntity<>(HttpStatus.OK); // 이게 맞나?
솔라는 200 OK를 의미하는 코드를 적어놓고는 잠시 손가락을 멈췄다. 당연히 성공했으니 200 OK라고 생각했지만, 무언가 찜찜한 기분이 가시지 않았다.
“성공적으로 삭제했으면, 응답 본문에는 뭘 보내주지? 삭제된 사용자 정보를 보내줄 수도 없고… 그냥 ‘삭제 성공’ 같은 메시지? 이것도 좀 이상한데.”
클라이언트 입장에서는 DELETE 요청을 보냈고, 성공 응답 코드만 받으면 임무가 완수된 것이다. 굳이 응답 본문을 열어서 “삭제되었습니다”라는 메시지를 확인할 필요가 있을까? 오히려 불필요한 데이터를 보내는 낭비처럼 느껴졌다. 솔라는 주석을 달고 코드를 빤히 바라보며 중얼거렸다.
“그냥 ‘성공했어. 그리고 줄 건 없어.‘라고만 알려줄 수는 없나?”
그때, 소파에 앉아 있던 루나가 솔라 쪽을 보며 물었다.
“고민하는 목소리가 여기까지 들리네. 이번엔 어떤 매듭이 엉켰어?”
솔라는 기다렸다는 듯이 자신의 화면을 가리켰다. “회원 삭제가 성공했을 때의 응답이야. 200 OK를 보내면 될 것 같은데, 클라이언트에게 딱히 줄 내용이 없으니까 응답 본문이 비게 되잖아. 이게 맞는 방식인지 모르겠어. 예전에 어디서 204라는 코드를 본 것 같기도 한데, 그건 ‘콘텐츠 없음’이라는 뜻 아니었나? 실패했다는 신호 같아서 쓰기 꺼려져.”
루나는 솔라의 말을 듣고는, 테이블 위에 놓여있던 빈 컵 두 개를 가져왔다. 하나는 솔라 앞에, 다른 하나는 자신 앞에 놓았다.
“이 컵이 클라이언트고, 내가 서버라고 해보자.”
루나는 솔라 앞의 컵(클라이언트)에서 작은 메모지 조각 하나를 꺼내 자신 앞의 컵(서버)으로 옮겼다. “네가 나에게 ‘이 메모지를 폐기해 줘’라는 DELETE 요청을 보냈어. 나는 요청대로 메모지를 잘게 찢어서 버렸지.”
루나는 말을 이으며 두 가지 상황을 연출했다.
“첫 번째 시나리오. 내가 빈 종이를 한 장 꺼내 ‘요청하신 메모지는 정상적으로 폐기되었습니다’라고 적어서 다시 네 컵에 넣어줬어. 너는 그 종이를 받아서 내용을 확인하고, ‘아, 폐기됐구나’라고 인지한 뒤 그 종이도 버리겠지. 이게 200 OK와 함께 메시지를 보내는 방식이야.”
솔라는 고개를 끄덕였다. 자신이 생각했던 방식과 정확히 같았다.
“두 번째 시나리오.” 루나가 말을 이었다. “나는 네 메모지를 폐기한 뒤, 너에게 아무것도 돌려주지 않았어. 그냥 내 일을 마쳤다는 의미로 고개만 한번 끄덕였지. 네 컵은 비어있는 그대로야. 너는 내가 무언가를 돌려주지 않았다는 사실 자체로 ‘아, 내 요청이 문제없이 처리됐구나’라고 이해하는 거야.”
루나는 솔라 앞의 빈 컵을 가리켰다.
“이게 바로 204 No Content의 의미야. ‘요청은 성공적으로 처리했고, 그래서 알려줄 내용은 더 이상 없다’는 아주 명확한 성공 신호지. 실패나 에러가 아니야.”
솔라의 눈이 동그래졌다. 204라는 숫자에 막연히 품고 있던 ‘없다=실패’라는 이미지가 깨지는 순간이었다.
“아…! 그러니까 204는 ‘비어있는 성공’ 같은 거구나. 삭제 요청은 리소스를 없애는 게 최종 목표니까, 성공하면 돌려줄 게 없는 게 당연하네. 그래서 오히려 204가 더 의미에 딱 맞는 응답이었던 거야.”
솔라는 자신이 적었던 return new ResponseEntity<>(HttpStatus.OK); 코드를 망설임 없이 지웠다. 그리고 그 자리에 새로운 코드를 채워 넣었다.
return ResponseEntity.noContent().build();
코드가 훨씬 간결하고 명확해졌다. 200 OK는 응답 본문에 무언가 유의미한 정보가 있을 때 사용하는 것이고, DELETE처럼 성공의 결과가 ‘사라짐’ 그 자체인 경우에는 204 No Content가 가장 정직하고 효율적인 응답이라는 것을 깨달았다. 바이트를 아끼는 차원을 넘어, 약속된 신호로 더 정확하게 소통하는 방법이었다.
“이제 PATCH로 필요한 것만 고치는 법도, DELETE를 깔끔하게 마무리하는 법도 알겠어.”
솔라는 자신감이 붙은 목소리로 말했다. 그러나 곧 새로운 질문이 떠올랐다.
“그런데 언니, 그럼 실제 API를 설계할 때… 사용자의 프로필 전체를 바꾸는 상황이라면 PUT을 쓰고, 상태 메시지만 바꾸면 PATCH를 쓰는 거잖아. 이런 판단은 어떤 기준으로 하는 거야? 기능 요구사항을 보고 내가 그냥 정하면 되는 걸까?”
부분 수정을 위한 PATCH와 깔끔한 삭제 응답 204. 개별 도구의 사용법은 익혔지만, 이제는 여러 도구들을 언제 어떻게 조합해 하나의 잘 설계된 시스템을 만들어야 하는지에 대한 더 큰 그림이 궁금해지기 시작했다.
5장: RESTful API에서 PATCH와 DELETE의 현명한 사용
솔라는 깨끗하게 정리된 책상 위, 노트북 옆에 놓인 작은 메모 카드 한 장을 내려다보고 있었다. 조금 전 루나가 건네준 가상의 ‘API 설계 요구사항’이었다. 개별 도구의 사용법은 익혔지만, 여러 도구를 언제 어떻게 조합해야 할지 궁금해하는 솔라를 위한 작은 과제인 듯했다. 카드에는 간결한 네 가지 요구사항이 적혀 있었다.
새로운 책 관리 API 요구사항
- 사용자가 책의 ‘읽음 상태’(
isRead)만 빠르게 변경할 수 있어야 한다. - 사용자가 책을 서재에서 완전히 ‘삭제’할 수 있어야 한다.
- 사용자가 책의 ‘제목’과 ‘저자’를 동시에 수정할 수 있어야 한다.
- 기존 책 정보를 완전히 새로운 정보로 ‘전체 교체’할 수 있어야 한다.
솔라는 이 네 개의 문장을 보며 이전 장들에서 겪었던 일들을 떠올렸다. PUT으로 일부만 수정하려다 데이터가 날아갔던 경험, PATCH의 부분 수정 원칙과 null 처리의 함정, 그리고 DELETE 후의 깔끔한 204 No Content 응답까지. 이제는 그 지식들을 이용해 이 요구사항들을 실제 API 설계로 번역해야 했다. PATCH는 부분 수정, DELETE는 삭제. 개념은 알지만 막상 설계자의 입장이 되니 어떤 기준으로 결정을 내려야 할지 막막했다.
“음… 어떤 기능에 어떤 메서드를 써야 할지, 이게 바로 실전이구나.”
솔라가 나지막이 중얼거리자, 맞은편에서 커피를 마시던 루나가 말없이 새 메모지를 꺼내왔다. 그녀는 메모지에 간단한 표를 그리기 시작했다. 요구사항, HTTP 메서드, 요청 URI, 성공 응답이라는 네 개의 열이 만들어졌다.
“네가 방금 본 요구사항들을 가지고 이 표를 한번 채워보는 거야. 네가 API 설계자라고 생각하고.”
루나의 말에 솔라는 연필을 집어 들었다. 그녀는 가장 명확해 보이는 것부터 시작하기로 했다.
‘2번, 책 삭제. 이건 쉽지.’ 솔라는 망설임 없이 표를 채워나갔다. DELETE, /books/{id}, 204 No Content. 삭제 요청의 성공은 더 이상 돌려줄 콘텐츠가 없음을 의미한다는, 지난번의 깨달음이 손끝을 자신감 있게 만들었다.
다음은 1번, ‘읽음 상태’만 변경하는 기능. “이것도 명확해. 책의 여러 정보 중 딱 하나만 바꾸는 거니까, 이건 PATCH의 역할이야.” 솔라는 PATCH, /books/{id}, 그리고 응답 코드 칸에는 200 OK라고 적었다. 부분 수정 후에는 변경된 리소스의 최신 상태를 클라이언트에게 보여주는 것이 일반적이기 때문이었다.
이제 남은 것은 3번과 4번. 솔라의 연필 끝이 잠시 허공에서 멈췄다.
“4번, ‘전체 교체’는 PUT의 정의랑 정확히 일치해. 클라이언트가 보낸 데이터로 리소스 전체를 덮어쓰는 거니까. 그럼 PUT, /books/{id}, 200 OK.”
이제 마지막 3번, ‘제목’과 ‘저자’를 동시에 수정하는 기능만 남았다. 솔라는 고개를 갸웃거렸다. “제목과 저자만 수정하는 거니까 이것도 ‘부분’ 수정이잖아? 그럼 PATCH인가? 그런데… 4번 ‘전체 교체’ 기능이 있는데, 굳이 또 다른 수정 기능을 PATCH로 만들어야 할 이유가 있을까?”
솔라의 고민을 읽은 듯, 루나가 질문을 던졌다.
“클라이언트, 즉 사용자의 입장에서 한번 생각해 봐. 4번 기능인 ‘전체 교체’는 어떤 화면에서 사용될까? 그리고 1번 기능인 ‘읽음 상태 변경’은 어떤 화면에서 쓰일까?”
루나의 질문은 기술적인 메서드의 정의가 아닌, 사용자의 경험에 초점을 맞추고 있었다. 솔라는 눈을 감고 상상해 보았다. ‘전체 교체’는 아마 ‘책 정보 상세 수정’ 페이지 같은 곳에서, 책의 제목, 저자, 출판일 등 모든 필드가 입력 폼에 채워져 있는 화면일 것이다. 사용자는 거기서 여러 정보를 바꾸고 ‘저장’ 버튼을 누를 것이다. 이 경우, 클라이언트는 폼에 있는 모든 데이터를 서버에 보내는 것이 자연스럽다.
반면, ‘읽음 상태 변경’은 책 목록 페이지에서 책 옆에 있는 작은 토글 버튼을 클릭하는 동작일 것이다. 그 작은 상호작용 하나를 위해 책의 모든 정보를 불러와서 다시 보낼 필요는 전혀 없었다.
“아…!” 솔라는 무언가 깨달은 듯 눈을 떴다. “그러니까 PUT은 사용자가 리소스의 ‘전체 모습’을 새로 정의해서 보내는 상황에 어울리고, PATCH는 리소스의 ‘일부 속성’만 가볍게 바꾸는 상호작용에 적합한 거구나!”
정체성을 바꾸는 것과 행동을 바꾸는 것의 차이와도 같았다. PUT은 리소스의 정체성 자체를 새로운 것으로 교체하는 느낌이라면, PATCH는 그 리소스의 현재 상태나 행동 양식을 살짝 바꾸는 느낌이었다.
“그럼 3번 요구사항, ‘제목과 저자 수정’은 PATCH로 만드는 게 맞겠네. 사용자가 이 두 가지만 수정하고 싶을 수도 있으니까. PUT으로 하려면 바뀌지 않는 다른 정보까지 전부 보내야 하는 불편함이 다시 생길 거야.”
솔라는 확신을 가지고 표의 마지막 칸을 채웠다.
| 요구사항 | HTTP 메서드 | 요청 URI | 성공 응답 |
|---|---|---|---|
| 1. 읽음 상태 토글 | PATCH | /books/{id} | 200 OK |
| 2. 책 삭제 | DELETE | /books/{id} | 204 No Content |
| 3. 제목/저자 수정 | PATCH | /books/{id} | 200 OK |
| 4. 전체 정보 교체 | PUT | /books/{id} | 200 OK |
완성된 표는 더 이상 단순한 기술 명세가 아니었다. 각 요구사항 뒤에 숨어있는 사용자의 의도와 경험을 존중하는 설계도처럼 보였다. 어떤 메서드를 선택할지는 단순히 기술적 제약이 아니라, API가 제공할 사용성의 문제이기도 했다.
솔라는 자신의 북클럽 애플리케이션 코드로 돌아갔다. 처음엔 막연히 PUT 메서드 하나로 모든 수정을 처리하려 했던 UserController. 이제는 그 설계가 얼마나 투박했는지 명확히 보였다. 솔라는 주석으로 새로운 API 설계의 청사진을 그리기 시작했다.
// UserController.java
// 사용자가 프로필 편집 페이지에서 모든 정보를 수정하고 저장할 때.
// 자원의 전체 교체를 의도하므로 PUT을 사용한다.
@PutMapping("/users/{id}")
public ResponseEntity<User> updateUserProfile(...) { ... }
// 사용자가 자신의 상태 메시지만 간편하게 변경할 때.
// 자원의 일부만 변경하므로 PATCH를 사용한다. null 처리에 유의!
@PatchMapping("/users/{id}")
public ResponseEntity<User> updateUserStatusMessage(...) { ... }
// 사용자가 계정을 탈퇴할 때.
// 자원을 삭제하며, 성공 시 별도의 본문을 반환할 필요가 없으므로 204 No Content를 사용.
@DeleteMapping("/users/{id}")
public ResponseEntity<Void> deleteUser(...) { ... }
스스로 작성한 주석과 코드를 보며 솔라는 미소 지었다. PUT, PATCH, DELETE. 이제 이들은 더 이상 헷갈리는 약어가 아니었다. 각자의 역할과 자리가 명확한, 잘 짜인 도구 상자 속 연장들이었다. 어떤 연장을 언제 꺼내 써야 할지, 이제는 분명히 알 것 같았다.