JSON Toolkit

두 JSON 문서가 다를 때: 위치 diff, id 기반 diff, 또는 먼저 정규화

핵심 요약: 레코드 50개가 51개가 되면 diff가 51개 변경을 보고합니다. 텍스트 diff의 거짓 양성, id로 배열 매칭, jq 정규화, 적용 가능한 패치를 다룹니다.

초급약 6분 소요2026-10-30 발행2026-10-30 업데이트
두 JSON 문서 비교 표지

핵심부터 말하면, 먼저 정규화한 뒤 비교하세요. jq -S나 안정적 직렬화로 키 순서, 들여쓰기, 숫자 표기를 지우고, 순서가 무의미할 때 업무 키로 배열을 정렬하거나 인덱싱하며, 그다음에 diff를 패치로 바꾸세요.

JSON 차이는 텍스트 차이가 아닙니다. 파일을 문자열로 비교하면 키 재정렬 하나가 페이지 전체를 켭니다. 배열을 인덱스로 비교하면 레코드 하나를 삽입할 때 나머지 50개가 변경으로 표시됩니다.

주문 서비스에 패치가 출시되었고, 손에는 두 응답만 있습니다. 전에는 레코드 50개, 후에는 51개이며 하나가 중간에 삽입되었습니다. diff 도구는 51개 변경을 보고하고, 빨간 벽이 있으며, 여전히 어떤 레코드가 진짜 움직였는지 알 수 없습니다.

도구가 고장 난 것이 아닙니다. "비교"는 JSON에서 세 가지 다른 뜻이고, 대부분은 기본적으로 가장 유용하지 않은 것을 꺼냅니다.

거짓 경보 재현: 삽입 하나, 변경 51개

Node 22로 서른 줄: 주문 50개({id, sku, qty})를 만들고, 꼬리에 하나를 덧붙이고 머리에 하나를 삽입하며, 인덱스별 정렬로 차이를 셉니다.

동작 레코드 위치 비교가 보고한 변경
꼬리에 하나 덧붙임 50 → 51 1
머리에 하나 삽입 50 → 51 51

같은 "레코드 하나 더"인데 소음이 51배입니다. 이유는 평범합니다. 인덱스로 비교하면 새 요소가 위치 0에 떨어지고 기존 레코드가 모두 한 슬롯씩 밀립니다. 위치 0 대 위치 0, 위치 1 대 위치 1 — 어떤 쌍도 같지 않습니다.

대신 id를 키로 하는 Map을 만들면 같은 데이터가 정확히 한 항목을 보고합니다. $[id=ORD-999] added.

그 실험이 어떤 도구 권장보다 중요한 규칙을 줍니다. 배열이 정렬된 시퀀스인지 순서 없는 집합인지가 어떤 비교를 써야 하는지 결정합니다.

텍스트 diff가 JSON에 대해 거짓말하는 세 가지 방식

diff a.json b.json은 문자를 비교합니다. JSON은 데이터 구조를 담습니다. 그 불일치가 세 가지 신뢰할 만한 거짓 양성을 만듭니다.

1. 재정렬된 객체 키

RFC 8259 4절은 분명합니다. 객체는 "이름/값 쌍의 순서 없는 컬렉션"입니다. 두 문서는 같은 값입니다:

{ "id": 42, "name": "Ada", "active": true }
{ "active": true, "name": "Ada", "id": 42 }

줄 diff는 세 줄 모두 변경이라고 말합니다. 키 10개 객체의 키 순서를 뒤집고 양쪽을 pretty-print한 뒤 줄 단위로 비교했습니다. 10줄 모두 표시되었고, 구조 비교는 동등하다고 말합니다. 소음 비율 10:0.

키 순서가 왜 바뀌나? 직렬화기 전환, 정렬 키를 쓰는 포매터 실행, 사전이나 해시 맵에서 객체 재구성, 언어 런타임 간 이동 — 어느 하나만으로도 동일한 데이터가 디스크에서 달라 보입니다.

2. 공백과 들여쓰기

{"a":1}과 {\n "a": 1\n}은 같은 값으로 파싱됩니다. 텍스트 diff는 줄 바꿈과 공백을 보고 모두 표시합니다.

