PUT vs PATCH 차이 정리

송현진·2025년 8월 25일
0

Architecture

목록 보기
18/18

PUT과 PATCH 한 줄 정의

  • PUT: 리소스를 “통째로 대체”하는 메서드이다. 요청 바디에 해당 리소스의 완전한 표현(representation)을 담아야 하며, 서버는 이를 기존 리소스와 교체한다. 즉, 기존 리소스를 날려버리고 새로운 리소스로 갈아끼운다고 생각하면 된다.
  • PATCH: 리소스를 “부분적으로 수정”하는 메서드이다. 요청 바디에는 전체 리소스가 아닌, 바꾸고 싶은 필드만 전달한다. 서버는 해당 변경 사항만 적용하고 나머지는 그대로 둔다.
항목PUTPATCH
의미대상 리소스를 대체대상 리소스의 일부 변경
바디보통 전체 필드 포함변경 필드만 포함
멱등성(Idempotence)멱등적 (같은 요청 반복해도 결과 동일)구현에 따라 다름 (JSON Merge Patch는 실무상 멱등적으로 설계 가능, JSON Patch는 보통 멱등X)
부분 업데이트권장하지 않음핵심 용도
필드 생략보통 “기존 유지”가 아닌, 스키마에 따라 다름 (실수 잦음)기본적으로 생략=미변경 (JSON Merge Patch), 명령적 변경(JSON Patch)
실패 가능 포인트전체 대체라 누락 필드 처리경로/연산 오류, 배열 인덱스, null 의미
대표 Content-Typeapplication/jsonapplication/merge-patch+json (RFC 7396), application/json-patch+json (RFC 6902)

“전체 대체” 의도가 아니면 PUT을 쓰지 않는 게 안전하다. 부분 수정이면 PATCH가 기본값이다.

PATCH의 두 가지 표준

1. JSON Merge Patch (RFC 7396)

JSON Merge Patch는 가장 단순한 형태의 부분 업데이트 방법으로 요청 바디가 평범한 JSON 객체 형태를 띤다. 이 방식에서 필드의 의미는 명확하게 정의되어 있다. 키가 생략되면 해당 필드는 변경하지 않음을 의미하고 키가 존재하면서 값이 주어지면 그 값으로 대체한다. 또한 키가 존재하면서 값이 null이면 해당 필드를 삭제하거나 null로 초기화한다. 이때 null의 의미가 삭제인지 초기화인지는 서버에서 어떻게 해석할지 반드시 문서화해야 한다.

장점으로는 구조가 단순해 바디 크기가 작고 같은 요청을 여러 번 보내도 결과가 동일하게 유지되므로 멱등성을 보장하기 쉽다. 그러나 단점은 배열을 다룰 때 나타난다. Merge Patch에서 배열은 기본적으로 “전체 교체”의 의미를 가지므로 배열 요소의 부분 삽입이나 삭제를 표현하기가 애매하다.

PATCH /users/1
Content-Type: application/merge-patch+json

{
  "nickname": "moomoo",
  "profileImageUrl": null
}

2. JSON Patch (RFC 6902)

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 — 전체 대체 (Replace)

아래 코드는 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);
    }
}

PATCH — JSON Merge Patch

Merge Patch를 사용하는 경우 요청 본문을 JsonNode로 받아서 기존 DTO에 덮어씌우는 방식으로 구현할 수 있다. ObjectMapperreaderForUpdating 기능을 사용하면 기존 객체에 부분적으로 새로운 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);
}

PATCH — JSON Patch

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 기반 동시성 제어

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);

테스트 전략 (Spring MockMvc)

테스트에서는 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

PUT

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
  }'

PATCH (Merge Patch)

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 }'

PATCH (JSON Patch)

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(Merge)를 사용해 nickname, bio, imageUrl 등을 선택적으로 수정한다.
  • 배송지 변경: PATCH(Merge)를 이용해 address1/2, zip, isDefault 등을 변경한다.
  • 장바구니 항목: 배열 조작이 필요할 수 있어 JSON Patch를 쓰거나, 아예 별도의 행위 기반 API(POST/DELETE)로 구현하는 것이 단순하다.
  • 관리자 배치 수정: 대량 수정의 경우 “벌크 PATCH” 엔드포인트를 설계해 부분 성공/실패 목록을 함께 반환하도록 한다.

📝 배운점

운영 환경에서는 PATCH를 기본으로 사용하는 것이 가장 안전하다. 특히 배열과 같은 세밀한 조작이 필요하지 않은 한 JSON Merge Patch를 선택하는 것이 현실적이며 이 방식은 단순하면서도 멱등성을 보장하기 쉽다. 반대로 PUT은 반드시 전체 대체 의도가 있을 때만 사용해야 하며, 클라이언트가 불완전한 바디를 보내는 상황을 방지하기 위해 문서화와 검증이 필수적이다.

OpenAPI 문서에는 Content-Type, nullable 처리 규칙, 예제 요청과 응답을 반드시 포함해야 한다. 또한 동시성 제어를 위해 ETag를 필수화하는 것이 좋으며, 변경 내역을 로깅하거나 이벤트로 발행해 추적 가능성을 높이는 것도 중요하다. JSON Patch를 사용하는 경우에는 재시도 정책을 세우지 않으면 멱등성이 깨질 수 있으므로 주의해야 하고, 보안 측면에서는 필드 단위 권한 검사가 특히 중요하다.

결국 PUT = 전체 대체, PATCH = 부분 수정이라는 원칙을 지키면서, 운영에서는 Merge Patch를 기본값으로 삼고, 복잡한 케이스에만 JSON Patch나 별도 행위 기반 API를 도입하는 것이 최선이라는 결론을 얻었다.

profile
개발자가 되고 싶은 취준생

0개의 댓글