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 클래스에는 메서드가 추가 될 예정
'FrontEnd > React.js' 카테고리의 다른 글
JWT 관련 면접 질문 모음 (0) | 2025.04.26 |
---|---|
토스의 "자료구조를 활용한 복잡한 프론트엔드 컴포넌트 제작하기"를 구현해보기 (3) (0) | 2025.04.14 |
토스의 "자료구조를 활용한 복잡한 프론트엔드 컴포넌트 제작하기"를 구현해보기 (1) (0) | 2025.04.09 |
[React] 카카오 api를 통해 주소를 좌표로 변환해보기 (undefinded Geocorder) (1) | 2024.09.06 |
react-day-picker 에서 한글화 시키기 (feat. date-fns) (1) | 2024.09.04 |