HTTP 메서드 완벽 가이드: PUT과 PATCH의 차이, DELETE body의 함정

웹 개발을 하다 보면 GET과 POST는 금방 익숙해지는데, PUT과 PATCH의 차이를 물어보면 "둘 다 수정 아닌가요?"라며 얼버무리게 되곤 합니다. DELETE에 body를 보낼 수 있는지도 은근히 헷갈리고요.
이 글에서는 HTTP 메서드 다섯 가지를 RFC 스펙부터 실무까지 다룹니다. PUT과 PATCH가 정확히 뭐가 다른지, DELETE body는 왜 무시될 수 있는지까지 짚어보겠습니다.
HTTP 메서드란
HTTP 메서드는 클라이언트가 서버에 "이 요청으로 뭘 해달라"고 알려주는 동사(verb)입니다. REST API에서는 리소스에 대한 CRUD 동작을 HTTP 메서드로 표현하죠.
| 메서드 | 의미 | CRUD 매핑 | 멱등성 | 안전성 |
|---|---|---|---|---|
| GET | 조회 | Read | O | O |
| POST | 생성 | Create | X | X |
| PUT | 전체 교체 | Update (전체) | O | X |
| PATCH | 부분 수정 | Update (부분) | X* | X |
| DELETE | 삭제 | Delete | O | X |
*PATCH는 구현 방식에 따라 멱등성이 보장될 수도, 그렇지 않을 수도 있습니다. 이건 뒤에서 자세히 다룹니다.
여기서 두 가지 용어를 짚고 넘어가겠습니다.
멱등성(Idempotency): 같은 요청을 여러 번 보내도 결과가 같다는 뜻입니다. DELETE /users/1을 두 번 보내면, 첫 번째는 삭제하고 두 번째는 이미 없으니 404가 나올 수 있지만, 서버의 최종 상태는 동일합니다. 이게 멱등입니다.
안전성(Safety): 요청이 서버 상태를 변경하지 않는다는 뜻입니다. GET은 안전하고, POST/PUT/PATCH/DELETE는 안전하지 않습니다.
GET: 데이터 조회
GET은 가장 기본적인 메서드로, 리소스를 읽어올 때 사용합니다.
GET /api/users/42 HTTP/1.1 Host: api.example.com Accept: application/json
HTTP/1.1 200 OK Content-Type: application/json { "id": 42, "name": "강건우", "email": "kang@example.com", "role": "STUDENT" }
GET에서 알아둘 것들
1. 요청 body를 쓰면 안 됩니다
RFC 9110에서는 GET 요청에 body를 포함하는 것을 다음과 같이 설명합니다:
A client SHOULD NOT generate content in a GET request unless it is made directly to an origin server that has previously indicated, in or out of band, that such a request has a purpose and will be adequately supported.
실무에서 GET에 body를 넣으면 어떤 일이 벌어지냐면요:
- 대부분의 HTTP 클라이언트 라이브러리가 body를 무시하거나 에러를 냅니다
- CDN이나 프록시가 body를 제거합니다
- Elasticsearch가
_searchAPI에서 GET + body를 쓰는데, 이건 예외적인 사례입니다. 실제로 POST도 같이 지원하고요.
2. 캐싱이 가능합니다
GET 응답은 브라우저나 CDN에 캐싱됩니다. Cache-Control, ETag, Last-Modified 헤더로 제어하죠. POST/PUT/PATCH/DELETE 응답은 기본적으로 캐싱되지 않습니다.
3. URL에 쿼리 파라미터로 데이터를 전달합니다
GET /api/posts?page=1&size=20&tag=spring&status=PUBLISHED
URL 길이 제한이 있기 때문에(브라우저마다 다르지만 보통 2,000~8,000자), 복잡한 검색 조건은 POST를 쓰기도 합니다.
흔한 실수: GET으로 상태를 변경하기
GET /api/users/42/delete ← 절대 이렇게 하면 안 됩니다 GET /api/posts/publish?id=5 ← 이것도 안 됩니다
웹 크롤러, 브라우저 프리페치, CDN 등이 GET 요청을 마음대로 보낼 수 있습니다. GET에 부수 효과를 넣으면 크롤러가 사이트의 데이터를 전부 삭제하는 사고가 실제로 일어납니다.
POST: 리소스 생성
POST는 새 리소스를 생성하거나, 서버에 데이터를 제출할 때 사용합니다.
POST /api/users HTTP/1.1 Host: api.example.com Content-Type: application/json { "name": "박서연", "email": "park@example.com", "role": "STUDENT" }
HTTP/1.1 201 Created Location: /api/users/43 Content-Type: application/json { "id": 43, "name": "박서연", "email": "park@example.com", "role": "STUDENT", "createdAt": "2026-04-03T14:30:00" }
POST에서 알아둘 것들
1. 멱등하지 않습니다
같은 POST 요청을 두 번 보내면 리소스가 두 개 생깁니다. 그래서 결제나 주문 같은 중요한 POST 요청에는 멱등키(Idempotency Key)를 쓰기도 합니다.
POST /api/payments HTTP/1.1 Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000 { "amount": 50000, "method": "CARD" }
이렇게 하면 같은 멱등키로 재요청이 와도 서버가 이전 결과를 돌려줍니다.
2. 응답 코드는 201 Created가 표준입니다
리소스 생성 시 201 Created를 반환하고, Location 헤더에 새 리소스의 URI를 넣는 게 정석이거든요. 그런데 현실에서는 200 OK를 쓰는 API도 많습니다.
3. POST는 "그 외 모든 것"을 담당하기도 합니다
REST에서 POST는 "catch-all" 역할도 합니다. CRUD에 딱 맞지 않는 동작들, 예를 들어 로그인, 검색, 배치 작업 같은 것들은 POST로 처리하는 게 일반적입니다.
POST /api/auth/login ← 로그인 POST /api/reports/generate ← 보고서 생성 POST /api/emails/send ← 이메일 발송 POST /api/search ← 복잡한 검색 조건
PUT: 리소스 전체 교체
PUT은 지정한 URI의 리소스를 요청 본문으로 완전히 교체하는 메서드입니다. 여기서 "완전히"라는 게 핵심입니다.
PUT /api/users/42 HTTP/1.1 Content-Type: application/json { "name": "강건우", "email": "kang_new@example.com", "role": "TUTOR" }
PUT의 의미: "이 리소스를 이걸로 대체해라"
RFC 9110은 PUT을 이렇게 정의합니다:
The PUT method requests that the state of the target resource be created or replaced with the state defined by the representation enclosed in the request message content.
두 가지를 짚어보겠습니다:
첫째, 리소스 전체를 보내야 합니다. PUT 요청에서 role 필드를 빼면, 서버는 "아, role이 없으니 그대로 두자"가 아니라 "role을 null로 설정하자"로 해석해야 합니다. 이게 PUT의 정의입니다.
PUT /api/users/42 HTTP/1.1 { "name": "강건우", "email": "kang@example.com" }
위 요청 후 서버의 상태:
{ "id": 42, "name": "강건우", "email": "kang@example.com", "role": null ← role이 빠졌으니 null 처리 }
둘째, 리소스가 없으면 생성할 수 있습니다. PUT은 upsert(있으면 교체, 없으면 생성) 시맨틱을 가질 수 있습니다. 다만 이건 서버 구현에 따라 다르고, 없으면 404를 반환하는 API가 더 많습니다.
PUT은 멱등합니다
PUT /api/users/42에 같은 데이터를 열 번 보내도 결과는 같습니다. 전체를 교체하니까요. POST와 가장 다른 점이 이겁니다.
실무에서 PUT을 쓸 때
설정값 저장이나 프로필 전체 수정처럼, 폼 전체를 한 번에 전송하는 경우에 PUT이 잘 맞습니다.
PUT /api/users/42/settings HTTP/1.1 { "theme": "dark", "language": "ko", "notifications": { "email": true, "push": false, "sms": false }, "timezone": "Asia/Seoul" }
PATCH: 리소스 부분 수정
PATCH는 리소스의 일부분만 수정할 때 사용합니다. PUT과 가장 큰 차이가 바로 이것입니다.
PATCH /api/users/42 HTTP/1.1 Content-Type: application/json { "role": "TUTOR" }
요청 후 서버의 상태:
{ "id": 42, "name": "강건우", "email": "kang@example.com", "role": "TUTOR" ← 이것만 변경 }
보내지 않은 필드(name, email)는 기존 값을 유지합니다.
PUT vs PATCH: 진짜 차이
다음 그림은 같은 이메일 변경 작업을 PUT과 PATCH로 수행했을 때 어떤 차이가 있는지 보여줍니다.

