JSON Toolkit

운영에서의 JSON Patch: 원자성, 멱등성, 동시 충돌

핵심 요약: JSON Patch는 변경 형식일 뿐이며 원자성, 멱등성, 동시성은 여전히 당신의 몫입니다. test 전제, 경로 이스케이프, 롤백, ETag 조정을 다룹니다.

고급약 10분 소요2026-10-31 발행2026-10-31 업데이트
운영에서의 JSON Patch 표지

핵심부터 말하면, 먼저 패치의 대상 경로와 권한을 검증한 뒤 test나 If-Match로 동시성 단언을 더하고, 패치를 사본에 적용해 실패 시 반쯤 쓰인 것이 남지 않게 하세요.

PATCH 요청의 진짜 위험은 틀린 필드를 쓰는 것이 아닙니다. 다른 사람의 변경을 조용히 덮어쓰면서 성공했다고 믿는 것입니다. JSON Patch가 도구를 주지만 안전 설계는 여전히 당신의 몫입니다.

1. RFC 6902 연산 집합 복습

JSON Patch는 변경을 연산 배열로 기술하며, 미디어 타입은 application/json-patch+json입니다. 여섯 연산이 배열 순서대로 실행됩니다:

연산 효과 핵심 제약
add 멤버 추가 또는 배열 요소 삽입 배열 인덱스가 길이와 같을 수 있음, -는 끝을 뜻함
remove 멤버 또는 요소 제거 대상 경로가 존재해야 함
replace 기존 값 교체 대상 경로가 존재해야 함
move 노드 이동 노드를 자기 자식 경로로 옮길 수 없음
copy 노드 복사 원천 경로가 존재해야 함
test 값 동등 단언 실패하면 패치 전체가 적용되지 않음

많은 사람이 앞 세 개만 기억하고, test가 무모한 쓰기를 조건부 쓰기로 바꾼다는 점을 잊습니다. 전체 연산 의미와 배열 경계는 JSON Pointer, Patch, Merge Patch에 있습니다.

2. test로 패치를 조건부 업데이트로 바꾸기

전형적인 낙관적 잠금 패치는 쓰기가 아니라 버전 단언으로 시작합니다:

{"op":"test","path":"/version","value":1}

완전한 패치에 넣습니다:

[
  { "op": "test", "path": "/version", "value": 1 },
  { "op": "replace", "path": "/version", "value": 2 },
  { "op": "replace", "path": "/status", "value": "paid" }
]

논리는 이렇습니다. 문서 버전이 지금 1임을 확인한 뒤 2로 바꿉니다. 다른 요청이 이미 버전을 2로 올렸다면 첫 test가 실패하고 패치 전체가 적용되지 않으며 서버는 충돌을 반환합니다. 분산 잠금 없이 lost update를 막습니다.

설계 포인트 몇 가지:

  • 모든 쓰기 전에 test를 두세요. 앞의 쓰기가 이미 효력을 냈다면 단언이 무의미합니다.
  • 클라이언트가 자유롭게 바꿀 수 있는 일반 필드가 아니라 서버가 생성한 버전 번호나 다이제스트를 단언하세요.
  • 성공한 쓰기마다 버전을 올리세요. 아니면 같은 옛 버전을 쓰는 두 요청이 모두 통과합니다.
  • test의 value는 문서 값과 JSON 타입이 정확히 같아야 합니다. 1과 "1"은 같지 않습니다.

3. 경로 이스케이프와 프로토타입 오염

JSON Pointer 이스케이프 규칙은 간단합니다. ~는 ~0, /는 ~1로 쓰고, /보다 ~를 먼저 치환하세요. 아니면 이중 이스케이프가 됩니다.

Key "a/b"    → /a~1b
Key "m~n"    → /m~0n
Key "a~/b"   → /a~0~1b

외부 패치의 위험은 대개 이스케이프가 아니라 위험한 키입니다. JavaScript에서 __proto__, constructor, prototype 같은 키는 평범한 add를 통해서도 프로토타입 오염을 일으킬 수 있습니다:

[
  { "op": "add", "path": "/__proto__/polluted", "value": true }
]

거부 목록이 아니라 허용 목록을 쓰세요:

  1. 패치가 업무 필드만 수정하게 허용합니다. 예: /status, /items/0/quantity
  2. 모든 path와 from을 정규식이나 경로 접두사로 검증하고, 배열 인덱스 외의 예상 밖 단계를 거부하세요.
  3. JSON을 Object.create(null)이나 Map 컨테이너로 파싱해 오염된 프로토타입 체인의 영향을 줄이세요.
  4. 연산 수와 각 value의 바이트 크기를 제한하세요.

