React Tree View 구현: 폴더 구조처럼 계층형 데이터 표현

React에서 트리 뷰(Tree View) 컴포넌트를 구현하는 방법을 정리했습니다.

root: 폴더를 눌러보세요.

  • 닫힌 폴더 directory_40
  • 닫힌 폴더 directory_3
  • 닫힌 폴더 directory_10
  • 닫힌 폴더 directory_29
  • 닫힌 폴더 directory_20

1. 트리 뷰(Tree View)란?

트리 뷰는 계층적인 데이터를 시각적으로 표현하는 UI입니다. 대표적인 예시로는 다음과 같은 것들이 있습니다.

  • 파일 탐색기 (폴더와 파일 구조)
  • 사이드바 메뉴 (하위 메뉴가 있는 내비게이션)
  • 카테고리 시스템 (대분류-중분류-소분류 형태)

트리 뷰의 핵심은 계층 구조 데이터를 관리하고, 이를 UI로 동적으로 렌더링하는 것입니다.

2. 트리 뷰의 데이터 구조 (JSON 형태의 계층 데이터 다루기)

트리 구조를 표현하려면, 기본적으로 다음과 같은 형태의 데이터를 다룹니다.

데이터 링크: https://pastebin.com/raw/sEzZ5JTu

서버로부터 링크와 같은 데이터가 온다고 하면 이는 계층 구조를 갖고 있지만, 현재는 배열(flat structure) 형태로 되어 있습니다. 이를 트리(Tree) 구조로 변환해야 합니다.

변환 전 (기존 데이터, flat 구조)

[
    {
        "id": 0,
        "type": "directory",
        "filename": "directory_12",
        "parent_id": null
    },
    {
        "id": 1,
        "type": "directory",
        "filename": "directory_20",
        "parent_id": null
    },
    {
        "id": 5,
        "type": "file",
        "filename": "file_41",
        "parent_id": 3
    },
    {
        "id": 9,
        "type": "directory",
        "filename": "directory_8",
        "parent_id": 2
    }
]

변환 후 (트리 구조)

위 데이터를 부모-자식 관계가 반영된 계층 구조(Tree Structure)로 변환하면 다음과 같은 형태가 됩니다.

[
    {
        "id": 0,
        "type": "directory",
        "filename": "directory_12",
        "parent_id": null,
        "children": []
    },
    {
        "id": 1,
        "type": "directory",
        "filename": "directory_20",
        "parent_id": null,
        "children": []
    },
    {
        "id": 2,
        "type": "directory",
        "filename": "directory_47",
        "parent_id": null,
        "children": [
            {
                "id": 9,
                "type": "directory",
                "filename": "directory_8",
                "parent_id": 2,
                "children": []
            }
        ]
    },
    {
        "id": 3,
        "type": "directory",
        "filename": "directory_8",
        "parent_id": null,
        "children": [
            {
                "id": 5,
                "type": "file",
                "filename": "file_41",
                "parent_id": 3
            }
        ]
    }
]

각 directory 항목에는 children 속성이 추가되었으며,

각 부모 ID(parent_id)에 해당하는 자식 노드가 children 배열에 포함됩니다.

트리 구조로 변환하는 함수

이러한 변환을 위해, arrayToTree 함수를 작성했습니다.

export type File = {
    id: string | number;
    type: "directory" | "file";
    filename: string;
    parent_id: string | number | null;
};

type FileTree = ({
    children?: FileTree;
} & File)[];

function arrayToTree(
    parentId: string | number | null,
    files: File[]
): FileTree {
    const nowFiles: FileTree = files.filter(
        (file) => file.parent_id === parentId
    );

    for (let i = 0; i < nowFiles.length; i++) {
        nowFiles[i].children = arrayToTree(nowFiles[i].id, files);
    }

    return nowFiles;
}

이 함수는 다음과 같은 방식으로 동작합니다.

  1. 부모 ID(parent_id)가 일치하는 항목을 찾습니다.
  2. 각 항목에 대해 children 속성을 추가하고, 자식 노드를 재귀적으로 찾습니다.
  3. 최종적으로 계층 구조를 가진 배열을 반환합니다.

3. 변환된 데이터를 활용하여 트리 UI 렌더링

이제 변환된 데이터를 TreeView 컴포넌트에서 사용할 수 있습니다.

트리 구조를 올바르게 렌더링하기 위해서는 두 개의 주요 컴포넌트가 필요합니다.

  1. TreeView.tsx → 트리의 최상위 루트 (최초 데이터 변환 및 노드 렌더링)
  2. Node.tsx → 개별 디렉터리 및 파일을 렌더링하는 컴포넌트

트리의 최상위 컴포넌트 (TreeView.tsx)

TreeView.tsx에서는 JSON 데이터를 트리 구조로 변환하고, 루트 노드부터 렌더링을 시작합니다.

import { useEffect, useState } from "react";
import Node from "./node";
import clsx from "clsx";

export type File = {
    id: string | number;
    type: "directory" | "file";
    filename: string;
    parent_id: string | number | null;
};

type FileTree = ({
    children?: FileTree;
} & File)[];

function arrayToTree(
    parentId: string | number | null,
    files: File[]
): FileTree {
    const nowFiles: FileTree = files.filter(
        (file) => file.parent_id === parentId
    );

    for (let i = 0; i < nowFiles.length; i++) {
        nowFiles[i].children = arrayToTree(nowFiles[i].id, files);
    }

    return nowFiles;
}

