데이터 변경 이력을 다루다 보면 before(변경 전)과 after(변경 후) 두 객체를 나란히 비교해야 하는 경우가 자주 생깁니다. 특히 중첩된 배열 구조 안에 동일한 ID가 양쪽에 흩어져 있을 때는, 이를 하나의 행으로 묶어 한눈에 비교할 수 있도록 그룹화하는 로직이 필요합니다. 이번 글에서는 JavaScript로 이 문제를 깔끔하게 해결하는 방법을 살펴보겠습니다.
문제 상황
다음과 같은 JSON 객체가 있다고 가정해 보겠습니다. 각 device는 고유한 id, price, 그리고 여러 항목을 담은 features 배열로 구성되어 있습니다.
const input = {
before: {
device: [
{
id: "1234",
price: "10",
features: [
{ name: "samsung", price: "10" },
{ name: "Apple", price: "20" }
]
},
{
id: "2154",
price: "20",
features: [
{ name: "samsung", price: "30" },
{ name: "Moto", price: "40" }
]
}
]
},
after: {
device: [
{
id: "1234",
price: "50",
features: [
{ name: "samsung", price: "20" },
{ name: "Lenovo", price: "30" }
]
},
{
id: "2158",
price: "40",
features: [
{ name: "samsung", price: "30" }
]
}
]
}
};
요구 사항
목표는 명확합니다. before와 after 객체에 동일한 id가 존재한다면, 해당 id와 그에 딸린 feature들을 하나의 행으로 묶어주는 것입니다. 예를 들어 id "1234"는 before에서 가격이 10이었지만 after에서는 50으로 변경되었으므로, 두 값이 같은 그룹 안에 나란히 배치되어야 합니다. 정리하면 결과물은 다음 구조를 가져야 합니다.
- id를 기준으로 그룹핑하며, 첫 번째 행에는 id와 before/after 가격이 위치합니다.
- feature 이름(name)을 기준으로 하위 행을 만들고, before/after 가격을 각각 채웁니다.
- 한쪽에만 존재하는 항목은 반대쪽 값을 0으로 처리합니다.
해결 코드
이제 위 요구 사항을 만족하는 함수를 작성해 보겠습니다.
const formatJSON = data => {
// before/after 컬럼명을 기준으로 초기값 0을 가진 템플릿 생성
const sub = Object.fromEntries(Object.keys(data).map(k => [k, 0]));
return Object.values(
Object.entries(data).reduce((r, [col, { device }]) => {
device.forEach(({ id, price, features }) => {
// 해당 id가 처음 등장했다면 새 그룹 생성
r[id] = r[id] || [{ id, ...sub }];
// 디바이스 레벨 가격 기록
r[id][0][col] = price;
// feature별로 행을 찾거나 새로 추가한 뒤 가격 기록
features.forEach(({ name, price }) => {
let temp = r[id].find(q => q.name === name);
if (!temp) {
r[id].push(temp = { name, ...sub });
}
temp[col] = price;
});
});
return r;
}, {})
);
};
console.log(formatJSON(input));
코드 동작 원리
핵심 로직을 단계별로 살펴보면 다음과 같습니다.
- 템플릿 생성:
Object.fromEntries(Object.keys(data).map(k => [k, 0]))는{ before: 0, after: 0 }형태의 객체를 만듭니다. 모든 새 행은 이 템플릿을 복사하므로, 존재하지 않는 컬럼은 자동으로 0이 됩니다. - 컬럼 순회:
Object.entries(data)로 before와 after를 차례대로 순회하면서, 각 컬럼 이름(col)을 키로 사용해 값을 기록합니다. - ID 그룹핑:
r[id]가 이미 존재하면 재사용하고, 없으면[{ id, ...sub }]로 새 배열을 만듭니다. 덕분에 동일한 id는 항상 하나의 그룹에 모이게 됩니다. - feature 병합:
find()로 같은 이름의 feature 행이 있는지 확인하고, 없으면 새로 추가한 뒤 현재 컬럼의 가격을 할당합니다.
실행 결과
위 코드를 실행하면 콘솔에 다음과 같은 출력이 나타납니다.
[
[
{ id: '1234', before: '10', after: '50' },
{ name: 'samsung', before: '10', after: '20' },
{ name: 'Apple', before: '20', after: 0 },
{ name: 'Lenovo', before: 0, after: '30' }
],
[
{ id: '2154', before: '20', after: 0 },
{ name: 'samsung', before: '30', after: 0 },
{ name: 'Moto', before: '40', after: 0 }
],
[
{ id: '2158', before: 0, after: '40' },
{ name: 'samsung', before: 0, after: '30' }
]
]
결과 해석
- id "1234": before와 after 양쪽에 모두 존재하므로 하나의 그룹으로 병합되었습니다. samsung은 양쪽 모두 있어 가격이 나란히 기록되었고, Apple은 before에만, Lenovo는 after에만 존재하므로 반대편 값이 0으로 채워졌습니다.
- id "2154": after에는 존재하지 않으므로 after 관련 값이 모두 0입니다.
- id "2158": before에는 존재하지 않으므로 before 관련 값이 모두 0입니다.
이처럼 reduce()와 Object.entries()를 조합하면 중첩된 배열 데이터를 키 기준으로 손쉽게 피벗(pivot)할 수 있습니다. diff 뷰어, 변경 이력 테이블, 가격 비교 화면 등을 만들 때 유용하게 활용할 수 있는 패턴이니 참고해 보시기 바랍니다.