3. 숫자와 이스케이프의 다른 표기

1.0과 1, 1e2와 100, "\u00e9"와 "é" — 파싱 후 같은 값이고 직렬화 후 다른 텍스트입니다. 이 경우는 줄 몇 개만 건드려 milder하지만, 설정 리뷰에서는 숫자가 바뀌었다고 생각하게 만들기 충분합니다.

반대 사례는 텍스트가 볼 수 없고 구조가 볼 수 있는 것입니다. 5 대 "5". 따옴표 한 쌍이 더 붙었지만 타입이 어긋났습니다. 대개 갱신이 아니라 버그입니다. 구조 비교는 주저 없이 이를 modify로 보고합니다.

세 가지 비교 모드, 세 가지 일

모드 비교 대상 소음 원천 출력 적합
텍스트 diff 문자 줄 키 순서, 들여쓰기, 숫자 표기 줄 번호, 빨강/초록 로컬라이제이션 번들, 키 순서가 안정적인 픽스처
구조 diff 파싱된 트리, 경로로 정렬 인덱스로 정렬된 배열 add/remove/modify 경로 API 응답, 설정, 웹훅
의미 diff 트리 + 업무 키 잘못된 키 선택만 move/insert 감지를 포함한 경로 레코드 목록, 주문, 재고

구조 diff는 "키 순서와 형식" 계층을 해결합니다. 의미 diff는 한 발 더 나아가 "배열 요소 정체성"을 해결합니다. 차이는 구현 복잡성이 아니라, 도구에 어떤 필드가 레코드의 여권인지 기꺼이 알려 주는지입니다.

배열이 경계선

질문 하나를 하세요. 이 배열의 순서 자체가 데이터인가?

  • 순서가 데이터: 이전 단계, 검색 순위, 차트 포인트, 승인 체인. 위치 변경은 동작 변경이라 위치 비교가 올바릅니다.
  • 순서가 무의미: 태그, 권한 ID 집합, 주문 라인 항목, 재고 목록. 삽입이 다른 항목의 판단에 영향을 주면 안 됩니다.

순서가 무의미할 때는 레코드를 위치로 난타하지 말고 키로 인덱싱하세요:

// Match by id: an insert or delete reports one entry, other items stay put
function diffById(before, after, path = '$') {
  const out = [];
  const beforeMap = new Map(before.map((item) => [item.id, item]));
  const afterMap = new Map(after.map((item) => [item.id, item]));

  for (const [id, value] of afterMap) {
    if (!beforeMap.has(id)) out.push({ path: `${path}[id=${id}]`, type: 'added' });
  }
  for (const [id, value] of beforeMap) {
    if (!afterMap.has(id)) out.push({ path: `${path}[id=${id}]`, type: 'removed' });
    else if (JSON.stringify(value) !== JSON.stringify(afterMap.get(id))) {
      out.push({ path: `${path}[id=${id}]`, type: 'changed' });
    }
  }
  return out;
}

성숙한 라이브러리는 이를 옵션으로 바꿉니다. jsondiffpatch는 LCS로 최장 공통 부분 수열을 찾지만 배열 내부의 객체 요소는 기본적으로 위치로 매칭합니다. 공식 문서는 콘텐츠로 매칭하려면 objectHash 함수를 공급해야 한다고 강조합니다. 처음 쓰는 사람은 라이브러리가 멍청하다고 결론짓지만, 실제로는 키를 넘겨 주지 않은 것입니다.

키 선택도 중요합니다. id가 안전한 선택입니다. sku는 SKU가 유일할 때만 동작합니다. 객체 전체를 해싱하면 "필드 하나 변경"이 "하나 제거 + 하나 추가"가 되어 위치 비교보다 나쁩니다.

가장 빠른 터미널 요령: jq로 정규화

라이브러리를 원하지 않고 한 번 보고 싶다면 먼저 정규화하고 그다음 diff하세요. jq -S는 객체 키를 재귀적으로 정렬하고 일관된 들여쓰기를 적용합니다:

# Normalize, then compare line by line; key order and indentation noise disappear
diff -u <(jq -S . before.json) <(jq -S . after.json)

