본문 바로가기
FrontEnd/React.js

토스의 "자료구조를 활용한 복잡한 프론트엔드 컴포넌트 제작하기"를 구현해보기 (2)

by 위그든씨 2025. 4. 13.

https://toss.tech/article/frontend-tree-structure

 

자료구조를 활용한 복잡한 프론트엔드 컴포넌트 제작하기

왜 토스증권 PC의 그리드 레이아웃을 왜 직접 구현하게 되었는지, 그리고 어떻게 만들어져 있는지를 이제부터 소개해 드릴게요.

toss.tech

1) 레이아웃 노드인 패널과 스플릿트 노드를 제작한다.

2) 생성 된 트리를 통해 컴포넌트와 연결한다.

1) 레이아웃 노드인 패널과 스플릿트 노드를 제작한다.

type LayoutNode = PanelNode | SplitNode;

interface PanelNode {
    type: "panel";
    id: string;
}

interface SplitNode {
    type: "split";
    id: string;
    left: Node;
    right: Node;
    orientation: "H" | "W";
    ratio: number;
}

토스 기술 블로그에서 제공한 노드의 타입은 위와 같다.

패널 노드는 화면에 그려질 컴포넌트에 대한 정보이고, 분할 노드는 레이아웃을 어떠한 방식으로 나눌 지에 대한 정보가 담겨있다.

패널과 분할 노드로 이루어질 트리에 적용 될 몇 가지 규칙을 추론해봤다.

1. 화면에 그려질 패널이 단 1개가 아니라면, 트리의 root 노드에는 분할 노드만이 올 수 있다. 

2. 분할 노드의 left와 right는 초기 생성했을 때가 아닌 트리에 추가 된 상황이라면 항상 채워져 있어야 한다. 

3. 패널 노드는 리프 노드에만 올 수 있다. 화면에 그려졌는데 컴포넌트를 또 나눌 수는 없으므로.

내가 짤 로직은 아래와 같다.

LayoutComponent 에는 BianaryTree 클래스로 생성 된 layoutTree라는 state가 존재한다.

해당 컴포넌트는 BianaryTree의 search 메서드를 통해 생선 된 컴포넌트 될 요소들이 담겨진 배열을 return 해준다.

search 메서드에서는 전체 화면의 가로 세로와 willRenderComponents 배열을 초깃값으로 둔 뒤 루트에서부터 탐색을 시작하여 현재의 노드가 패널이라면 willRenderComponents 배열에 가로, 세로, 컴포넌트 이름, id로 이루어진 객체를 담아준다. 

현재 노드가 분할이라면 이전의 가로 세로와 현재 분할 노드의 orientation과 radio를 통해 왼쪽/위 , 오른쪽/아래 에 할당 될 가로 세로를 계산하여 탐색을 계속 진행한다.

탐색이 끝나면 willRenderComponents 을 리턴해준다. 이 배열의 요소들은 LayoutComponent의 리턴문에서 DynamicComponent를 실행한다.

DynamicComponent는 absolute로 설정 되어 있고 props로  width, height, top, left를 할당 받아서 패널이 렌더링 될 구역을 지정하게 된다. 

이제 tree의 노드에 할당 될 패널과 분할을 생성해본다. 

export type PanelNodeInstance = InstanceType<typeof PanelNode>;
export type PanelNodeProperty = {
    id: string;
    type: 'panel';
    componentName: IComponentName;
};

export class PanelNode implements IPanelNode {
    id: string;
    type: 'panel';
    componentName;
    constructor({
        id,
        componentName,
    }: {
        id: string;
        componentName: IComponentName;
    }) {
        this.id = id;
        this.type = 'panel';
        this.componentName = componentName;
    }
}

패널 노드에는 나중에 드래그 앤 드랍에 사용 될 id,  이 노드의 타입을 알려줄 panel이 할당 된 type,어떤 컴포넌트를 렌더링 할 지 알려줄 componentName이라는 property가 존재한다.

export type SplitNodeInstance = InstanceType<typeof SplitNode>;
export type SplitNodeProperty = {
    id: string;
    type: 'split';
    ratio: number;
    left: ILayoutNode | null;
    right: ILayoutNode | null;
    orientation: IOrientaion;
};
export class SplitNode {
    id;
    type: 'split';
    ratio;
    private left: ILayoutNode | null;
    private right: ILayoutNode | null;
    orientation;

