데이터베이스에서 조회한 평면(flat) 구조의 레코드 배열은 각 행이 parentId 값으로만 관계를 표현하기 때문에, 화면에 렌더링하거나 트리 컴포넌트에 전달하려면 계층형 JSON 구조로 변환하는 과정이 필요합니다. 이 글에서는 id와 parentId만 있는 객체 배열을 부모-자식 관계가 반영된 트리 구조로 바꾸는 JavaScript 함수를 소개합니다.
문제 정의
다음과 같이 각 객체가 id, name, parentId 세 가지 속성을 가진 배열이 있다고 가정해 보겠습니다.
const arr = [
{ "id": 7, "name": "Kuwait", "parentId": 2 },
{ "id": 4, "name": "Iraq", "parentId": 2 },
{ "id": 10, "name": "Qatar", "parentId": 2 },
{ "id": 2, "name": "Middle East", "parentId": 1 },
{ "id": 3, "name": "Bahrain", "parentId": 2 },
{ "id": 6, "name": "Jordan", "parentId": 2 },
{ "id": 8, "name": "Lebanon", "parentId": 2 },
{ "id": 1, "name": "Africa/Middle East", "parentId": null },
{ "id": 5, "name": "Israel", "parentId": 2 },
{ "id": 9, "name": "Oman", "parentId": 2 }
];
목표는 parentId가 null인 최상위 노드(Africa/Middle East)를 루트로 삼고, 나머지 객체들을 각각 부모 노드의 children 배열에 중첩시킨 새로운 배열을 반환하는 것입니다.
변환 함수 구현
핵심 아이디어는 간단합니다. 모든 노드를 id로 빠르게 조회할 수 있는 맵(map)을 하나 준비한 뒤, 배열을 단 한 번 순회하면서 각 노드를 부모의 children 배열에 연결하는 방식입니다.
const transformTree = (data, root = null) => {
const res = [];
const map = {};
data.forEach((el) => {
// 자식이 부모보다 먼저 등록된 경우, 기존 children 배열을 그대로 이어받는다
el.children = (map[el.id] && map[el.id].children) || [];
map[el.id] = el;
if (el.parentId === root) {
// 최상위(root) 노드라면 결과 배열에 바로 추가
res.push(el);
} else {
// 부모 노드가 아직 맵에 없다면 임시 객체를 생성해 children을 붙여둔다
map[el.parentId] = map[el.parentId] || {};
map[el.parentId].children = map[el.parentId].children || [];
map[el.parentId].children.push(el);
}
});
return res;
};
console.log(JSON.stringify(transformTree(arr), undefined, 4));
코드 동작 원리
- map 객체: id를 키로 사용해 특정 노드를 상수 시간(O(1))에 찾을 수 있는 해시 맵입니다.
- children 병합: 입력 배열에서 자식이 부모보다 앞에 위치할 수 있으므로, el.children = (map[el.id] && map[el.id].children) || [] 구문으로 이미 등록된 자식 목록을 잃지 않고 그대로 이어받습니다.
- 루트 판별: parentId가 root(기본값 null)와 일치하면 결과 배열 res에 직접 추가하고, 그렇지 않으면 부모 노드의 children에 push합니다.
- 순서 무관 처리: 부모 노드가 아직 맵에 존재하지 않아도 빈 객체를 만들어 children을 미리 확보해 두기 때문에, 어떤 순서로 정렬된 배열이 들어와도 올바른 트리가 완성됩니다.
실행 결과
콘솔에 출력되는 결과는 다음과 같습니다.
[
{
"id": 1,
"name": "Africa/Middle East",
"parentId": null,
"children": [
{
"id": 2,
"name": "Middle East",
"parentId": 1,
"children": [
{ "id": 7, "name": "Kuwait", "parentId": 2, "children": [] },
{ "id": 4, "name": "Iraq", "parentId": 2, "children": [] },
{ "id": 10, "name": "Qatar", "parentId": 2, "children": [] },
{ "id": 3, "name": "Bahrain", "parentId": 2, "children": [] },
{ "id": 6, "name": "Jordan", "parentId": 2, "children": [] },
{ "id": 8, "name": "Lebanon", "parentId": 2, "children": [] },
{ "id": 5, "name": "Israel", "parentId": 2, "children": [] },
{ "id": 9, "name": "Oman", "parentId": 2, "children": [] }
]
}
]
}
]
마무리
이 알고리즘은 배열을 한 번만 순회하므로 시간 복잡도가 O(n)이며, 노드 수가 많아져도 성능 저하가 거의 없습니다. 카테고리 분류, 조직도, 메뉴 트리처럼 데이터베이스에는 평면 형태로 저장되지만 UI에서는 계층적으로 표현해야 하는 데이터를 jstree, TreeView 같은 트리 컴포넌트에 넘기기 전에 활용하기 좋은 패턴입니다.