JSON Pointer, Patch, Merge Patch
핵심 요약: 세 개의 짧은 명세가 변경 프로토콜을 이룹니다. Pointer는 찾고, Patch는 정확히 편집하며, Merge Patch는 부분적으로 병합합니다. 잘못 고르면 배열과 동시성에서 다칩니다.

핵심부터 말하면, 위치로 찾을 때는 JSON Pointer, 필터나 재귀에는 JSONPath를 쓰세요. 배열 삽입/삭제, null 쓰기, 버전 단언은 JSON Patch에 맡기고, Merge Patch는 평평한 설정과 폼 리소스에 남겨 두세요.
찾기와 수정은 다른 일이고, 명세는 이를 세 문서로 나눕니다. 혼동의 흔한 대가는 "배열 전체가 교체되었는데 아무도 눈치채지 못함"입니다.
1. JSON Pointer: 가장 짧은 찾기 문법
RFC 6901은 정확히 한 가지를 합니다. 문자열로 문서의 노드를 가리킵니다.
"" → the whole document
"/store" → the store member under the root
"/store/book" → one level deeper
"/store/book/0/title" → item 0 of the array
세 규칙:
- 빈 문자열은 "빈 노드"가 아니라 루트 노드 자체를 가리킵니다.
- 각
/가 하나의 참조 토큰을 나눕니다. 배열은 십진 인덱스를 쓰고(앞의 0은 허용되지 않아01은 무효), - 토큰 안에서
~는~0,/는~1로 씁니다./보다~를 먼저 치환해야 합니다. 아니면 이중 이스케이프가 됩니다.
/**
* 단일 참조 토큰을 JSON Pointer 세그먼트로 이스케이프합니다.
* @param token 원본 키 이름 또는 인덱스
* @returns 이스케이프된 토큰
*/
function escapePointerToken(token: string): string {
return token.replace(/~/g, '~0').replace(/\//g, '~1');
}
/**
* 키 이름 배열을 JSON Pointer로 잇습니다.
* @param tokens 키 이름 또는 인덱스 배열
* @returns /로 시작하는 포인터 문자열
*/
function buildPointer(tokens: Array<string | number>): string {
return tokens.map((token) => `/${escapePointerToken(String(token))}`).join('');
}
키 자체에 /가 있을 때 가치가 가장 잘 드러납니다. {"a/b": 1}의 포인터는 /a/b가 아니라 /a~1b입니다(전자는 a 아래의 b를 뜻함).
URL 안에서 쓸 때는 퍼센트 인코딩도 필요합니다. #과 ~가 프래그먼트에서 특별한 의미를 갖기 때문입니다.
2. JSON Patch: 여섯 연산의 원자적 시퀀스
RFC 6902는 변경을 연산 배열로 기술하며, 미디어 타입은 application/json-patch+json입니다:
[
{ "op": "test", "path": "/version", "value": 7 },
{ "op": "replace", "path": "/name", "value": "新的名字" },
{ "op": "add", "path": "/tags/-", "value": "json" },
{ "op": "remove", "path": "/temp" },
{ "op": "copy", "from": "/name", "path": "/alias" },
{ "op": "move", "from": "/tags/0", "path": "/tags/1" }
]
| 연산 | 의미 | 경계 |
|---|---|---|
add |
멤버를 더하거나 배열 요소를 삽입 | 배열 인덱스가 길이와 같을 수 있음(append), -는 끝을 뜻함 |
remove |
제거 | 경로가 존재해야 함 |
replace |
기존 값 교체 | 경로가 존재해야 함(remove + add와 같지만 경로가 먼저 존재해야 함) |
move |
이동 | 노드를 자기 하위 경로로 옮길 수 없음 |
copy |
복사 | 원천이 존재해야 함 |
test |
동등 단언 | 실패하면 패치 전체가 적용되지 않음, 낙관적 잠금에 사용 |
핵심 속성은 원자성입니다. 어느 연산이 실패하면 패치 전체가 롤백되어 "앞 세 개가 적용되고 네 번째가 실패"하는 중간 상태가 없습니다.
그래서 PATCH 요청 본문으로 훌륭합니다.
2.1 test로 낙관적 동시성 제어
[
{ "op": "test", "path": "/etag", "value": "a3f9c1" },
{ "op": "replace", "path": "/status", "value": "paid" }
]
클라이언트가 먼저 etag를 GET하고 test 연산에 씁니다. 동시 쓰기에서 두 번째 요청의 test가 실패하고 서버는
409 Conflict나 412 Precondition Failed를 반환합니다. 마지막 쓰기 승리보다 훨씬 안전하고 분산 잠금이 필요 없습니다.
2.2 배열 연산이 가장 실수가 잦음
// Want to insert at index 1
{ "op": "add", "path": "/items/1", "value": {...} } // Correct: insert; the original index 1 and everything after shift back
// Want to replace index 1
{ "op": "replace", "path": "/items/1", "value": {...} } // Correct: replace
배열 인덱스 n에서의 add는 덮어쓰기가 아니라 삽입입니다. 범위를 벗어난 인덱스(n > length)는 오류를 내야 합니다.
일부 구현이 관대해서 같은 패치가 서버마다 다르게 동작합니다.
3. Merge Patch: 대상을 닮은 조각
RFC 7386은 규칙이 네 개뿐입니다:
- 대상이 객체이고 패치가 객체 → 재귀 병합
- 패치의 키 값이
null→ 대상에서 그 키를 삭제 - 패치의 키 값이 객체가 아님 → 바로 교체
- 패치 자체가 객체가 아님 → 대상 전체를 교체
// Target
{ "a": "b", "c": { "d": "e", "f": "g" } }
// Patch
{ "a": "z", "c": { "f": null } }
// Result
{ "a": "z", "c": { "d": "e" } }
미디어 타입은 application/merge-patch+json입니다. "부분 업데이트"처럼 읽혀 오용하기 특히 쉽습니다:
- 배열은 통째로 교체만 됩니다.
list[2]를 삭제하고 싶나? Merge Patch는 못 합니다. null을 쓸 수 없습니다.null이 이미 삭제를 뜻하기 때문입니다.- "이 필드는 그대로 두기"와 "이 필드 삭제" 사이의 세 번째 상태(예: 명시적으로 비우기)를 표현하지 못합니다.
4. 능력 매트릭스: 어느 것을 고를지
| 요구 | JSON Patch | Merge Patch |
|---|---|---|
| 특정 배열 요소를 정확히 교체 | ✓ | ✗ |
| 배열에서 항목 하나 삭제 | ✓ | ✗ |
| 여러 필드를 부분 업데이트 | ✓ (여러 replace) | ✓ (가장 간결) |
null 값 쓰기 |
✓ | ✗ |
| 동시성 제어(버전 단언) | ✓ (test) | ✗ (If-Match로만) |
| 패치 가독성 | 중간 | 좋음(데이터 자체처럼 보임) |
| 구현 복잡도 | 높음 | 낮음 |
| 적합한 리소스 모양 | 깊이 중첩, 목록형 | 평평한 설정, 폼 |
경험 법칙:
- 폼, 설정 페이지, 설정 리소스 → Merge Patch, 프런트엔드는 변경된 필드만 제출
- 목록 추가와 삭제, 상태 기계 전이, 동시성 보호가 필요한 모든 것 → JSON Patch
- 둘은 공존할 수 있습니다. 엔드포인트마다 다른
Content-Type을 선언하고 서버가 타입으로 분기합니다.
5. 구현 노트
import { applyPatch, compare } from 'fast-json-patch';
const document = { version: 7, items: [{ id: 1 }, { id: 2 }] };
const patch = compare(document, { version: 8, items: [{ id: 1 }] });
// test operations are validated by default; setting mutateDocument to true modifies in place, use with caution
const result = applyPatch(document, patch, true, false);
console.log(result.newDocument);
놓치기 쉬운 네 가지:
- 공유 객체를 제자리에서 변경하지 마세요. 실패한 패치는 반쯤 수정된 상태를 남깁니다(구현이 롤백을 보장하지 않는 한).
- 경로 검증: 외부 패치는 모든
path/from을 검증하고__proto__,constructor같은 키를 거부해야 합니다. - 크기 제한: 패치 항목 수를 제한해 단일 패치가 수십만 연산을 수행하지 않게 하세요.
- 감사: 패치는 "무엇이 바뀌었는가"의 완전한 설명이라 감사 로그에 자연히 어울립니다.
6. 오류 의미
| 상황 | 권장 상태 코드 | 설명 |
|---|---|---|
| 패치가 배열이 아니거나 구조가 무효 | 400 Bad Request |
요청 본문 형식이 틀림 |
path가 참조하는 노드가 없음 |
422 Unprocessable Entity |
문법은 유효하지만 의미상 실행 불가 |
test 연산 실패 |
409 또는 412 |
버전 충돌을 뜻함 |
| 해당 경로 수정 권한 없음 | 403 Forbidden |
리소스 존재를 의도적으로 숨기는 게 아니라면 404로 가리지 마세요 |
400과 422를 구분하는 것이 중요합니다. 전자는 "패치를 이해할 수 없다", 후자는 "이해했지만 이 데이터에서는 그 변경이 허용되지 않는다"입니다.
7. 체크리스트
- [ ] 엔드포인트가 올바른
Content-Type(json-patch+json/merge-patch+json)을 선언합니다 - [ ] 패치 적용이 원자적이고 실패 시 부분 결과가 남지 않습니다
- [ ] 외부 패치가 경로 화이트리스트와 항목 수 제한으로 검증됩니다
- [ ] 동시성 제어가
test나If-Match를 씁니다 - [ ] 패치가 감사 로그에 기록됩니다
- [ ] 배열 시나리오는 JSON Patch를 명시적으로 고릅니다. Merge Patch에 기대지 마세요
FAQ
JSON Patch와 Merge Patch 중 어떻게 결정하나?
특정 배열 요소를 교체하거나 제거하고, null 값을 쓰며, 버전을 단언해야 하면 JSON Patch, 몇 필드만 갱신하는 폼과 설정 리소스에는 Merge Patch를 쓰세요. Merge Patch는 배열 항목 하나를 삭제하지 못하고 null도 쓸 수 없습니다. null이 이미 삭제를 뜻하기 때문입니다.
배열 인덱스 n의 add가 왜 덮어쓰기가 아닌가?
add가 배열에 삽입하기 때문입니다. 인덱스 n의 요소와 그 뒤 모든 것이 밀립니다. 기존 요소를 교체하려면 replace를 써야 하고 경로가 먼저 존재해야 합니다. 범위를 벗어난 인덱스(n > length)는 오류를 내야 하며, 관대한 구현은 같은 패치가 서버마다 다르게 동작하게 합니다.
원본 객체를 제자리에서 수정해도 되나?
아니요. 구현이 롤백을 보장하지 않는 한 실패한 패치는 반쯤 수정된 상태를 남깁니다. 딥 복사나 구조적 복제한 사본에 패치를 적용하고 성공 후 참조를 교체하세요.