    constructor({
        id,
        ratio,
        orientation,
    }: Omit<ISplitNode, 'type' | 'left' | 'right'>) {
        this.id = id;
        this.type = 'split';
        this.ratio = ratio;
        this.orientation = orientation;
        this.left = null;
        this.right = null;
    }

    getChildren(direction: 'left' | 'right') {
        if (direction === 'left') return this.left;
        return this.right;
    }

    toggleOrientation(position: '상' | '하' | '좌' | '우') {
        if (position === '상' || position === '하') {
            this.orientation = 'horizontality';
        } else {
            this.orientation = 'verticality';
        }
    }
    changeRatio(value: number) {
        let tempValue = value;
        if (value <= 0.2) tempValue = 0.2;
        if (value >= 0.8) tempValue = 0.8;
        this.ratio = tempValue;
    }

    appendNode(direction: 'left' | 'right', node: ILayoutNode) {
        if (direction === 'left') {
            this.left = node;
        } else {
            this.right = node;
        }
    }
}

분할 노드에는 수평/수직을 기록 할 orientation, 좌우 또는 위아래를 어떤 비율로 나눌지 정할 radio, 좌우/ 위아래에 어떤 자식을 가르킬 지 left, right 가 존재한다.

left right의 직접적인 수정을 방지하기 위해 private로 지정했기에 get과 set 메서드를 추가해줬고 드래그 앤 드랍 이벤트로 orientation을 변경하게 될 때 쓰일 toggle 메서드가 있다. 또한 차후 분할선도 드래그를 통해 비율 조정이 가능할 것을 감안하여 changeRatio 메서드를 만들어줬더. 

이제 위의 노드 두개들이 추가 될 트리 클래스를 만들어준다.

// search 메서드를 실행하면 렌더링 될 패널 노드를 통해 아래 요소를 생성해준 뒤 배열에 삽입 후 리턴해줄 거
export type IWillRenderComponent = {
    id: string;
    top: number;
    left: number;
    width: number;
    height: number;
    componentName: IComponentName;
};

class BinaryTree {
    private root: SplitNodeInstance;
    private width: number;
    private height: number;

    constructor(splitNode: SplitNodeInstance, width: number, height: number) {
        this.root = splitNode;
        this.width = width;
        this.height = height;
    }
    // 분할 노드에만 자식을 추가해줄 수 있다.
    appenNodeInTree(
        parent: ILayoutNode,
        children: ILayoutNode,
        direction: 'left' | 'right'
    ) {
        if (parent.type === 'panel') {
            throw new Error('패널 노드에는 자식이 붙을 수 없다.');
        }
        if (direction === 'left') {
            parent.appendNode('left', children);
        } else {
            parent.appendNode('right', children);
        }
    }
	
    // 초기 트리 구성시 명시적으로 연결을 해준다. 
    init() {
        const [first, second, third] = initialTreeComposition; // 레이아웃 영역별로 레벨 별 노드들을 생성해줌
        this.appenNodeInTree(this.root, first.left, 'left');
        this.appenNodeInTree(this.root, first.right, 'right');

        this.appenNodeInTree(first.right, second.left, 'left');
        this.appenNodeInTree(first.right, second.right, 'right');

        this.appenNodeInTree(second.right, third.left, 'left');
        this.appenNodeInTree(second.right, third.right, 'right');
    }

    getSize() {
        return {
            width: this.width,
            height: this.height,
        };
    }
    getTree() {
        return this.root;
    }
	// 루트 노드부터 탐색을 시작하여 각 영역별로 할당되는 width,height,top,left를 기록해준 뒤 렌더링 될 컴포넌트의 정보를 담은 배열을 리턴해준다.
    search() {
        const { leftOrUpChildren, rightOrDownChildren } =
            calculateWidthAndHeight({
                width: this.width,
                height: this.height,
                ratio: this.root.ratio,
                orientation: this.root.orientation,
            });
        const willRenderComponents: IWillRenderComponent[] = [];

        const visitChildren = (
            node: ILayoutNode,
            width: number,
            height: number,
            top: number,
            left: number
        ) => {
            if (node.type === 'split') {
                const { leftOrUpChildren, rightOrDownChildren } =
                    calculateWidthAndHeight({
                        width,
                        height,
                        ratio: node.ratio,
                        orientation: node.orientation,
                    });
                visitChildren(
                    node.getChildren('left')!,
                    leftOrUpChildren.width,
                    leftOrUpChildren.height,
                    top,
                    left
                );
                visitChildren(
                    node.getChildren('right')!,
                    rightOrDownChildren.width,
                    rightOrDownChildren.height,
                    node.orientation === 'verticality'
                        ? top
                        : top + leftOrUpChildren.height,
                    node.orientation === 'verticality'
                        ? left + leftOrUpChildren.width
                        : left
                );
            } else {
                willRenderComponents.push({
                    id: node.id,
                    componentName: node.componentName,
                    height,
                    width,
                    top,
                    left,
                });
            }
        };

        visitChildren(
            this.root.getChildren('left')!,
            leftOrUpChildren.width,
            leftOrUpChildren.height,
            0,
            0
        );
        visitChildren(
            this.root.getChildren('right')!,
            rightOrDownChildren.width,
            rightOrDownChildren.height,
            this.root.orientation === 'verticality'
                ? 0
                : leftOrUpChildren.height,
            this.root.orientation === 'verticality' ? leftOrUpChildren.width : 0
        );

        return willRenderComponents;
    }
}

