API를 만들다 보면 “이건 PUT인가, PATCH인가?”라는 질문이 꼭 나옵니다.
짧게 요약하면 이렇습니다.
PUT은 리소스를 통째로 갈아끼우는 전면 교체, PATCH는 필요한 부분만 쓱싹 고치는 부분 수정입니다.
그리고 의도상 PUT은 멱등적(같은 요청을 여러 번 보내도 결과가 같음)이고, PATCH는 기본적으로 멱등이 보장되지 않습니다. 표준이 뭐라고 말하는지, 실제로는 어떻게 구현하는지, 스프링 부트 코드와 cURL 예시까지 깔끔하게 정리해보겠습니다. PUT과 PATCH의 핵심 차이를 이해하면 API를 만들때도, 사용할때도 더 편해지니까 가능하면 이 개념을 잘 숙지하시면 좋습니다~
한장으로 보는 PUT vs PATCH 비교 이미지

표준이 말하는 PUT과 PATCH
PUT: 전면 교체, 의도상 멱등
PUT은 요청 바디에 담긴 표현(representation)으로 대상 리소스의 상태를 대체한다는 의도를 가집니다. 즉, 서버에 이미 있던 리소스든 없던 리소스든, 그 URI가 가리키는 것을 “이걸로” 바꿔치기하겠다는 뜻에 가깝습니다. 표준은 PUT의 의도가 멱등적임을 분명히 밝힙니다. 같은 PUT을 여러 번 보내도 결과가 같아야 한다는 뜻이죠. (IETF Datatracker)
PATCH: 부분 수정, 기본적으로 멱등 아님
PATCH는 리소스의 일부를 수정합니다. 요청 본문에는 “어떻게 바꿀지”에 대한 지시 사항이 오죠. 표준은 PUT으로 부분 수정을 재활용하지 말라고 못을 박습니다. 왜냐면 프록시·캐시·클라이언트가 결과를 혼동하기 쉽기 때문입니다. 또한 PATCH는 안전하지도, 멱등적이지도 않습니다(물론 멱등적으로 설계할 수는 있음). (RFC Editor)
PATCH의 두 얼굴: JSON Merge Patch vs JSON Patch
PATCH의 본문은 포맷이 여러 가지일 수 있지만, 실무에서 가장 많이 쓰는 건 두 가지입니다.
JSON Merge Patch
콘텐츠 타입은 application/merge-patch+json. 변경하고 싶은 필드만 보내면 됩니다. 객체 필드를 null로 보내면 삭제의 의미가 됩니다(배열 일부만 수정하는 데에는 부적합). (IETF Datatracker)
PATCH /users/42
Content-Type: application/merge-patch+json
{
"name": "Alice K",
"phone": null
}
JSON Patch
콘텐츠 타입은 application/json-patch+json. 연산들의 배열을 보내며, 각 연산은 add/replace/remove/move/copy/test 등을 가집니다. 배열의 특정 위치 조작, 경로 기반 정교한 업데이트가 가능합니다. (IETF Datatracker)
PATCH /users/42
Content-Type: application/json-patch+json
[
{ "op": "replace", "path": "/name", "value": "Alice K" },
{ "op": "remove", "path": "/phone" }
]
상태 코드와 조건부 요청(ETag)로 동시성 안전하게
PATCH가 성공했는데 응답 바디를 굳이 돌려줄 필요가 없다면 204 No Content를 쓸 수 있습니다. 200으로 새 표현을 돌려줘도 됩니다. 둘 다 표준에서 허용하는 방식입니다. (RFC Editor)
동시 수정 충돌을 막으려면 ETag와 If-Match로 조건부 요청을 거세요. 서버는 If-Match가 현재 ETag와 일치하지 않으면 412 Precondition Failed를 줄 수 있습니다. PUT/PATCH/DELETE 같은 상태 변경 요청에서 잃어버린 업데이트를 예방하는 권장 패턴입니다. (IETF Datatracker)
PATCH /users/42
If-Match: "v9-6f2a"
Content-Type: application/merge-patch+json
{ "name": "Alice K" }
언제 PUT, 언제 PATCH?
- 리소스 전체를 새 값으로 교체하고 싶다 → PUT
예: 문서 저장, 스냅샷 교체, 클라이언트가 서버 상태를 “정합하게” 덮어쓸 수 있을 때 - 일부 필드만 바꾸고 싶다 → PATCH
예: 사용자 닉네임만 변경, 설정의 일부 토글, 느슨한 부분 업데이트
주의할 점은 “PUT으로 부분 업데이트를 흉내 내기”입니다. 요청 바디에 없는 필드는 보존할지 삭제할지 애매해지고, 팀마다 해석이 달라져 버그의 근원이 됩니다. 표준 의도에 맞춰 “PUT=교체, PATCH=부분 수정”으로 분리하는 편이 API 사용자 경험이 명확합니다. (RFC Editor)
비교표로 핵심만 쏙쏙
| 항목 | PUT | PATCH |
|---|---|---|
| 의미 | 대상 리소스 상태를 요청 바디로 전면 교체 | 대상 리소스 상태의 일부만 수정 |
| 멱등성 | 의도상 멱등 | 기본적으로 비멱등(설계에 따라 멱등 가능) |
| 본문 포맷 | 리소스 전체 표현 | JSON Merge Patch, JSON Patch 등 |
| 대표 콘텐츠 타입 | application/json 등 | application/merge-patch+json, application/json-patch+json |
| 추천 사례 | 문서/설정 전체 스냅샷 교체 | 특정 필드/배열 일부 수정 |
| 충돌 방지 | If-Match로 조건부 PUT | If-Match로 조건부 PATCH |
| 성공 코드 | 200(표현 반환) 또는 204(바디 없음) | 200 또는 204 |
cURL로 보는 실제 호출
PUT: 전체 교체
curl -X PUT https://api.example.com/users/42 \
-H "Content-Type: application/json" \
-H 'If-Match: "v9-6f2a"' \
-d '{
"name":"Alice K",
"email":"alice@example.com",
"marketingOptIn": true,
"tags": ["beta","paid"]
}'
PATCH(merge): 일부 수정 + 삭제
curl -X PATCH https://api.example.com/users/42 \
-H "Content-Type: application/merge-patch+json" \
-H 'If-Match: "v9-6f2a"' \
-d '{
"name":"Alice K",
"phone": null
}'
PATCH(json-patch): 배열/경로 조작
curl -X PATCH https://api.example.com/users/42 \
-H "Content-Type: application/json-patch+json" \
-H 'If-Match: "v9-6f2a"' \
-d '[
{"op":"replace","path":"/name","value":"Alice K"},
{"op":"remove","path":"/phone"},
{"op":"add","path":"/tags/-","value":"vip"}
]'
스프링 부트 구현 예시
- PUT에서는 요청 DTO를 엔티티 전체로 매핑해 교체
- PATCH에서는 null 필드는 무시하고 지정된 필드만 덮어쓰기(merge semantics)
엔티티와 DTO
// User.java (엔티티, 예시)
public class User {
private Long id;
private String name;
private String email;
private Boolean marketingOptIn;
private String phone;
// 게터/세터 생략
}
// UserPutDto.java (전체 교체용)
public class UserPutDto {
private String name;
private String email;
private Boolean marketingOptIn;
private String phone;
// 게터/세터
}
// UserPatchDto.java (부분 수정용: null은 "수정 안 함")
public class UserPatchDto {
private String name;
private String email;
private Boolean marketingOptIn;
private String phone;
// 게터/세터
}
MapStruct로 부분 업데이트(널 무시)
// UserMapper.java
import org.mapstruct.*;
@Mapper(componentModel = "spring",
nullValuePropertyMappingStrategy = NullValuePropertyMappingStrategy.IGNORE)
public interface UserMapper {
// PUT: DTO -> 새로운 엔티티로 전면 교체 시 사용 가능
User toEntity(UserPutDto dto);
// PATCH: DTO의 null은 무시하고 지정된 필드만 @MappingTarget에 적용
void updateFromPatchDto(UserPatchDto dto, @MappingTarget User entity);
}
서비스 로직(ETag/버전 체킹 예시)
public class UserService {
// 가정: 저장소에 version(혹은 ETag) 필드가 있다
public User replace(Long id, String ifMatch, UserPutDto dto) {
User existing = repository.findById(id).orElseThrow();
assertMatch(existing.getVersion(), ifMatch); // If-Match 검증
User replaced = mapper.toEntity(dto);
replaced.setId(id);
return repository.save(replaced);
}
public User patch(Long id, String ifMatch, UserPatchDto dto) {
User existing = repository.findById(id).orElseThrow();
assertMatch(existing.getVersion(), ifMatch); // If-Match 검증
mapper.updateFromPatchDto(dto, existing);
return repository.save(existing);
}
private void assertMatch(String currentVersion, String ifMatch) {
if (ifMatch == null || !ifMatch.equals(currentVersion)) {
throw new PreconditionFailedException(); // 412 매핑
}
}
}
컨트롤러
@RestController
@RequestMapping("/v1/users")
public class UserController {
private final UserService service;
public UserController(UserService service) { this.service = service; }
@PutMapping("/{id}")
public ResponseEntity<Void> putUser(
@PathVariable Long id,
@RequestHeader(value = "If-Match", required = false) String ifMatch,
@RequestBody @Valid UserPutDto dto) {
service.replace(id, ifMatch, dto);
return ResponseEntity.noContent().build(); // 204
}
@PatchMapping(value = "/{id}", consumes = {
"application/merge-patch+json", "application/json"})
public ResponseEntity<Void> patchUser(
@PathVariable Long id,
@RequestHeader(value = "If-Match", required = false) String ifMatch,
@RequestBody UserPatchDto dto) {
service.patch(id, ifMatch, dto);
return ResponseEntity.noContent().build(); // 204
}
}
구현 시 주의사항
- JSON Merge Patch를 엄격히 지원하려면
application/merge-patch+json을 명시하고, “null=삭제” 규칙을 수용할지 정책을 분명히 해야 합니다. 배열의 일부 수정은 JSON Merge Patch로 곤란할 수 있습니다. 그럴 땐 JSON Patch를 고려하세요. (IETF Datatracker) - JSON Patch를 지원하려면 RFC 6902 연산을 적용해줄 라이브러리가 필요합니다. 서버는 연산을 순서대로 원자적으로 적용해야 하며, 중간 실패 시 전체 롤백되어야 합니다. (IETF Datatracker)
- Accept-Patch 헤더로 서버가 지원하는 패치 포맷을 알릴 수 있습니다(예: OPTIONS 응답에 포함). (RFC Editor)
- 동시성 충돌을 반드시 다루세요. ETag/If-Match로 412를 반환하거나, 버전 필드를 통해 낙관적 락을 구현하면 안전합니다. (IETF Datatracker)
테스트 체크리스트
- PUT 멱등성
같은 PUT을 두 번 보내도 상태가 동일한지 확인합니다(마지막 수정 시각만 달라지는 것은 허용될 수 있으나, 비즈니스 상태는 같아야 함). (IETF Datatracker) - PATCH 원자성
여러 필드를 한 번에 PATCH할 때, 하나라도 실패하면 전체가 적용되지 않아야 합니다. (RFC Editor) - 조건부 요청
서로 다른 클라이언트가 거의 동시에 갱신할 때 If-Match 없이는 마지막 요청이 승자가 됩니다. 테스트에서 ETag 불일치 시 412가 제대로 나는지 확인하세요. (IETF Datatracker) - 콘텐츠 타입
클라이언트·서버가application/merge-patch+json과application/json-patch+json을 정확히 구분하는지, 415 처리 및 Accept-Patch 노출이 적절한지 확인합니다. (IETF Datatracker, RFC Editor)
결론 요약
- PUT은 교체, PATCH는 부분 수정.
- PUT은 의도상 멱등, PATCH는 기본 비멱등(설계로 멱등 가능).
- PATCH 포맷은 크게 두 가지: 간단한 Merge Patch(객체 중심, null=삭제), 정교한 JSON Patch(경로 연산).
- 동시성 안전을 위해 ETag/If-Match를 반드시 고려.
- 구현은 단순할수록 실수가 적습니다. 전면 교체는 PUT, 일부 수정은 PATCH. 이 규칙만 지켜도 API는 훨씬 예측 가능해집니다.
참고 자료
- RFC 9110: HTTP Semantics(특히 PUT의 의도, 조건부 요청 If-Match 등). (IETF Datatracker)
- RFC 5789: PATCH Method for HTTP(부분 수정, 비멱등성, Accept-Patch, 204/200 등). (RFC Editor)
- RFC 7396: JSON Merge Patch(컨텐츠 타입과 null=삭제, 적용 규칙). (IETF Datatracker)
- RFC 6902: JSON Patch(연산 배열과 콘텐츠 타입, 예시). (IETF Datatracker)
- MDN HTTP 리소스 목록(HTTP 최신 표준 안내). (MDN 웹 문서)
'IT' 카테고리의 다른 글
| JPA에서 동시성 제어 방법(낙관적 락,비관적 락) (0) | 2025.12.24 |
|---|---|
| 오늘 공개된 GPT-5.2, GPT-5.1·GPT-5(5.0)와 뭐가 달라졌나 (0) | 2025.12.12 |
| 맥북 M1 ~ M5 칩셋 성능 변화 한 번에 보기 (2025년 11월 기준) (3) | 2025.11.11 |
| MySQL VS 오라클(Oracle) 비교: 돈, 기능, 규모로 따져보는 현실적인 DB 선택 가이드 (1) | 2025.11.11 |
| 개발에서 말하는 원자성(Atomicity)이란 무엇인가 (2) | 2025.11.10 |