PUT은 리소스 전체를 덮어쓰기 때문에 모든 필드를 보내야 하고, 빠진 필드는 null이 됩니다. 반면 PATCH는 변경할 필드만 보내면 되고, 나머지 필드는 기존 값을 유지합니다. 필드 하나만 바꾸려고 전체 데이터를 보내야 하는 PUT의 부담이 PATCH를 선호하게 만드는 이유입니다.
이 둘의 차이를 파일 작업에 비유하면 이해가 쉽습니다.
- PUT = 파일을 통째로 덮어쓰기 (파일 전체를 새 내용으로 교체)
- PATCH = 파일의 특정 부분만 편집 (변경할 부분만 지정)
예시로 비교해 보겠습니다. 사용자의 이메일만 바꾸려면:
# PUT - 전체 리소스를 다 보내야 합니다 PUT /api/users/42 HTTP/1.1 { "name": "강건우", "email": "new@example.com", ← 바꿀 필드 "role": "STUDENT", "bio": "개발을 공부하고 있습니다", "avatar": "https://...", "settings": { ... } }
# PATCH - 바꿀 것만 보냅니다 PATCH /api/users/42 HTTP/1.1 { "email": "new@example.com" }
PUT으로 이메일 하나 바꾸려고 모든 필드를 다 보내야 하는 건 불편하죠. 그래서 현실에서는 PATCH를 훨씬 많이 씁니다.
PATCH의 멱등성 문제
PUT은 항상 멱등하지만, PATCH는 그렇지 않을 수 있습니다. 왜 그런지 보겠습니다.
멱등한 PATCH (대부분의 경우):
PATCH /api/users/42 { "role": "TUTOR" }
이걸 열 번 보내도 role은 계속 TUTOR입니다. 멱등합니다.
멱등하지 않은 PATCH:
PATCH /api/posts/7 { "viewCount": "+1" }
이건 보낼 때마다 조회수가 1씩 올라갑니다. 멱등하지 않죠.
또 다른 예시를 보면:
PATCH /api/lists/1 { "op": "append", "value": "새 항목" }
이것도 보낼 때마다 리스트에 항목이 추가되니 멱등하지 않습니다.
요청 본문이 "이 값으로 설정해라"인지 "이만큼 더해라"인지에 따라 멱등성이 갈리는 겁니다. RFC 5789에서도 이 점을 언급합니다:
A PATCH request can be issued in such a way as to be idempotent, which also helps prevent bad outcomes from collisions between two PATCH requests on the same resource in a similar time frame.
PATCH의 공식 스펙: JSON Patch와 JSON Merge Patch
사실 "PATCH에 JSON을 보내면 해당 필드만 바뀐다"는 건 관례이지 표준이 아닙니다. PATCH의 공식 형식은 두 가지가 있습니다.
1. JSON Patch (RFC 6902)
PATCH /api/users/42 HTTP/1.1 Content-Type: application/json-patch+json [ { "op": "replace", "path": "/email", "value": "new@example.com" }, { "op": "add", "path": "/tags/-", "value": "backend" }, { "op": "remove", "path": "/bio" } ]
연산자 기반으로 변경을 표현합니다. add, remove, replace, move, copy, test 연산을 지원합니다.
2. JSON Merge Patch (RFC 7396)
PATCH /api/users/42 HTTP/1.1 Content-Type: application/merge-patch+json { "email": "new@example.com", "bio": null }
우리가 흔히 쓰는 방식과 비슷합니다. 보낸 필드만 바뀌고, null을 보내면 해당 필드를 삭제합니다. 다만 JSON Merge Patch의 한계는 null을 "값"으로 설정하는 것과 "삭제"를 구분할 수 없다는 점입니다.
실무에서는 대부분 Content-Type을 application/json으로 쓰면서 Merge Patch처럼 동작하게 만듭니다. 공식 스펙을 엄격하게 따르는 API는 많지 않거든요.
PUT과 PATCH 선택 기준
| 상황 | 권장 메서드 |
|---|---|
| 폼 전체 저장 (설정, 프로필 전체) | PUT |
| 필드 하나만 변경 (상태, 닉네임) | PATCH |
| 리소스 생성이 필요할 수도 있으면 (upsert) | PUT |
| 대역폭이 제한적이면 | PATCH |
| 동시 수정 충돌이 걱정되면 | PUT + ETag |
현실적으로 API를 설계할 때는 다음과 같이 분리하는 팀이 많습니다:
- 전체 수정 API → PUT
- 부분 수정 API → PATCH
- 상태만 바꾸는 API → PATCH
DELETE: 리소스 삭제
DELETE는 지정한 리소스를 삭제합니다. 단순해 보이지만 body 처리 문제가 있습니다.
DELETE /api/users/42 HTTP/1.1 Host: api.example.com Authorization: Bearer eyJhbGciOi...
HTTP/1.1 204 No Content
DELETE의 특성
1. 멱등합니다
DELETE /api/users/42를 두 번 보내면, 첫 번째는 204 No Content, 두 번째는 404 Not Found가 나올 수 있습니다. 응답 코드는 다르지만 서버의 최종 상태(42번 사용자가 없음)는 같으니 멱등합니다.
2. 응답 코드
| 응답 | 의미 |
|---|---|
| 200 OK | 삭제 완료, 응답 body에 삭제된 리소스 정보 포함 |
| 202 Accepted | 삭제 요청 접수, 비동기로 처리 예정 |
| 204 No Content | 삭제 완료, 응답 body 없음 |
| 404 Not Found | 이미 없거나 존재하지 않는 리소스 |
DELETE의 body: "보낼 수는 있지만 무시될 수 있다"
DELETE에 body를 보내면 어떻게 될까요? 여기가 실무에서 은근히 삽질하는 부분입니다.
RFC 9110은 이렇게 말합니다:
Although request message framing is independent of the method used, content received in a DELETE request has no generally defined semantics, cannot alter the meaning or target of the request, and might lead some implementations to reject the request and close the connection because of its potential as a request smuggling attack.
해석하면 이렇습니다:
- DELETE 요청에 body가 올 수는 있습니다 (HTTP 프로토콜이 금지하지는 않음)
- 하지만 그 body에 정해진 의미가 없습니다
- 요청의 의미나 대상을 바꿀 수 없습니다
- 일부 구현체는 body가 있는 DELETE를 아예 거부할 수 있습니다
body가 무시되거나 거부되는 실제 사례
다음 그림은 DELETE 요청에 담은 body가 프로덕션 인프라를 거치면서 사라지는 과정과, 안전한 대안을 보여줍니다.