아래는 분할 노드의 정보를 넘겨 준 뒤 해당 분할 노드에서의 좌우 또는 위아래 영역에 할당 될 높이와 가로를 계산하여 리턴해주는 유틸리티 함수이다. 

const calculateWidthAndHeight = ({
    width,
    height,
    orientation,
    ratio,
}: {
    width: number;
    height: number;
    ratio: number;
    orientation: IOrientaion;
}): CalculateWidthAndHeightReturnValue => {
    let tempRadio = ratio;
    if (ratio <= 0.2) {
        tempRadio = 0.2;
    }
    if (ratio >= 0.8) {
        tempRadio = 0.8;
    }

    const leftOrUpChildren = {
        width: orientation === 'horizontality' ? width : width * tempRadio,
        height: orientation === 'horizontality' ? height * tempRadio : height,
    };
    const rightOrDownChildren = {
        width:
            orientation === 'horizontality'
                ? width
                : width - leftOrUpChildren.width,
        height:
            orientation === 'horizontality'
                ? height - leftOrUpChildren.height
                : height,
    };

    return {
        leftOrUpChildren,
        rightOrDownChildren,
    };
};

트리의 레벨 별 노드들을 생성해주는 함수와 함께 이를 결과로 초기 트리 구성 요소들에 대한 정보이다.

// 요청에 따라 정의 된 클래스를 생성해주는 함수
const makeNode = (
    nodeQuery: NodeProperty
): PanelNodeInstance | SplitNodeInstance => {
    if (nodeQuery.type === 'panel') {
        return new PanelNode(nodeQuery);
    }

    return new SplitNode({
        id: nodeQuery.id,
        orientation: nodeQuery.orientation,
        ratio: nodeQuery.ratio,
    });
};

// 트리의 각 레벨 별 좌우 노드를 구성해준다
type MakeLevelOfTreeReturnValue = {
    left: PanelNodeInstance | SplitNodeInstance;
    right: PanelNodeInstance | SplitNodeInstance;
};
const makeLevelOfTree = (
    leftNodeQuery: NodeProperty,
    rightNodeQuery: NodeProperty
): MakeLevelOfTreeReturnValue => {
    const leftNode = makeNode(leftNodeQuery);
    const rightNode = makeNode(rightNodeQuery);

    return {
        left: leftNode,
        right: rightNode,
    };
};



// 위의 유틸리티 함수를 통해 각 레벨 별 좌우 요소들을 정의해준다. 이것은 코드를 단순하게 짜느라 만들어진 코드이고 실제로는 좀 더 명시적으로 어떤 요소의 자식이 될 지 짤 필요가 있다.
const initialTreeComposition = [
    makeLevelOfTree(
        {
            id: '11111',
            type: 'panel',
            componentName: 'AComponent',
        },
        {
            id: '10',
            type: 'split',
            orientation: 'horizontality',
            ratio: 0.5,
            left: null,
            right: null,
        }
    ),
    makeLevelOfTree(
        {
            id: '1',
            type: 'panel',
            componentName: 'BComponent',
        },
        {
            id: '11',
            type: 'split',
            orientation: 'verticality',
            ratio: 0.3,
            left: null,
            right: null,
        }
    ),
    makeLevelOfTree(
        {
            id: '2',
            type: 'panel',
            componentName: 'CComponent',
        },
        {
            id: '3',
            type: 'panel',
            componentName: 'DComponent',
        }
    ),
];

2) 생성 된 트리를 통해 컴포넌트와 연결한다.

 

위의 트리와 노드 클래스를 통해 페이지 컴포넌트에서는 state를 생성하여 관리해준다.