4. 원자적 적용과 롤백

RFC 6902는 원자적 실행을 요구하지만, 원자성은 의미 요구일 뿐 모든 라이브러리의 기본값이 아닙니다. fast-json-patch 같은 라이브러리는 mutateDocument가 true면 문서를 제자리에서 변경하고, 실패 시 부분 변경을 남길 수 있습니다.

import { applyPatch, validate } from 'fast-json-patch';

/**
 * 패치를 불변으로 적용하고 실패 시 원본 문서를 반환합니다.
 * @param document 원본 문서, 호출자는 변경하면 안 됨
 * @param patch 적용할 JSON Patch 연산 배열
 * @returns 성공 플래그와 새 문서를 포함한 결과
 */
function applySafely(document: unknown, patch: unknown[]) {
  const errors = validate(patch, document);

  if (errors) {
    return { ok: false, document };
  }

  try {
    const result = applyPatch(document, patch, false, false);
    return { ok: true, document: result.newDocument };
  } catch {
    return { ok: false, document };
  }
}

구현 권장 세 가지:

  • 먼저 딥 복사나 구조적 복제하고 사본에 적용하며 성공 후에만 참조를 교체하세요.
  • 라이브러리의 자동 롤백에 의존하지 마세요. 예외 시 동작이 구현마다 다릅니다.
  • 실행 전 검증: validate는 구조만 검사하고 의미 충돌은 여전히 test가 필요합니다. 어느 것도 다른 것을 대체하지 않습니다.

패치가 여러 리소스에 걸치면(예: 주문과 재고를 함께 바꾸기) JSON Patch 원자성은 단일 문서 안에서만 성립합니다. 문서 간에는 데이터베이스 트랜잭션이나 보상 동작이 필요합니다.

5. 멱등성: PATCH는 자연히 멱등하지 않음

HTTP 의미는 PUT과 DELETE를 멱등으로 정의하지만 PATCH는 반드시 그렇지 않습니다. 같은 JSON Patch를 반복하면 완전히 다른 결과가 나올 수 있습니다:

  • add /items/-는 매번 배열 끝에 덧붙여 재시도가 중복을 만듭니다.
  • replace /version은 버전 1을 2로 바꾸고, 이미 적용된 뒤에도 재시도에서 다시 성공합니다.
  • remove /temp는 경로가 더 이상 없어 두 번째 실행에서 실패합니다.

멱등성을 달성하는 흔한 세 방법:

접근 동작 방식 적합
버전 전제 첫 연산이 test /version, 그다음 증가 명시적 버전이 있는 리소스
멱등 키 클라이언트가 Idempotency-Key 전송, 서버가 결과 캐시 결제, 주문, 기타 쓰기
대상 상태 패치 add 대신 replace를 써서 결과가 최종 값에만 의존 설정과 상태 필드

최악의 패턴은 재시도가 안전하다고 가정하면서 add와 remove를 섞는 패치입니다. 패치가 무엇이든 덧붙이거나 삭제하면 버전 단언이나 멱등 키와 짝지어야 합니다.

6. 동시 충돌: ETag와 If-Match

패치 안의 test 외에 HTTP는 표준 메커니즘을 제공합니다. 서버가 ETag를 반환하고 클라이언트가 다음 요청에서 If-Match를 보냅니다.

GET /orders/42 HTTP/1.1
Host: api.example.com

HTTP/1.1 200 OK
ETag: "a3f9c1"
Content-Type: application/json
PATCH /orders/42 HTTP/1.1
Host: api.example.com
If-Match: "a3f9c1"
Content-Type: application/json-patch+json

[{"op":"replace","path":"/status","value":"paid"}]

리소스가 바뀌었다면 서버는 412 Precondition Failed를 반환하고 클라이언트는 다시 가져온 뒤 재시도합니다. If-Match: *는 "리소스가 존재하는 한 실행"을 뜻해 덮어쓰기를 막아야 할 때 적합하지 않습니다.

test와 If-Match의 차이:

  • If-Match는 본문 파싱 전 HTTP 계층에서 검사되어 더 저렴합니다.
  • test는 패치 의미 계층에서 검사되고 서버 측 ETag 지원 없이도 어떤 필드든 단언할 수 있습니다.
  • 결합할 수 있지만, 하나만 구현하고 동시성에서 안전하다고 주장하지 마세요.

