두 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는 동적 계획법으로 풉니다. 길이 m 과 n 의 두 배열에 대해, 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" 짝짓기 알고리즘은 연속된 del 과 add 연산 블록을 모아 지퍼처럼 맞춥니다. 짝이 안 맞는 삭제는 오른쪽 자리에 빈 자리, 짝이 안 맞는 추가는 왼쪽에 빈 자리:
// 연속된 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)를 함께 낼 수 있습니다 —— '