# When the array is genuinely unordered, sort by key before comparing
diff -u <(jq -S '.items |= sort_by(.id)' before.json) \
        <(jq -S '.items |= sort_by(.id)' after.json)

printf '{"b":2,"a":1,"arr":[1,2]}' | jq -S .를 실행해 {"a":1,"arr":[1,2],"b":2}를 얻었습니다. 키는 정렬되고 arr은 그대로. 기억하세요. -S는 객체 키만 정렬하고 배열 요소는 절대 정렬하지 않습니다. 배열은 sort / sort_by로 명시적으로 정렬하거나 위치 소음을 받아들이세요.

두 함정:

  • jq는 제자리 편집을 못 하고 표준 출력에 씁니다. 프로세스 치환(<(...))을 쓰거나 임시 파일에 쓰고 확인한 뒤 교체하세요.
  • -r은 문자열에서 따옴표를 벗겨 출력이 줄 바꿈이나 셸 유의 문자를 포함할 수 있습니다. 신뢰할 수 없는 데이터를 이어 파이핑하기 전에 인용하세요.

이 사이트의 /diff가 하는 것과 하지 않는 것

내장 JSON 비교 도구는 같은 구조 접근을 따릅니다. 양쪽을 트리로 파싱하고 경로로 재귀 정렬해 path + type(add/remove/modify) + before/after를 만듭니다. 객체 키 순서는 결과에 영향을 주지 않고, 모두 브라우저에서 실행되며 붙여 넣은 페이로드는 절대 업로드되지 않습니다.

그러나 배열은 인덱스로 정렬합니다. 이는 의도된 절충이지 결함이 아닙니다:

  • 꼬리에 덧붙이거나, 같은 위치를 수정하거나, 통째로 교체하면 깨끗한 출력을 얻습니다.
  • 머리나 중간에 레코드를 삽입하면 그 뒤 모든 것이 modify로 표시되어 "실제로 새 레코드는 하나"임을 눈으로 찾아야 합니다.

그래서 순서 목록을 비교할 때 올바른 동작은 붙여 넣기 전에 양쪽을 id로 정렬하는 것입니다. 정렬된 입력은 삽입 지점을 고정하고 소음을 한 항목으로 접습니다. 설치가 필요 없고 대부분의 일상 사례를 덮습니다.

"적용 가능한 패치 생성"이나 "이동한 요소 감지"가 필요하면 페이지 내 도구는 그렇게 하지 않습니다. 다음 절입니다.

diff를 패치로 바꾸기: 두 형식, 섞지 말 것

사람은 diff를 읽고, 프로그램은 패치를 적용합니다. JSON에는 의미가 완전히 다른 두 패치 형식이 있습니다.

차원 JSON Patch(RFC 6902) JSON Merge Patch(RFC 7396)
모양 op, path, value가 있는 연산 배열 대상 문서 모양의 JSON 객체
배열 지원, 인덱스로 /items/0, 추가는 /- 미지원, 배열은 통째로 교체
키 삭제 remove null로만 가능하며, 값이 합법적으로 null인 필드는 삭제 불가
이동 표현 move / copy 불가
적합 배열을 포함한 리소스, 감사 로그, 원자적 업데이트 평범한 객체의 부분 업데이트, 설정 전달

JSON Patch에는 사람들이 건너뛰는 연산이 하나 있습니다. test.

[
  { "op": "test", "path": "/version", "value": 3 },
  { "op": "replace", "path": "/status", "value": "shipped" }
]

test가 실패하면 패치 전체가 거부됩니다. "버전 3을 봤기 때문에 썼다"를 보장하는 낙관적 동시성이 프로토콜에 내장된 것입니다. 추가 If-Match 헤더가 필요 없습니다. PATCH 엔드포인트에는 어떤 문서 관례보다 값어치가 있습니다.

경로는 JSON Pointer(RFC 6901)를 씁니다. ~0은 ~를, ~1은 /를 이스케이프합니다. 슬래시가 포함된 키(예: MIME 타입을 키로)는 이스케이프해야 하며, 아니면 포인터가 다른 곳에 떨어집니다. 디테일은 JSON Pointer와 Patch에 있습니다.

