← 전체 글

두 JSON 파일 비교 방법: 알고리즘과 도구

평문 diff는 키 재정렬과 공백 잡음을 놓친다. 제대로 된 JSON diff의 작동: LCS 라인 diff, 의미적 트리 비교, 키 정규화, 각 접근법의 트레이드오프.

두 JSON 응답이 거의 동일해 보이는데 배포 사이에 뭔가 바뀌어 테스트가 실패합니다. 또는 큰 설정 파일을 건드린 PR을 리뷰하면서 정확히 어떤 값이 바뀌었는지 알아야 합니다. JSON 파일 비교는 단순해 보입니다 —— 소박한 텍스트 diff가 키 재정렬을 변경으로 취급하고, 깊게 중첩된 구조에는 더 똑똑한 접근이 필요하다는 걸 깨닫기 전까지는요. 이 글은 제대로 된 JSON diff를 가능하게 하는 알고리즘을 설명합니다.

평문 텍스트 diff로는 부족한 이유

두 파일을 비교하는 가장 간단한 방법은 diff 를 돌리거나 줄 단위로 비교하는 것입니다. 평문에는 잘 동작합니다. JSON에서는 적어도 두 가지 측면에서 무너집니다.

문제 1: 키 순서

JSON 객체는 명세상 순서가 없습니다. 다음 두 문서는 의미적으로 동일합니다:

// 문서 A
{ "name": "Ada", "plan": "pro", "active": true }

// 문서 B
{ "active": true, "name": "Ada", "plan": "pro" }

줄 단위 텍스트 diff는 모든 줄을 바뀌었다고 보고합니다. 제대로 된 JSON diff는 차이를 0으로 보고합니다.

문제 2: 형식 노이즈

들여쓰기, 공백, 긴 배열을 한 줄에 쓰느냐 여러 줄에 쓰느냐는 모두 데이터와 무관합니다. 텍스트 diff는 공백 차이 하나하나를 변경으로 취급합니다.

두 문제의 해결책은 같습니다: 먼저 파싱하고, 구조를 diff 하라. 텍스트를 diff 하지 말고.

1단계 —— 파싱하고 정규화

어떤 비교든 시작 전에, 두 문서를 JSON.parse() 로 JavaScript 객체로 파싱한 다음, 키를 정렬하고 일관된 들여쓰기로 다시 직렬화합니다:

function normalise(json) {
  const value = JSON.parse(json);
  return JSON.stringify(sortKeys(value), null, 2);
}

function sortKeys(value) {
  if (Array.isArray(value)) return value.map(sortKeys);
  if (value !== null && typeof value === 'object') {
    return Object.keys(value)
      .sort((a, b) => a.localeCompare(b))
      .reduce((acc, key) => {
        acc[key] = sortKeys(value[key]);
        return acc;
      }, {});
  }
  return value;
}

정규화 후 위 문서 A와 B는 모두 같은 문자열을 만듭니다:

{
  "active": true,
  "name": "Ada",
  "plan": "pro"
}

이제 두 정규화 문자열의 텍스트 diff는 형식이나 키 순서 노이즈가 아니라 실제 데이터 차이만 강조합니다.

2단계 —— LCS로 줄 단위 diff

정규화된 텍스트가 있으면 문제는 이렇게 됩니다: 두 줄 시퀀스가 주어졌을 때, 하나를 다른 하나로 바꾸는 최소 변경 집합(삽입과 삭제)을 찾기. 이것이 고전적인 최장 공통 부분 수열(LCS) 문제입니다.

LCS란?

두 수열의 LCS는 양쪽에 같은 순서로 등장하는 가장 긴 부분 수열로, 반드시 연속할 필요는 없습니다. 예:

Before: ["  active: true", "  name: Ada",   "  plan: pro" ]
After:  ["  active: true", "  name: Ada",   "  plan: team"]

LCS:    ["  active: true", "  name: Ada"]   // 공통 2줄