클라이언트가 보낸 DELETE body는 CDN, API Gateway, 리버스 프록시 등을 거치면서 제거될 수 있습니다. 개발 환경에서는 중간 인프라가 없어서 잘 동작하지만, 프로덕션에 배포하면 body가 사라져서 500 에러가 발생합니다. 안전한 대안은 POST /batch-delete처럼 POST 메서드를 사용하는 것입니다.
1. CDN과 프록시
Cloudflare, AWS CloudFront, nginx 같은 중간 장비들이 DELETE 요청의 body를 제거하거나 연결을 끊을 수 있습니다. 개발 환경에서는 잘 동작하다가 프로덕션에 CDN을 붙이는 순간 깨지는 겁니다.
2. HTTP 클라이언트 라이브러리
| 라이브러리 | DELETE body 지원 |
|---|---|
| JavaScript fetch() | 지원 (body 옵션 사용) |
| Axios | 지원 (data 또는 config.data) |
| Java HttpClient (11+) | 제한적 (method()로 우회 필요) |
| OkHttp (Android) | 지원 |
| Python requests | 지원 |
| Go net/http | 지원하지만 비권장 |
| curl | -d 또는 --data로 가능 |
지원 여부와 별개로, 중간에 끼인 프록시가 body를 제거할 수 있으니 신뢰할 수 없습니다.
3. API Gateway
AWS API Gateway, Kong, Apigee 같은 API 게이트웨이에서도 DELETE body를 파싱하지 않거나, body 유효성 검사를 건너뛰는 경우가 있습니다.
DELETE body가 필요한 상황과 대안
"그러면 여러 리소스를 한 번에 삭제하고 싶을 때는 어떡하죠?"
이런 경우가 실무에서 자주 나옵니다:
# 이렇게 하고 싶지만... body가 무시될 수 있음 DELETE /api/users HTTP/1.1 { "ids": [1, 2, 3, 4, 5] }
대안은 여러 가지가 있습니다.
대안 1: 쿼리 파라미터 사용
DELETE /api/users?ids=1,2,3,4,5
단순하고 안전합니다. 다만 URL 길이 제한이 있어 대량 삭제에는 부적합합니다.
대안 2: POST로 대체
POST /api/users/batch-delete HTTP/1.1 { "ids": [1, 2, 3, 4, 5] }
실무에서 가장 많이 쓰는 방식입니다. 메서드 이름만으로는 삭제 작업임을 알 수 없지만, URL 구조로 의도를 파악할 수 있습니다. GitHub API, Google API도 이 방식을 씁니다.
대안 3: 개별 DELETE 요청
DELETE /api/users/1 DELETE /api/users/2 DELETE /api/users/3
HTTP/2에서 멀티플렉싱을 쓰면 병렬 처리가 가능하긴 하지만, 네트워크 오버헤드가 있습니다.
소프트 삭제 vs 하드 삭제
DELETE를 구현할 때 빠지지 않는 고민이 하나 있습니다. 진짜로 데이터를 지울 것인가, 아니면 상태만 바꿀 것인가.
// 하드 삭제 - DB에서 실제 삭제 userRepository.deleteById(userId); // 소프트 삭제 - status 컬럼만 변경 user.setStatus(UserStatus.DELETED); user.setDeletedAt(LocalDateTime.now()); userRepository.save(user);
클라이언트 입장에서는 둘 다 DELETE /api/users/42입니다. 서버 내부 구현만 다르죠. 소프트 삭제 방식을 사용하면 데이터 복구가 가능하고, 외래키 제약도 깨지지 않으니 실무에서 선호하는 편입니다.
메서드 비교 총정리
멱등성과 안전성 매트릭스
안전(Safe) 멱등(Idempotent) 캐시 가능 GET O O O POST X X 조건부* PUT X O X PATCH X X** X DELETE X O X * POST 응답에 Cache-Control이 있으면 캐시 가능 ** 구현에 따라 멱등할 수 있음
요청/응답 body 정리
| 메서드 | 요청 body | 응답 body |
|---|---|---|
| GET | 보내면 안 됨 | 있음 |
| POST | 있음 | 있음 |
| PUT | 있음 | 선택 |
| PATCH | 있음 | 선택 |
| DELETE | 보낼 수 있지만 무시될 수 있음 | 선택 |
Spring Boot에서의 구현 예시
@RestController @RequestMapping("/api/users") public class UserController { // GET - 조회 @GetMapping("/{id}") public ResponseEntity<UserResponse> getUser(@PathVariable Long id) { return ResponseEntity.ok(userService.getUser(id)); } // POST - 생성 @PostMapping public ResponseEntity<UserResponse> createUser( @Valid @RequestBody CreateUserRequest request) { UserResponse created = userService.createUser(request); URI location = URI.create("/api/users/" + created.getId()); return ResponseEntity.created(location).body(created); } // PUT - 전체 교체 @PutMapping("/{id}") public ResponseEntity<UserResponse> replaceUser( @PathVariable Long id, @Valid @RequestBody ReplaceUserRequest request) { // 모든 필드가 필수 - 안 보내면 validation 에러 return ResponseEntity.ok(userService.replaceUser(id, request)); } // PATCH - 부분 수정 @PatchMapping("/{id}") public ResponseEntity<UserResponse> updateUser( @PathVariable Long id, @RequestBody UpdateUserRequest request) { // null인 필드는 수정하지 않음 return ResponseEntity.ok(userService.updateUser(id, request)); } // DELETE - 삭제 @DeleteMapping("/{id}") public ResponseEntity<Void> deleteUser(@PathVariable Long id) { userService.deleteUser(id); return ResponseEntity.noContent().build(); } }
PUT용 DTO와 PATCH용 DTO를 분리하는 게 포인트입니다:
// PUT용 - 모든 필드 필수 public record ReplaceUserRequest( @NotBlank String name, @NotBlank @Email String email, @NotNull UserRole role ) {} // PATCH용 - 모든 필드 선택 public record UpdateUserRequest( String name, // null이면 변경하지 않음 String email, // null이면 변경하지 않음 UserRole role // null이면 변경하지 않음 ) {}
Next.js API Route에서의 구현 예시
// app/api/users/[id]/route.ts // GET export async function GET( request: Request, { params }: { params: { id: string } } ) { const user = await getUser(params.id); if (!user) { return Response.json( { error: '사용자를 찾을 수 없습니다' }, { status: 404 } ); } return Response.json(user); } // PUT - 전체 교체 export async function PUT( request: Request, { params }: { params: { id: string } } ) { const body = await request.json(); // 모든 필수 필드 검증 if (!body.name || !body.email || !body.role) { return Response.json( { error: '모든 필드를 입력해주세요' }, { status: 400 } ); } const updated = await replaceUser(params.id, body); return Response.json(updated); } // PATCH - 부분 수정 export async function PATCH( request: Request, { params }: { params: { id: string } } ) { const body = await request.json(); // body에 있는 필드만 업데이트 const updated = await updateUser(params.id, body); return Response.json(updated); } // DELETE export async function DELETE( request: Request, { params }: { params: { id: string } } ) { await deleteUser(params.id); return new Response(null, { status: 204 }); }
자주 하는 질문
Q: PATCH가 있는데 PUT을 왜 써야 하나요?
멱등성 보장이 필요할 때 PUT이 유리합니다. 네트워크 오류로 요청이 재전송되어도 PUT은 결과가 같지만, PATCH는 구현에 따라 다를 수 있거든요. 또 "이 리소스의 전체 상태가 정확히 이것이어야 한다"는 걸 명시적으로 표현할 수 있습니다.
Q: 실무에서 PUT을 진짜 RFC대로 쓰는 곳이 있나요?
솔직히 말하면 많지 않습니다. 상당수의 API가 PUT을 PATCH처럼 씁니다. 보내지 않은 필드는 기존 값을 유지하는 식이거든요. RFC 원칙주의자들은 이걸 잘못된 구현이라고 하지만, 현실적으로 잘 돌아가면 문제가 되진 않습니다. 다만 API 문서에 동작 방식을 명확히 적어야 합니다.
Q: DELETE 후 같은 ID로 POST하면 재생성이 가능한가요?
서버 구현에 따라 다릅니다. Auto-increment ID를 쓰면 보통 새 ID가 할당되고, UUID를 쓰면 마찬가지입니다. 소프트 삭제를 쓴다면 "복원" API를 따로 만드는 게 낫습니다.
Q: 로그인은 어떤 메서드를 써야 하나요?
POST입니다. GET은 URL에 비밀번호가 노출되니 안 되고, PUT/PATCH는 의미가 맞지 않습니다. 세션 "생성"이라고 보면 POST가 자연스럽습니다.
마무리
HTTP 메서드는 "이걸로 보내면 동작한다" 수준을 넘어서, 각 메서드가 가진 의미(시맨틱)를 알아야 API를 제대로 설계할 수 있습니다.
다시 정리하면:
- GET: 조회 전용. body 쓰지 말 것. 상태 변경하면 안 됨
- POST: 생성 또는 CRUD에 안 맞는 동작. 멱등하지 않음
- PUT: 리소스를 통째로 교체. 멱등함. 빠진 필드는 null 처리
- PATCH: 리소스 일부만 수정. 멱등성은 구현에 따라 다름
- DELETE: 삭제. body를 보내면 무시될 수 있으니 쿼리 파라미터나 POST로 대체
PUT과 PATCH의 차이를 "전체 교체 vs 부분 수정"으로만 외우지 말고, 멱등성과 DTO 설계까지 연결해서 생각해 보세요. 실제로 API를 만들다 보면 이 차이가 코드 구조에 영향을 줍니다.
DELETE body 문제는 "기술적으로 보낼 수 있는지"가 아니라 "인프라를 거치는 과정에서 body가 유지되는지"가 쟁점입니다. 로컬에서 잘 되던 게 CDN 붙이는 순간 깨진다면, 그건 body를 쓴 DELETE 때문일 수 있습니다.




댓글
댓글을 작성하려면 이 필요합니다.