PUT
: 리소스를 “통째로 대체”하는 메서드이다. 요청 바디에 해당 리소스의 완전한 표현(representation)을 담아야 하며, 서버는 이를 기존 리소스와 교체한다. 즉, 기존 리소스를 날려버리고 새로운 리소스로 갈아끼운다고 생각하면 된다.PATCH
: 리소스를 “부분적으로 수정”하는 메서드이다. 요청 바디에는 전체 리소스가 아닌, 바꾸고 싶은 필드만 전달한다. 서버는 해당 변경 사항만 적용하고 나머지는 그대로 둔다.항목 | PUT | PATCH |
---|---|---|
의미 | 대상 리소스를 대체 | 대상 리소스의 일부 변경 |
바디 | 보통 전체 필드 포함 | 변경 필드만 포함 |
멱등성(Idempotence) | 멱등적 (같은 요청 반복해도 결과 동일) | 구현에 따라 다름 (JSON Merge Patch는 실무상 멱등적으로 설계 가능, JSON Patch는 보통 멱등X) |
부분 업데이트 | 권장하지 않음 | 핵심 용도 |
필드 생략 | 보통 “기존 유지”가 아닌, 스키마에 따라 다름 (실수 잦음) | 기본적으로 생략=미변경 (JSON Merge Patch), 명령적 변경(JSON Patch) |
실패 가능 포인트 | 전체 대체라 누락 필드 처리 | 경로/연산 오류, 배열 인덱스, null 의미 |
대표 Content-Type | application/json | application/merge-patch+json (RFC 7396), application/json-patch+json (RFC 6902) |
“전체 대체” 의도가 아니면 PUT
을 쓰지 않는 게 안전하다. 부분 수정이면 PATCH가 기본값이다.
JSON Merge Patch는 가장 단순한 형태의 부분 업데이트 방법으로 요청 바디가 평범한 JSON 객체 형태를 띤다. 이 방식에서 필드의 의미는 명확하게 정의되어 있다. 키가 생략되면 해당 필드는 변경하지 않음을 의미하고 키가 존재하면서 값이 주어지면 그 값으로 대체한다. 또한 키가 존재하면서 값이 null이면 해당 필드를 삭제하거나 null로 초기화한다. 이때 null의 의미가 삭제인지 초기화인지는 서버에서 어떻게 해석할지 반드시 문서화해야 한다.
장점으로는 구조가 단순해 바디 크기가 작고 같은 요청을 여러 번 보내도 결과가 동일하게 유지되므로 멱등성을 보장하기 쉽다. 그러나 단점은 배열을 다룰 때 나타난다. Merge Patch에서 배열은 기본적으로 “전체 교체”의 의미를 가지므로 배열 요소의 부분 삽입이나 삭제를 표현하기가 애매하다.
PATCH /users/1
Content-Type: application/merge-patch+json
{
"nickname": "moomoo",
"profileImageUrl": null
}
JSON Patch는 Merge Patch보다 더 정교한 변경을 지원한다. 요청 바디가 “연산들의 배열”로 구성되며, 각 원소는 op
, path
, value
로 이루어진다. 지원되는 연산에는 add
, remove
, replace
, move
, copy
, test
가 있다. 이 덕분에 배열의 특정 인덱스 요소만 삭제하거나 교체하는 등 세밀한 조작이 가능하다.
장점은 강력한 표현력 덕분에 복잡한 구조의 리소스 변경을 유연하게 처리할 수 있다는 점이다. 반면 단점으로는 클라이언트와 서버 모두 구현이 복잡해지고 연산의 순서에 따라 결과가 달라질 수 있어 보통 멱등성을 보장하지 못한다는 점이다.
PATCH /users/1
Content-Type: application/json-patch+json
[
{ "op": "replace", "path": "/nickname", "value": "moomoo" },
{ "op": "remove", "path": "/profileImageUrl" }
]
API를 설계할 때는 먼저 업데이트의 성격을 구분해야 한다. 리소스를 전체 대체하는 경우라면 PUT
을 쓰는 것이 맞고 일부만 갱신하려는 경우라면 PATCH
를 선택해야 한다. 특히 배열 부분 조작이 필요한 경우에는 JSON Patch가 적합하고 그렇지 않다면 Merge Patch를 권장한다.
또한 null의 의미를 반드시 명확히 정의해야 한다. 어떤 필드에 null을 보냈을 때 이것을 삭제로 해석할지 단순 초기화로 해석할지 문서화해야 한다. 멱등성을 보장하는 것도 중요하다. 네트워크 문제로 같은 요청이 여러 번 전송될 수 있으므로 Merge Patch처럼 멱등적 설계가 가능하거나 서버에서 재시도 상황을 안전하게 처리할 수 있는 장치가 필요하다.
동시성 제어도 고려해야 한다. ETag와 If-Match 헤더를 함께 사용하면 클라이언트가 보낸 버전과 서버의 최신 버전을 비교해 충돌 여부를 감지할 수 있다. 마지막으로 부분 업데이트 시에는 필드 단위 오류를 어떻게 반환할지도 합의해야 하며 RFC7807의 Problem Details 포맷을 사용하는 것이 좋다.
아래 코드는 Spring Boot에서 PUT을 통해 리소스를 전체 대체하는 예시다. 요청 DTO는 리소스의 필수 필드를 모두 포함해야 하며 일부 필드가 누락될 경우 400(검증 실패)
또는 422(처리 불가)
로 응답하는 것이 자연스럽다. 또한 ETag가 일치하지 않으면 412 Precondition Failed
를 반환해 동시성 충돌을 처리할 수 있다.
@RestController
@RequestMapping("/users")
@RequiredArgsConstructor
class UserController {
private final UserService userService;
@PutMapping("/{id}")
public ResponseEntity<UserResult> replace(
@PathVariable Long id,
@Valid @RequestBody UserReplaceCommand cmd,
@RequestHeader(value = "If-Match", required = false) String ifMatch // ETag
) {
UserResult result = userService.replace(id, cmd, ifMatch);
return ResponseEntity.ok()
.eTag("\"" + result.getVersion() + "\"")
.body(result);
}
}
Merge Patch를 사용하는 경우 요청 본문을 JsonNode
로 받아서 기존 DTO에 덮어씌우는 방식으로 구현할 수 있다. ObjectMapper
의 readerForUpdating
기능을 사용하면 기존 객체에 부분적으로 새로운 JSON을 반영할 수 있다. 반영된 DTO는 비즈니스 로직 검증을 거친 뒤 엔티티에 적용하고 저장한다.
특히 null 처리 규칙은 반드시 문서화해야 한다. 어떤 경우 null을 허용할지, 삭제로 처리할지를 Validator와 함께 명확히 규정하지 않으면 예기치 못한 오류가 발생한다.
@PatchMapping(path = "/{id}", consumes = "application/merge-patch+json")
public ResponseEntity<UserResult> mergePatch(
@PathVariable Long id,
@RequestBody JsonNode patch, // 원본 JSON 노드
@RequestHeader(value = "If-Match", required = false) String ifMatch
) throws JsonProcessingException {
UserResult result = userService.mergePatch(id, patch, ifMatch); // 내부에서 null/생략 처리 규칙 반영
return ResponseEntity.ok()
.eTag("\"" + result.getVersion() + "\"")
.body(result);
}
JSON Patch의 경우 연산 기반으로 처리한다. 먼저 현재 DTO를 JsonNode로 직렬화하고, 클라이언트가 보낸 패치를 적용한 뒤 다시 DTO로 역직렬화한다. 배열 인덱스 접근이나 경로 오류가 발생하면 422
상태 코드로 매핑하는 것이 바람직하다.
@PatchMapping(path = "/{id}", consumes = "application/json-patch+json")
public ResponseEntity<UserResult> jsonPatch(
@PathVariable Long id,
@RequestBody JsonPatch patch,
@RequestHeader(value = "If-Match", required = false) String ifMatch
) {
UserResult result = userService.jsonPatch(id, patch, ifMatch);
return ResponseEntity.ok()
.eTag("\"" + result.getVersion() + "\"")
.body(result);
}
ETag는 응답에 포함되는 버전 정보로, 클라이언트는 이후 수정 요청 시 If-Match 헤더로 이 값을 전달한다. 서버에서 현재 버전과 클라이언트가 보낸 버전이 불일치할 경우 412 Precondition Failed
를 반환한다. 이를 통해 동시성 충돌을 방지할 수 있다. DB 레벨에서는 JPA의 @Version
같은 낙관적 락을 이용해 버전을 관리하는 것이 일반적이다.
GET /users/1
→ 200 OK
ETag: "15"
PATCH /users/1
If-Match: "15"
Content-Type: application/merge-patch+json
{ "nickname": "moomoo" }
필드 단위 검증은 Bean Validation을 통해 처리할 수 있다. PATCH 요청의 경우 들어온 필드만 검증하면 되는데 이를 위해 DTO를 분리하거나 Validation 그룹을 활용할 수 있다. 에러 응답은 RFC7807 Problem Details 포맷을 따르는 것이 표준적이며 어떤 필드가 어떤 이유로 실패했는지를 명확히 전달하는 것이 좋다.
{
"type": "https://example.com/problems/validation-error",
"title": "Validation failed",
"status": 400,
"errors": [
{ "field": "nickname", "message": "must not be blank" }
]
}
실무에서는 PUT을 부분 업데이트 용도로 잘못 사용하는 경우가 많다. 이 경우 누락된 필드가 삭제로 처리될지 유지로 처리될지 애매해 문제가 된다. 따라서 부분 업데이트는 반드시 PATCH로 분리하는 것이 안전하다.
또한 null 처리 규칙이 모호하면 충돌이 잦다. Merge Patch에서 배열은 통째로 교체되는 의미가 되므로 부분 추가/삭제가 필요하다면 JSON Patch나 별도 엔드포인트를 설계하는 것이 좋다. JSON Patch는 멱등성이 보장되지 않는 경우가 많아 재시도 시 결과가 달라질 수 있다. 이를 고려해 운용 정책을 세우거나 Merge Patch를 선택하는 것이 바람직하다. 마지막으로 PATCH는 변경된 필드만 오기 때문에 감사 로그나 변경 이력을 남기려면 변경 전/후 값을 비교해 기록하는 설계가 필요하다.
단일 필드 갱신은 JPA의 @DynamicUpdate
를 사용하거나 JPQL 벌크 업데이트로 최적화할 수 있다. 이는 네트워크와 객체 변환 비용을 줄일 수 있는 장점이 있다. 다만 벌크 업데이트는 영속성 컨텍스트와 동기화 문제를 주의해야 한다. 또한 필드별 권한 검증도 필요하다. 예를 들어 일반 사용자가 role
필드를 변경하지 못하도록 화이트리스트 기반의 검증 로직을 추가해야 한다.
@Modifying(clearAutomatically = true, flushAutomatically = true)
@Query("UPDATE User u SET u.nickname = :nickname, u.updatedAt = CURRENT_TIMESTAMP WHERE u.id = :id")
int updateNickname(@Param("id") Long id, @Param("nickname") String nickname);
테스트에서는 PUT 요청 시 전체 바디가 누락되면 400
또는 422
가 반환되는지 확인해야 하고 PATCH의 경우 필드 생략이 미변경으로 처리되는지, null이 삭제로 처리되는지 등을 검증해야 한다. JSON Patch에서는 경로가 없거나 배열 인덱스가 잘못되면 422
를 반환하도록 테스트해야 한다. ETag를 이용한 동시성 제어도 검증 포인트다. If-Match가 일치하면 정상 처리, 불일치하면 412
를 반환해야 한다.
mockMvc.perform(patch("/users/{id}", 1L)
.contentType("application/merge-patch+json")
.header("If-Match", "\"15\"")
.content("""
{ "nickname": "moomoo", "profileImageUrl": null }
"""))
.andExpect(status().isOk())
.andExpect(header().string("ETag", "\"16\""))
.andExpect(jsonPath("$.nickname").value("moomoo"))
.andExpect(jsonPath("$.profileImageUrl").value(Matchers.nullValue())); // 삭제/초기화 규칙에 맞게 검증
curl -X PUT https://api.example.com/users/1 \
-H 'Content-Type: application/json' \
-H 'If-Match: "15"' \
-d '{
"email": "a@b.com",
"nickname": "moomoo",
"profileImageUrl": "https://...",
"marketingConsent": true
}'
curl -X PATCH https://api.example.com/users/1 \
-H 'Content-Type: application/merge-patch+json' \
-H 'If-Match: "15"' \
-d '{ "nickname": "moomoo", "profileImageUrl": null }'
curl -X PATCH https://api.example.com/users/1 \
-H 'Content-Type: application/json-patch+json' \
-H 'If-Match: "15"' \
-d '[
{ "op": "replace", "path": "/nickname", "value": "moomoo" },
{ "op": "remove", "path": "/profileImageUrl" }
]'
운영 환경에서는 PATCH를 기본으로 사용하는 것이 가장 안전하다. 특히 배열과 같은 세밀한 조작이 필요하지 않은 한 JSON Merge Patch를 선택하는 것이 현실적이며 이 방식은 단순하면서도 멱등성을 보장하기 쉽다. 반대로 PUT은 반드시 전체 대체 의도가 있을 때만 사용해야 하며, 클라이언트가 불완전한 바디를 보내는 상황을 방지하기 위해 문서화와 검증이 필수적이다.
OpenAPI 문서에는 Content-Type, nullable 처리 규칙, 예제 요청과 응답을 반드시 포함해야 한다. 또한 동시성 제어를 위해 ETag를 필수화하는 것이 좋으며, 변경 내역을 로깅하거나 이벤트로 발행해 추적 가능성을 높이는 것도 중요하다. JSON Patch를 사용하는 경우에는 재시도 정책을 세우지 않으면 멱등성이 깨질 수 있으므로 주의해야 하고, 보안 측면에서는 필드 단위 권한 검사가 특히 중요하다.
결국 PUT = 전체 대체, PATCH = 부분 수정이라는 원칙을 지키면서, 운영에서는 Merge Patch를 기본값으로 삼고, 복잡한 케이스에만 JSON Patch나 별도 행위 기반 API를 도입하는 것이 최선이라는 결론을 얻었다.