// 결과:
//   same:    "  active: true"
//   same:    "  name: Ada"
//   deleted: "  plan: pro"
//   added:   "  plan: team"

LCS는 우리가 원하는 diff 자체를 줍니다: 그대로인 줄, 삭제된 줄, 추가된 줄.

DP 알고리즘

LCS는 동적 계획법으로 풉니다. 길이 mn 의 두 배열에 대해, dp[i][j] 가 「전 배열의 처음 i 요소」와 「후 배열의 처음 j 요소」의 LCS 길이가 되도록 2D 표를 만듭니다:

// 바닥에서 위로 DP 표 채우기
for (let i = m - 1; i >= 0; i--) {
  for (let j = n - 1; j >= 0; j--) {
    dp[i][j] = before[i] === after[j]
      ? dp[i + 1][j + 1] + 1                    // 줄 일치
      : Math.max(dp[i + 1][j], dp[i][j + 1]);   // 더 나은 분기 채택
  }
}

// 표를 거슬러 올라가며 diff 연산 복원
let i = 0, j = 0;
while (i < m && j < n) {
  if (before[i] === after[j]) {
    ops.push({ type: 'same', line: before[i++] }); j++;
  } else if (dp[i + 1][j] >= dp[i][j + 1]) {
    ops.push({ type: 'del', line: before[i++] });
  } else {
    ops.push({ type: 'add', line: after[j++] });
  }
}

시간 복잡도: O(m × n). 공간 복잡도: O(m × n). 일반적인 JSON 문서엔 충분히 빠릅니다. 매우 큰 문서(예: 각 2000줄 이상)에서는 이 표가 상당한 메모리를 먹습니다 —— 그 규모에선 시간 복잡도가 O(n + d²)(d 는 차이 수)인 Myers diff 알고리즘으로 바꿀 만합니다.

메모리 최적화: 타입드 배열

일반 JavaScript 2D 숫자 배열은 요소마다 상당한 오버헤드가 있습니다. 평탄한 Int32Array 를 쓰면 메모리가 대략 8배 줄고 캐시 지역성도 좋아집니다:

const W  = n + 1;
const dp = new Int32Array((m + 1) * W); // 평탄 버퍼

// dp[i][j] 는 dp[i * W + j] 로 접근
dp[i * W + j] = before[i] === after[j]
  ? dp[(i + 1) * W + (j + 1)] + 1
  : Math.max(dp[(i + 1) * W + j], dp[i * W + (j + 1)]);

500줄 문서면 이 표를 힙 객체 약 2 MB에서 연속된 타입드 메모리 약 1 MB로 줄여 줍니다.

3단계 —— 인접한 변경을 '수정' 행으로 짝짓기

원시 LCS 출력은 삭제와 추가만 압니다. 하지만 좌우 비교 diff 뷰에서는 대체한 추가 줄과 짝지은 삭제 줄을 '수정' 행으로 보이는 편이 훨씬 보기 좋습니다:

// LCS의 원시 연산:
del  "  plan: pro"
add  "  plan: team"

// 짝지은 후:
modified  left: "  plan: pro"   right: "  plan: team"

짝짓기 알고리즘은 연속된 deladd 연산 블록을 모아 지퍼처럼 맞춥니다. 짝이 안 맞는 삭제는 오른쪽 자리에 빈 자리, 짝이 안 맞는 추가는 왼쪽에 빈 자리:

// 연속된 del/add 블록 수집
const dels = [], adds = [];
while (ops[i].type !== 'same') {
  if (ops[i].type === 'del') dels.push(ops[i].line);
  else                        adds.push(ops[i].line);
  i++;
}

// 수정 쌍으로 지퍼
const pairs = Math.min(dels.length, adds.length);
for (let k = 0; k < pairs; k++)
  rows.push({ type: 'modified', left: dels[k], right: adds[k] });

// 남은 삭제
for (let k = pairs; k < dels.length; k++)
  rows.push({ type: 'deleted', left: dels[k] });

