같은 null이라도 요청 형식의 계약에 따라 뜻이 달라집니다
수정 요청에서 필드를 빼는 것과 그 필드를 null로 보내는 것을 같게 처리하면 값이 예상과 다르게 사라질 수 있습니다. JSON Merge Patch는 이 차이를 명시적으로 사용하는 형식입니다. 객체 패치에 없는 멤버는 원래 값을 유지하고, null인 멤버는 대상 객체에서 삭제합니다. 그러나 모든 JSON API가 이 규칙을 쓰는 것은 아닙니다. 서버가 어떤 미디어 타입과 수정 계약을 지원하는지 확인한 뒤에 적용해야 합니다. JSON이라는 이름만 보고 null을 공통 삭제 명령처럼 해석하지 마세요.
이번 글은 RFC 7396의 값 변환 규칙을 작은 Python 함수로 구현하고, 생략·삭제·배열 교체·중첩 삭제·문서 전체 교체·없는 필드 삭제의 여섯 사례를 실행했습니다. 외부 API 요청은 보내지 않았습니다. HTTP 응답 코드나 실제 서비스의 권한 검사를 확인한 실험도 아닙니다. 요청 문서가 어떤 결과 값을 만드는지 먼저 분리해 확인하면, 실제 통신에서 생기는 인증·동시 수정·재시도 문제를 그 위에 정확히 연결할 수 있습니다.
시작할 때는 수정하려는 필드를 나열하는 것만으로 부족합니다. 값을 바꿀 것인지, 필드를 없앨 것인지, 현재 값을 유지할 것인지 세 가지 의도를 따로 적어 보세요. 특히 양식의 빈 입력칸을 서버에 어떻게 보내는지 확인해야 합니다. 화면에서 빈 칸이라는 한 상태가 저장 계층의 생략, 빈 문자열, null이라는 여러 표현 중 무엇으로 바뀌는지에 따라 결과가 달라집니다.
원본에 일반 값과 배열, 중첩 객체와 null을 함께 넣었습니다
가상 원본의 title은 draft, description은 keep입니다. tags에는 review와 source 두 문자열을 넣었고 profile에는 city와 name을 넣었습니다. nullable이라는 별도 멤버에는 원래 null을 저장했습니다. 서로 다른 자료형을 한 문서에 둔 이유는 객체의 일부 수정과 배열 교체가 같은 방식으로 보이지 않게 하기 위해서입니다. 다운로드 결과에는 원본과 각 패치, 기대 결과, 실제 결과가 따로 들어 있어 특정 사례를 원본과 바로 대조할 수 있습니다.
{"title":"draft","description":"keep","tags":["review","source"],"profile":{"city":"Seoul","name":"editor"},"nullable":null}패치마다 원본을 새로 기준 삼았습니다. 첫 패치를 적용한 결과에 다음 패치를 연속으로 적용한 실험이 아닙니다. 따라서 표의 각 행은 서로 독립적입니다. 연속 수정이라면 이전 결과가 다음 대상이 되므로 같은 패치도 다른 결과를 만들 수 있습니다. 검사 원장에 기준 문서의 버전을 함께 적는 이유입니다. 패치만 보관하고 대상 문서를 잃으면 나중에 무엇이 유지되고 무엇이 바뀌었는지 완전히 설명하기 어렵습니다.
원본이 그대로 남는지도 확인했습니다. 중첩 객체를 얕게 복사한 뒤 안쪽 값을 바꾸면 원본까지 함께 달라질 수 있으므로 deepcopy를 사용했습니다. 이 검사는 로컬 값 변환의 부작용을 줄이기 위한 것입니다. 데이터베이스에 저장되는 원자성이나 여러 사용자의 동시 수정까지 보장하는 트랜잭션을 만들었다는 뜻은 아닙니다.
보내지 않은 필드는 유지하고, 객체 안의 null은 삭제합니다
첫 사례는 title만 revised로 보냅니다. 결과에서는 제목만 달라졌고 description, tags, profile과 원래의 nullable은 그대로 남았습니다. 두 번째 사례는 description을 null로 보냈습니다. 이때 결과 객체에서 description 멤버 자체가 없어졌습니다. 값이 null인 멤버가 새로 저장된 것이 아닙니다. 따라서 응답에서 필드 존재 여부를 보는 검사와 값이 null인지 보는 검사는 구분해야 합니다. 두 질문이 같은 결과를 뜻하지 않습니다.
원래 대상에 null이 있던 nullable 멤버는 해당 필드를 패치에서 생략했기 때문에 유지됐습니다. JSON Merge Patch가 문서의 모든 null을 일괄 삭제하는 규칙은 아니라는 뜻입니다. 삭제 의미를 갖는 null은 객체 패치의 해당 위치에 있습니다. 원본의 null과 패치의 null을 구분하지 않으면 문서를 읽기만 해도 어떤 필드가 삭제될 것처럼 오해하기 쉽습니다. 실제 요청을 만들 때도 원본을 통째로 복사한 뒤 무심코 null 필드를 포함하는 작업을 주의해야 합니다.
| 수정 의도 | 이번 패치 | 결과 |
|---|---|---|
| 제목만 변경 | title을 revised로 지정 | 나머지 멤버 유지 |
| 설명 제거 | description에 null | description 멤버 삭제 |
| 없는 멤버 제거 | missing에 null | 원본과 동일 |
중첩 객체는 내려가며 병합하지만 배열은 전체가 바뀝니다
세 번째 사례의 tags 패치는 edited 한 개만 담은 배열입니다. 결과는 review와 source 뒤에 edited가 추가된 세 항목 배열이 아니라 edited 하나의 배열입니다. 이 형식에서 배열은 원소별 부분 병합 대상이 아니라 새 값으로 교체됩니다. 화면에서 태그 하나를 추가하려는 의도라면 기존 항목까지 포함한 새 배열을 만들거나, 서버가 제공하는 별도의 항목 추가 계약을 사용해야 할 수 있습니다. 배열의 의미를 모르고 짧은 패치만 보내면 기존 항목을 의도치 않게 지울 수 있습니다.
네 번째 사례는 profile 안의 city를 null로 보내고 lang을 ko로 추가했습니다. 결과에서 city는 없어지고 name은 editor로 유지되며 lang이 새로 생겼습니다. 바깥 객체의 다른 멤버도 그대로 남습니다. 이 차이는 값이 객체인지 배열인지에 따라 처리 경로가 달라지기 때문에 생깁니다. 문서 안에 여러 자료형이 섞여 있다면 필드별로 예상되는 교체 단위를 확인해야 합니다. 중첩이라는 이유만으로 모든 자료형이 원소 수준으로 병합되는 것은 아닙니다.
배열 안 객체의 한 필드만 바꾸고 싶은 경우도 같은 함정이 있습니다. 배열 전체를 보내는 과정에서 다른 사용자가 추가한 항목을 놓칠 수 있습니다. 이 글의 코드는 그런 실제 동시 수정 상황을 만들지 않았습니다. 다만 무엇을 통째로 교체하는지 알고 있어야 서버의 버전 검사나 항목 식별자 계약이 왜 필요한지 판단할 수 있습니다.
문서 전체에 null을 보내면 객체 멤버 삭제와 다른 결과가 나옵니다
다섯 번째 사례의 패치 자체는 null입니다. 객체 안의 한 멤버가 아니라 패치 문서의 최상위 값입니다. 이때 전체 결과가 null로 교체됐습니다. 객체 안의 null이 삭제를 뜻한다고 외웠다가 최상위 null을 빈 객체로 해석하면 서로 다른 결과를 만들게 됩니다. 코드도 패치가 객체인지 먼저 확인하고, 객체가 아니면 대상 전체를 그 값의 복사본으로 바꾸는 경로로 처리합니다. 조건의 순서를 보면 두 동작이 어디에서 갈라지는지 확인할 수 있습니다.
추가로 대상이 문자열 old인데 객체 패치로 priority를 1로 보내는 사례도 실행했습니다. 결과는 priority 하나를 가진 객체입니다. 대상이 객체가 아닐 때 객체 패치를 적용하는 규칙도 확인하기 위한 입력입니다. 이것은 서버의 실제 데이터 모델이 문자열과 객체 사이의 변경을 허용한다는 뜻은 아닙니다. 서버는 별도의 스키마나 권한 규칙으로 이런 결과를 거절할 수 있습니다. 형식의 변환 결과와 특정 서비스가 받아들이는 결과는 나눠 판단해야 합니다.
실무에서는 변경 후 허용되는 최상위 자료형을 명시하는 것이 도움이 됩니다. 화면이 객체를 기대하는데 전체 결과가 null이나 배열로 바뀌면 다음 처리 단계가 실패할 수 있습니다. 패치 요청이 파싱됐다는 사실뿐 아니라 적용 결과가 다음 계약을 만족하는지도 검사해야 합니다. 특히 삭제 가능한 멤버와 문서 전체 삭제를 같은 UI 동작으로 묶을 때는 의도가 분명해야 합니다.
같은 패치를 반복한 값은 같았지만 HTTP 재시도 보장은 아닙니다
여섯 기본 사례에서는 결과에 같은 패치를 다시 적용해도 값이 달라지지 않았습니다. 삭제한 필드를 다시 지우거나, 같은 문자열과 배열로 다시 교체해도 추가 변화가 없었습니다. 이 결과는 이번 순수 값 변환의 반복 성질을 확인한 것입니다. 실제 서버가 수정 때마다 수정 이력을 추가하거나 알림을 보내는지, 재시도 과정에 다른 사용자의 변경이 끼어드는지까지 실행한 것은 아닙니다. 같은 요청 본문이라는 이유만으로 모든 PATCH 요청을 무조건 재시도해도 된다는 결론으로 확대하지 않습니다.
서버 통신에서는 대상 문서의 버전을 확인하는 조건이 필요할 수 있습니다. 예를 들어 읽은 문서의 버전과 현재 버전이 다르면 적용을 보류하는 계약을 두면 오래된 배열로 새 내용을 덮어쓰는 일을 줄일 수 있습니다. RFC 5789가 설명하는 HTTP PATCH의 조건과, RFC 7396이 정하는 패치 문서의 값 변환은 서로 연결되지만 같은 층의 규칙은 아닙니다. 이 글의 실행 결과는 후자에 대한 작은 재현입니다.
다운로드 코드에서 sourceUnmodified가 참인지, 각 사례의 repeatSamePatchUnchanged가 참인지 직접 확인할 수 있습니다. 하나는 원본을 바꾸지 않았다는 검사이고 다른 하나는 같은 패치의 반복 결과 검사입니다. 이름이 비슷한 통과 값을 하나로 합치지 않으면, 무엇이 검증됐고 무엇이 아직 서버 계약에 달렸는지 더 분명해집니다.
생략·삭제·빈 문자열을 양식과 스키마에서도 따로 다룹니다
사용자가 설명란을 비웠을 때 필드를 보내지 않을지, 빈 문자열을 보낼지, 멤버를 삭제할지 먼저 정해야 합니다. 이 선택을 프런트엔드와 서버가 다르게 이해하면 사용자는 저장했다고 생각하지만 이전 값이 남거나, 반대로 유지하려던 필드가 사라질 수 있습니다. 입력칸의 현재 표시만으로 수정 의도를 추측하지 않고, 변경 여부와 삭제 의도를 별도 상태로 관리할 수 있습니다. 구체적인 UI 설계는 서비스마다 다르지만 요청 문서로 바뀌는 경계는 확인할 수 있습니다.
스키마를 바꾸면서 필드 이름도 바뀌는 경우에는 패치의 대상 이름도 함께 점검하세요. 예전 이름으로 null을 보내면 새 필드를 지우는 것이 아니라 이미 없는 이름을 지우는 요청이 될 수 있습니다. 필드명 변경과 명시적 버전 이관 검사에서 이런 계약 변화의 출발점을 확인할 수 있습니다. 입력이 읽히는 단계부터 나눠 보고 싶다면 JSON 문법과 스키마를 분리하는 가이드도 함께 읽어 보세요.
실제 도입 전에는 정상 패치뿐 아니라 유지하려던 필드를 생략한 사례, 빈 배열로 교체한 사례, 없는 이름을 삭제한 사례, 허용되지 않는 최상위 값도 고정 입력으로 정해 두면 좋습니다. 기대 결과를 사람이 먼저 확인하고 코드 결과와 대조해야 검사가 구현을 그대로 따라 하는 형식적인 통과표가 되지 않습니다.
실행된 범위는 값 변환이며 운영 승인까지 대신하지 않습니다
이번 결과는 여섯 독립 패치와 비객체 대상 추가 사례에서 얻었습니다. 사용자 인증, 허용 필드, 객체 크기, 최대 중첩 깊이, 실제 API의 적용 원자성이나 변경 이력은 시험하지 않았습니다. 깊거나 큰 외부 문서를 처리하려면 재귀와 메모리 사용의 한도도 필요합니다. 예제 함수는 이런 운영 조건을 모두 갖춘 서버 구현이 아니라, 각 입력이 어떤 결과를 만드는지 읽기 쉽도록 제한한 재현 코드입니다.
적용할 API를 정했다면 먼저 그 문서가 JSON Merge Patch를 지원하는지 확인하고, 필드별 유지·삭제·교체 의도를 표로 적어 보세요. 다음으로 원본 버전과 예상 결과를 저장한 뒤 작은 가상 자료에서 실행하면 됩니다. 결과가 맞더라도 실제 문서에 쓰기 전에는 서비스의 권한과 동시 수정 조건을 별도로 확인해야 합니다. 필드를 안 보낸 것과 null로 보낸 것의 차이를 끝까지 유지하는 것이 이 검수의 출발점입니다.
입력과 결과를 직접 대조하는 재현 자료
실험 실행일은 2026년 10월 15일이며 이 글의 발행 일정과 구분합니다. Python 3 표준 라이브러리로 가상 자료만 처리했습니다. 아래 파일은 기존 사용자 파일을 인자로 받지 않습니다.
python3 json-merge-patch-semantics-lab-20261015.py실행 시각과 런타임은 환경에 따라 달라집니다. 판정 근거는 다운로드 결과의 고정 입력과 각 사례의 실제 출력으로 확인하세요.
규칙을 확인한 공식 원문
아래 원문은 2026년 10월 15일 확인했습니다. 형식과 동작의 설명을 확인한 출처이며, 이 글의 가상 실험이나 실제 업무 성과를 대신 증명하지 않습니다.
- RFC 7396: JSON Merge Patch — 객체 null 삭제, 배열 전체 교체, 비객체 패치의 전체 교체 규칙을 확인했습니다.
- RFC 5789: HTTP PATCH — PATCH 메서드와 패치 문서의 형식·적용 조건을 분리해 확인했습니다.
- Python copy — 중첩 값의 원본 보존을 위한 deepcopy 동작을 확인했습니다.