실용 문제가 하나 남습니다. diff에서 자동 생성된 패치는 대개 배열을 인덱스로 지목합니다. 인덱스는 운영에서 취약합니다. 동시 삽입 하나로 /items/3이 같은 레코드를 가리키지 않습니다. 생성 패치를 객체 필드로 제한하거나, 적용 전에 test로 핵심 필드를 검증하세요.

비교를 CI에 넣기: 텍스트가 아니라 값을 단언

세 규칙이면 충분합니다:

  1. 테스트에서 파싱된 값을 단언하고 JSON.stringify 출력은 절대 쓰지 마세요. Jest에서는 toEqual, Python에서는 json.loads 후 assert a == b. 키 순서와 들여쓰기는 참여하지 않습니다.
  2. 동적 필드를 화이트리스트. requestId, timestamp, traceId는 실행마다 다릅니다. 제외가 없으면 테스트가 영구적으로 빨갛습니다. jsondiffpatch의 propertyFilter, deepdiff의 exclude_paths가 이를 위해 존재합니다.
  3. 실패 시 문서 전체가 아니라 경로를 출력. 로그에서 5MB 페이로드를 읽는 사람은 없습니다. $.data.items[id=ORD-042].qty: 2 → 3이 문제를 즉시 찾아 줍니다.

설정 드리프트도 같습니다. 스테이징과 운영 설정을 모두 jq -S하고 업무 키로 배열을 정렬하면, 남는 것이 진짜 차이입니다. 같은 데이터가 환경마다 달라 보이는 이 부류의 문제는 대개 정의되지 않은 계약이며, API 계약 설계가 다루는 주제입니다.

FAQ

키 순서도 차이로 세야 하나?

아니요. RFC 8259는 객체를 순서 없는 컬렉션으로 정의하므로 {"a":1,"b":2}와 {"b":2,"a":1}은 같은 값입니다. 재정렬을 변경으로 보고하는 도구는 JSON 비교가 아니라 텍스트 비교를 하는 것입니다.

레코드 하나를 삽입하면 왜 모든 항목이 변경으로 표시되나?

도구가 배열을 인덱스별로 정렬하기 때문입니다. 새 요소가 위치 0에 있고 기존 레코드가 모두 하나씩 밀려 어떤 두 위치도 맞지 않습니다. 위치 비교 대신 비교 전에 id 같은 키로 정렬하거나 인덱싱하세요.

온라인 비교 도구가 데이터를 업로드하나?

구현에 달려 있습니다. 서버 렌더 도구는 페이로드를 백엔드로 보내고 로그에 남을 수 있으며, 클라이언트 측 도구는 브라우저에서 파싱하고 아무것도 업로드하지 않습니다. 운영 설정이나 사용자 페이로드를 비교하기 전에 페이지가 로컬에서 계산하는지 확인하거나 네트워크를 끊고 다시 시도하세요.

diff 결과를 바로 패치로 쓸 수 있나?

직접은 안 됩니다. diff는 어디가 다른지 말하고, JSON Patch는 어떤 연산을 순서대로 실행할지 말합니다. 둘 사이에 변환 계층이 있습니다. 생성 패치도 배열을 인덱스로 지목하는 경향이 있어 동시성에서 깨집니다. 적용 전에 test 연산을 더하세요.

수십 MB 파일을 어떻게 비교하나?

먼저 스트리밍(jq -c가 한 줄에 레코드 하나를 씀)하거나 관심 있는 하위 트리만 추출하세요(예: jq '.data.items'). 수십 MB의 전체 재귀 비교는 여전히 메모리에 들어가고, 100MB를 넘으면 샤딩하거나 키로 인덱싱하세요. 구체적 메모리와 시간 수치는 대용량 JSON 성능을 참고하세요.

비교 전에 배열을 id로 정렬해야 하나?

예. 내장 JSON 비교 도구가 배열을 인덱스로 정렬하므로 머리나 중간에 레코드 하나를 삽입하면 그 뒤 모든 것이 modify로 표시됩니다. 붙여 넣기 전에 양쪽을 id로 정렬하면 삽입 지점이 고정되고 소음이 한 항목으로 접힙니다. 단, 배열 순서 자체가 데이터인 경우 — 이전 단계, 검색 순위, 차트 포인트 — 는 위치 비교가 올바르고 정렬이 틀립니다.