function PageComponent() {
    const [layoutTree, setLayoutTree] = useState(() => {
        const rootSplit = new SplitNode({
            id: '0',
            orientation: 'verticality',
            ratio: 0.5,
        });
        const tree = new BinaryTree(rootSplit, WIDTH, HEIGHT);
        tree.init();
        return tree;
    });
    //...
    
  }

이 컴포넌트의 리턴문에서는 layoutTree의 메서드인 search를 실행시키고 이것의 반환 값인 배열을 두면 리액트의 렌더링 엔진은 해당 배열에서 컴포넌트들을 꺼내서 렌더링 해주게 된다.

<div className="flex flex-col items-center justify-center gap-10 relative">
            {layoutTree.search().map((c: IWillRenderComponent) => (
                <DynamicComponent
                    key={c.id}
                    componentName={c.componentName}
                    height={c.height}
                    width={c.width}
                    top={c.top}
                    left={c.left}
                />
            ))}
</div>

다이나믹 컴포넌트에서는 props의 name에 따른 컴포넌트를 리턴해줄것이다. 이때 페이지 컴포넌트에서 layoutTree의 값에 변화가 생긴다면 자식 컴포넌트인 요소들은 전체가 리렌더링 되므로 최적화를 위해서 Dynamic 은 React.memo 로 메모이제이션 동작을 해준다.

import { memo } from 'react';
import AComponent from './a-component';
import BComponent from './b-component';
import CComponent from './c-component';
import DComponent from './d-component';

export type IComponentName =
    | 'AComponent'
    | 'BComponent'
    | 'CComponent'
    | 'DComponent';

interface DynamicComponentProps {
    componentName: IComponentName;
    width: number;
    height: number;
    top: number;
    left: number;
}

const componentMap = {
    AComponent,
    BComponent,
    CComponent,
    DComponent,
};

const DynamicComponent = memo(function C({
    componentName,
    width,
    height,
    top,
    left,
}: DynamicComponentProps) {
    const Component = componentMap[componentName];

    return (
        <div
            className="absolute border border-black p-4"
            style={{
                width,
                height,
                top,
                left,
            }}
        >
            <Component />
        </div>
    );
});

export default DynamicComponent;

위의 이미지는 초기 트리를 렌더링 한 이후이며 결과물의 경계를 좀 더 명확히 보고 싶어서 다이나믹 자체에 패딩 값을 준 결과이다.

또한 유연한 레이아웃 그리드인 것을 감안하여 뷰포트의 가로 세로 값이 변한다면 자식들의 가로 세로도 변화해주기 위해 페이지 컴포넌트에서는 초기 렌더링 시 resize 이벤트를 추가하여 tree에서의 width 와 height에 변화를 준다.

변화 된 트리로 인해 state에 변화가 생기므로 전체는 리렌더링 되게 된다.

'use client';

import DynamicComponent from '@/components/flexble/dynamic-component';
import { SplitNode } from '@/lib/binary/binary-node';
import BinaryTree, { IWillRenderComponent } from '@/lib/binary/binary-tree';
import { useEffect, useState } from 'react';

const WIDTH = 1470;
const HEIGHT = 798;

function PageComponent() {
    const [layoutTree, setLayoutTree] = useState(() => {
        const rootSplit = new SplitNode({
            id: '0',
            orientation: 'verticality',
            ratio: 0.5,
        });
        const tree = new BinaryTree(rootSplit, WIDTH, HEIGHT);
        tree.init();
        return tree;
    });

    useEffect(() => {
        const handleResizeEvent = (e: UIEvent) => {
            const target = e.currentTarget as Window;

            const { innerWidth, innerHeight } = target;
            setLayoutTree((tree) => {
                const newTree = new BinaryTree(
                    tree.getTree(),
                    innerWidth,
                    innerHeight
                );
                return newTree;
            });
        };

        window.addEventListener('resize', handleResizeEvent);
        return () => window.removeEventListener('resize', handleResizeEvent);
    }, []);

    return (
        <div className="flex flex-col items-center justify-center gap-10 relative">
            {layoutTree.search().map((c: IWillRenderComponent) => (
                <DynamicComponent
                    key={c.id}
                    componentName={c.componentName}
                    height={c.height}
                    width={c.width}
                    top={c.top}
                    left={c.left}
                />
            ))}
        </div>
    );
}

export default PageComponent;

 

다음 파트에서는 각 컴포넌트에 드래그 앤 드랍 요소를 추가한 동작을 구현할 것이다. 

드래그 앤 드랍 동작을 위해 tree 클래스에는 메서드가 추가 될 예정