export default function TreeView() {
    const [files, setFiles] = useState<File[]>();
    const [isReady, setReady] = useState(false);

    useEffect(() => {
        async function setData() {
            const response = await fetch("/data.json", {
                method: "GET",
            });

            if (response.ok) {
                const data = await response.json();
                setFiles(data);
                setReady(true);
            }
        }

        setData();
    }, []);

    if (!isReady) return <p>데이터를 불러오는 중</p>;

    return (
        <>
            <div className={clsx("flex flex-col")}>
                <header>
                    <p>root</p>
                </header>
                <ul className="h-[200px]">
                    {files &&
                        arrayToTree(null, files).map((file) => (
                            <Node key={file.id} {...file} />
                        ))}
                </ul>
            </div>
        </>
    );
}

이 함수는 다음과 같은 방식으로 동작합니다.

  1. arrayToTree(null, files)을 사용하여 배열 데이터를 트리 구조로 변환
  2. Node 컴포넌트를 사용하여 각 노드를 재귀적으로 렌더링
  3. 데이터를 비동기적으로 불러온 후 렌더링 (useEffect 활용)
  4. 초기 로딩 상태(isReady)를 사용하여 데이터가 없을 경우 “데이터를 불러오는 중…” 메시지 표시

개별 노드를 렌더링하는 컴포넌트 (Node.tsx)

이제 TreeView.tsx에서 넘겨받은 트리 구조 데이터를 개별적으로 렌더링하는 Node.tsx를 작성합니다.

import { useState } from "react";
import { File } from "./tree-view";
import { FaRegFolder, FaRegFolderOpen, FaRegFile } from "react-icons/fa";

type Node = {
    children?: Node[];
} & File;4

type Props = {} & Node;

export default function Node({
    type,
    filename,
    children,
    id,
    parent_id,
}: Props) {
    const [open, setOpen] = useState(false);

    return (
        <li>
            {type === "directory" ? (
                <div
                    className="flex items-center gap-2"
                    onClick={() => setOpen(!open)}
                >
                    {open ? <FaRegFolderOpen /> : <FaRegFolder />} {filename}
                </div>
            ) : (
                <div className="flex items-center gap-2">
                    <FaRegFile /> {filename}
                </div>
            )}
            {open &&
                children &&
                children.map((file) => (
                    <ul key={file.id} className="ml-2">
                        <Node {...file} />
                    </ul>
                ))}
        </li>
    );
}

이 함수는 다음과 같은 방식으로 동작합니다.

  1. 폴더(directory)와 파일(file)을 구분하여 아이콘 표시
  2. 디렉터리를 클릭하면(onClick) 하위 폴더를 열거나 닫을 수 있도록 상태(open) 관리
  3. children이 있는 경우에만 하위 노드를 렌더링하여 불필요한 생성을 방지

4. 마무리 및 전체코드

지금까지 트리 구조 데이터를 변환하고, 이를 UI에서 렌더링하는 과정을 정리하였습니다.

  • arrayToTree 함수를 이용하여 배열 데이터를 계층형 구조로 변환
  • TreeView.tsx에서 최초 데이터를 불러오고, 루트 노드를 렌더링
  • Node.tsx에서 개별 노드를 재귀적으로 렌더링하며 폴더 및 파일을 구분하여 표시
// app.tsx
import TreeView from "./components/tree-view";

function App() {
    return (
        <>
            <div className="flex justify-center min-h-screen bg-slate-200 gap-10">
                <TreeView />
            </div>
        </>
    );
}

export default App;

// tree-view.tsx
import { useEffect, useState } from "react";
import Node from "./node";
import clsx from "clsx";

export type File = {
    id: string | number;
    type: "directory" | "file";
    filename: string;
    parent_id: string | number | null;
};

type FileTree = ({
    children?: FileTree;
} & File)[];

function arrayToTree(
    parentId: string | number | null,
    files: File[]
): FileTree {
    const nowFiles: FileTree = files.filter(
        (file) => file.parent_id === parentId
    );

    for (let i = 0; i < nowFiles.length; i++) {
        nowFiles[i].children = arrayToTree(nowFiles[i].id, files);
    }

    return nowFiles;
}

export default function TreeView() {
    const [files, setFiles] = useState<File[]>();
    const [isReady, setReady] = useState(false);

    useEffect(() => {
        async function setData() {
            const response = await fetch("/data.json", {
                method: "GET",
            });

            if (response.ok) {
                const data = await response.json();
                setFiles(data);
                setReady(true);
            }
        }

        setData();
    }, []);

    if (!isReady) return <p>데이터를 불러오는 중</p>;

    return (
        <>
            <div className={clsx("flex flex-col")}>
                <header>
                    <p>root</p>
                </header>
                <ul className="h-[200px]">
                    {files &&
                        arrayToTree(null, files).map((file) => (
                            <Node key={file.id} {...file} />
                        ))}
                </ul>
            </div>
        </>
    );
}

// node.tsx
import { useState } from "react";
import { File } from "./tree-view";
import { FaRegFolder, FaRegFolderOpen, FaRegFile } from "react-icons/fa";

type Node = {
    children?: Node[];
} & File;

type Props = {} & Node;

export default function Node({
    type,
    filename,
    children,
    id,
    parent_id,
}: Props) {
    const [open, setOpen] = useState(false);

    return (
        <li>
            {type === "directory" ? (
                <div
                    className="flex items-center gap-2"
                    onClick={() => setOpen(!open)}
                >
                    {open ? <FaRegFolderOpen /> : <FaRegFolder />} {filename}
                </div>
            ) : (
                <div className="flex items-center gap-2">
                    <FaRegFile /> {filename}
                </div>
            )}
            {open &&
                children &&
                children.map((file) => (
                    <ul key={file.id} className="ml-2">
                        <Node {...file} />
                    </ul>
                ))}
        </li>
    );
}

Leave a Comment