// 남은 추가
for (let k = pairs; k < adds.length; k++)
  rows.push({ type: 'added', right: adds[k] });

4단계 —— 요약 카운트를 위한 의미 diff

줄 diff는 어떤 텍스트가 바뀌었는지 말해 줍니다. 의미 diff는 어떤 필드가 어떻게 바뀌었는지 말해 줍니다 —— '3개 추가, 1개 삭제, 2개 변경' 같은 요약에 유용합니다.

의미 diff는 파싱된 두 객체를 재귀적으로 동시에 걷습니다:

function diffValue(key, path, before, after) {
  if (before === undefined) return { status: 'added',   after  };
  if (after  === undefined) return { status: 'removed', before };

  if (isObject(before) && isObject(after)) {
    const keys     = union(Object.keys(before), Object.keys(after)).sort();
    const children = keys.map(k =>
      diffValue(k, path + '.' + k, before[k], after[k])
    );
    return {
      status: children.every(c => c.status === 'unchanged') ? 'unchanged' : 'changed',
      children,
    };
  }

  if (Array.isArray(before) && Array.isArray(after)) {
    const len      = Math.max(before.length, after.length);
    const children = Array.from({ length: len }, (_, i) =>
      diffValue(i, path + '[' + i + ']', before[i], after[i])
    );
    return {
      status: children.every(c => c.status === 'unchanged') ? 'unchanged' : 'changed',
      children,
    };
  }

  return deepEqual(before, after)
    ? { status: 'unchanged', before, after }
    : { status: 'changed',   before, after };
}

생성된 트리를 걸으며 상태별 잎 노드를 세면 요약 수치가 나옵니다. 줄 diff와 달리 의미 diff는 구성상 키 순서와 무관합니다 —— JSON 안의 출현 순서와 관계없이 같은 키 경로의 값을 비교합니다.

모두 합치기

완전한 JSON diff 도구는 다음 단계를 엮습니다:

function jsonDiff(leftText, rightText) {
  // 1. 파싱
  const leftVal  = JSON.parse(leftText);
  const rightVal = JSON.parse(rightText);

  // 2. 의미 diff → 요약 카운트
  const summary = summarise(diffValue('root', '
#x27;, leftVal, rightVal)); // 3. 정규화(키 정렬, 일관 들여쓰기) const leftNorm = JSON.stringify(sortKeys(leftVal), null, 2); const rightNorm = JSON.stringify(sortKeys(rightVal), null, 2); // 4. LCS 줄 diff const rows = buildLineDiff(leftNorm, rightNorm); return { summary, rows }; }

Deep-Equal 대 구조 diff

흔한 지름길은 두 JSON 문서를 deepEqual(Node의 assert.deepStrictEqual, Lodash의 isEqual)로 비교하는 것입니다 —— 빠르지만 불리언만 주는 결과. 두 문서가 다르다는 것은 알려 줘도 어디서어떻게 다른지는 말해 주지 않습니다.

구조 diff 는 두 트리를 모두 걸으며 리포트(카운트, 경로, 전후 값)를 만들고 선택적으로 JSON Patch 도 낼 수 있습니다. 답이 예/아니오면 deepEqual 을 쓰세요 —— 테스트, 캐시 무효화. 사람이 차이에 따라 행동해야 한다면 구조 diff.

diff에서 JSON Patch 생성

의미 diff 트리가 있으면, 추가/삭제/변경 노드를 세는 같은 순회에서 JSON Patch(RFC 6902)를 함께 낼 수 있습니다 —— '이전' 문서를 '이후'로 바꾸는 이식 가능한 add / remove / replace 연산 목록. 그게 diff를 HTTP PATCH 요청의 본문으로 실어 보내거나, 나중에 다른 곳에 적용하는 방법입니다.

function toJsonPatch(node, pointer = '') {
  const ops = [];
  if (node.status === 'added')   ops.push({ op: 'add',     path: pointer, value: node.after });
  if (node.status === 'removed') ops.push({ op: 'remove',  path: pointer });
  if (node.status === 'changed' && !node.children) {
    ops.push({ op: 'replace', path: pointer, value: node.after });
  }
  for (const child of node.children ?? []) {
    const seg = String(child.key).replace(/~/g, '~0').replace(/\//g, '~1');
    ops.push(...toJsonPatch(child, pointer + '/' + seg));
  }
  return ops;
}

경로 세그먼트는 JSON Pointer (RFC 6901) 규칙을 따릅니다 —— 키 안의 ~/ 는 각각 ~0~1 로 이스케이프해야 함에 주의. fast-json-patch 같은 라이브러리도 같은 결과를 만들어 줍니다. HTTP로 patch를 보낼 때는 application/json-patch+json content type을 쓰세요. null 이 삭제를 의미하는 더 간단한 '오버레이' 대안에 대해서는 JSON Patch 대 JSON Merge Patch 참조.

알아 둘 만한 엣지 케이스

  • 배열 요소 재정렬 —— 의미 diff는 배열을 위치 기준으로 비교하므로([0] vs [0]), 한 배열이 버전 간 다른 순서로 정렬돼 있으면 데이터가 같아도 모든 요소가 '변경'으로 보일 수 있습니다. 이를 잘 처리하려면 배열 수준에서도 LCS가 필요하고, 복잡도가 크게 늘어납니다.
  • 매우 큰 문서 —— O(m × n) LCS 표는 수만 줄 문서에 메모리를 다 써 버릴 수 있습니다. 실용적인 휴리스틱: m × n 이 임계값(예: 200만)을 넘으면 완전한 diff 계산 대신 모든 줄을 변경으로 표시하는 폴백을 씁니다.
  • 수치 정밀도 —— JSON.parse() 는 모든 수를 IEEE 754 double로 변환합니다. 매우 큰 정수(2⁵³ 초과)는 소리 없이 정밀도를 잃으므로, 큰 정수의 마지막 자릿수만 다른 두 문서가 파싱 후 같은 것으로 비교될 수 있습니다.

자주 묻는 질문

두 JSON 파일을 어떻게 비교하나요?

양쪽을 파싱하고 정규화(키 정렬, 일관 들여쓰기)한 다음, 평문 텍스트 diff가 아니라 구조 diff를 돌리세요 —— 또는 둘 다 JSON Diff 에 붙여 넣으면 그 작업을 하고 색으로 구분된 좌우 뷰를 보여 줍니다.

왜 평문 텍스트 diff가 JSON에서 동작하지 않나요?

JSON 객체는 순서가 없고 공백은 의미가 없기 때문입니다. 텍스트 diff는 재정렬된 키와 재포맷을 변경으로 표시하지만, JSON을 아는 diff는 둘 다 무시하고 실제 데이터 차이만 보고합니다.

의미 JSON diff란?

파싱된 두 구조를 걸으며 순서와 무관하게 같은 키 경로의 값을 비교하고, '3 추가, 1 삭제, 2 변경' 같은 카운트를 내는 것입니다. 구성상 키 순서에 영향을 받지 않습니다.

JSON 비교가 수치 정밀도를 잃을 수 있나요?

예 —— JSON.parse() 는 수를 IEEE 754 double로 변환하므로, 2⁵³ 를 넘는 정수는 마지막 자릿수가 달라도 같은 것으로 비교될 수 있습니다. RFC 8785 의 정규화 규칙이 관련 문제를 다룹니다.

JSON Diff 도구 사용해 보기

JSON Diff 는 fixjson.org에서 위에 설명한 모든 것을 구현합니다: 두 문서를 파싱하고, 정렬된 키로 정규화하고, LCS 줄 diff를 돌려, 추가/삭제/수정이 색으로 구분된 좌우 뷰를 보여 줍니다 —— 추가/삭제/변경/유지된 필드 수를 보여 주는 요약 행도 함께. YAML 문서도 지원합니다. 모두 브라우저에서 실행되며, 어떤 서버로도 데이터가 전송되지 않습니다.