7. 라이브러리 차이와 선택

구현 언어 원자성 test 지원 비고
fast-json-patch JavaScript 선택(기본으로 새 문서 반환) 예 생태계 넓음, mutateDocument 주의
jsonpatch Python 예외 시 롤백 예 표준에 가깝고 오류가 명확
json-patch Go 사본에서 동작 예 JSON Merge Patch와 함께 자주 사용
zjsonpatch Java 예 예 Jackson 통합이 편리

"여섯 연산을 모두 지원"만으로 라이브러리를 고르지 마세요. 세 가지를 확인하세요. 실패 시 롤백하는지, test가 엄격히 비교하는지, 범위를 벗어난 배열 add가 오류를 내는지. 느슨한 구현은 같은 패치가 환경마다 다르게 동작하게 합니다.

8. 출시 전 안전 체크리스트

  • [ ] 엔드포인트가 application/json-patch+json을 선언합니다
  • [ ] 패치가 사본에 적용되고 실패가 원본 문서를 오염시키지 않습니다
  • [ ] 외부 패치의 모든 path와 from이 허용 목록 검사를 통과합니다
  • [ ] __proto__, constructor, prototype 같은 위험 키가 거부됩니다
  • [ ] 연산 수와 단일 값 크기가 제한됩니다
  • [ ] test나 If-Match가 동시성 제어를 제공합니다
  • [ ] 패치가 행위자, 타임스탬프, 전체 연산 배열과 함께 감사 로그에 기록됩니다
  • [ ] 패치가 문서에 걸칠 때 트랜잭션 경계가 명시됩니다

패치는 본질적으로 "무엇이 바뀌었는가"의 완전한 설명이라 자연스러운 감사 기록입니다. API 계층의 오류 코드와 멱등성은 JSON API 설계에서 이어가세요. 패치가 두 브랜치의 병합에서 왔다면 충돌 의미도 필요하며, JSON 파일의 Git 병합에서 다룹니다.

FAQ

실패한 test 연산은 어떤 상태 코드를 반환해야 하나?

충돌이 버전 단언에서 왔다면 409 Conflict와 412 Precondition Failed 모두 방어 가능합니다. If-Match와 일관되게 412를 선호하고, 응답 본문에 현재 버전을 넣어 클라이언트가 재시도하게 하세요.

add와 remove가 든 패치를 안전하게 재시도할 수 있나?

직접은 안 됩니다. 배열 끝에 대한 add는 데이터를 중복시키고, remove는 경로가 사라져 두 번째 실행에서 실패합니다. 버전 전제나 멱등 키를 더해 재시도가 같은 연산으로 인식되게 하세요.

JSON Patch가 문서 간 트랜잭션을 보장할 수 있나?

아니요. RFC 6902 원자성은 단일 JSON 문서에 적용됩니다. 주문, 재고, 기타 리소스가 관련되면 데이터베이스 트랜잭션, Saga 보상, 또는 메시지 큐로 일관성을 맞추세요.

패치가 왜 __proto__에 절대 쓰면 안 되나?

대부분 JavaScript 구현이 문서를 평범한 객체에 저장하므로 __proto__를 쓰면 프로토타입 체인이 바뀌고 이후 모든 객체에 영향을 줍니다. 경로 허용 목록이 가장 신뢰할 만한 방어이며, 라이브러리의 내부 필터링에만 의존하지 마세요.

PATCH 요청을 멱등하게 만들려면?

흔한 세 접근이 있습니다. 패치를 test /version으로 시작하고 성공 시 버전을 올리거나, 서버가 Idempotency-Key에 결과를 캐시하거나, add 대신 replace를 써서 결과가 최종 값에만 의존하게 하세요. 패치가 무엇이든 덧붙이거나 삭제하면 버전 단언이나 멱등 키와 짝지어야 하며, 단순 재시도는 안 됩니다.

test와 If-Match를 둘 다 써야 하나?

둘 다 쓸 필요는 없지만 하나만 구현한다고 엔드포인트가 동시성에서 안전해지지 않습니다. If-Match는 본문 파싱 전 HTTP 계층에서 검사되어 더 저렴하고, test는 패치 의미 계층에서 검사되며 서버 측 ETag 지원 없이 어떤 필드든 단언할 수 있습니다.