본문 바로가기
FrontEnd/React.js

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

by 위그든씨 2025. 4. 14.

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

 

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

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

toss.tech

1. 드래그 앤 드랍시 트리 구조가 변하게 되므로 이를 위한 메서드를 추가해준다.

2. 각 컴포넌트에 draggable을 추가해준 뒤 (1)번 포스팅에서 구해놨던 드랍 시 어떤 사분면일지 결정 할 함수를 생성해준다. 

3. DnD시 메서드에 따른 DnD 동작이 잘 되는지 확인해준다. (이 떄 DnD와 연관 없는 컴포넌트가 리렌더링 되지는 않는지 확인도 필요) 

1. 드래그 앤 드랍시 트리 구조가 변하게 되므로 이를 위한 메서드를 추가해준다.

0) 각 컨텐츠의 width, height, top , left는 tree.search 메서드에서 각각 계산 되어 할당 되므로 이동 메서드에서는 노드의 위치 변화만 신경 써주면 된다.

1) 드래그 중인 것은 source, 드랍 될 곳을 detiny 라고 명명

2) 드랍 될 곳이 소스와 같은 부모로부터 파생 된 즉 형제일 경우 둘의 위치만 바꿔주면 된다.

3) 부모가 같지 않을 경우에는 

3-1 ) source의 형제(sibling)가 기존 소스의 영역까지 먹어버린다. 즉 부모인 분할 노드의 자리에 형제를 두면 된다.

3-2) detiny가 먹고 있는 영역을 이제 소스와 양분할 것이므로 기존 트리에서 detiny 노드 위치에 새로운 분할 노드를 생성해 준 후

그 분할 노드 left right 자식 요소에 source와 destiny 노드를 연결해주면 된다. 

 (cf. 개발 모드에서 strictMode라면 상태 체크로 인해 두번 실행 된다. 그래서 setter에서 tree 모양 변경시 기존 tree와의 차이로 인해 렌더링 시 에러가 발생하여 잠시 꺼주었다)

우선 tree 클래스에 movePanelNode를 추가해준다.

movePannelNode({
        destinyId,
        position,
        sourceId,
    }: {
        sourceId: string;
        destinyId: string;
        position: '상' | '하' | '좌' | '우';
    }) {
        const orientation: IOrientaion = ['상', '하'].includes(position)
            ? 'horizontality'
            : 'verticality';
        const sourceNode = this.findPanelNode(sourceId)!;
        const destinyNode = this.findPanelNode(destinyId)!;

        const parentOfSource = this.findParentOfNode(sourceId)!;
        const parentOfDestiny = this.findParentOfNode(destinyId)!;

    }

놓일 포지션에 따라 orientation이 수직 분할이 나눠질 것.

위 로직 2,3번을 수행하기 위해 일단 소스와 데스티니의 부모인 분할 노드를 찾는다.

분할 노드를 찾는 행위는 root로부터 시작하여 BFS 방식으로 탐색을 진행하였다. 이진 트리의 특징과 달리 이 트리는 명확하게 좌우를 나누는 기준이 없으므로 완전 탐색이 필요하다고 생각했다. 

아래는 BFS방식을 활용한 find 메서드이다

findPanelNode(panelId: string) {
        const q: ILayoutNode[] = [this.root];
        while (q.length) {
            const currentNode = q.shift();
            if (!currentNode) continue;
            if (currentNode.type === 'panel') {
                if (currentNode.id === panelId) return currentNode;
                continue;
            }
            const leftNode = currentNode.getChildren('left')!;
            const rightNode = currentNode.getChildren('right')!;
            q.push(leftNode);
            q.push(rightNode);
        }
}

이것과 마찬가지로 부모를 찾는 행위도 BFS 방식으로 완탐을 하면서 자식이 타겟 id와 같다면 그 패널 노드를 반환하는 메서드를 생성

findParentOfNode(nodeId: string) {
        const q: ILayoutNode[] = [this.root];
        while (q.length) {
            const currentNode = q.shift();
            if (!currentNode || currentNode.type === 'panel') continue;
            const leftNode = currentNode.getChildren('left')!;
            const rightNode = currentNode.getChildren('right')!;
            if (leftNode.id === nodeId) return currentNode;
            if (rightNode.id === nodeId) return currentNode;
            q.push(leftNode);
            q.push(rightNode);
        }
}

이 메서드를 통해 부모를 찾았는데 만약 소스와 데스티니의 부모가 같다면 둘의 위치만 스왑해주면 되므로 movePanelNode에 아래의 코드를 추가해줬다.

if (parentOfDestiny.id === parentOfSource.id) {
            if (parentOfSource.getChildren('left')?.id === sourceId) {
                parentOfSource.appendNode('left', destinyNode);
                parentOfSource.appendNode('right', sourceNode);
            } else {
                parentOfSource.appendNode('left', sourceNode);
                parentOfSource.appendNode('right', destinyNode);
            }
}

부모가 같지 않다면 3번의 로직을 실행해야 하므로 소스의 형제 노드와 함께 부모의 부모인 조부모 분할 노드를 찾고 해당 조부모 노드에 형제를 추가해주고, 데스티니의 자리에 분할 노드 생성후 분할 노드의 left right에 소스와 데스티니를 추가해줬다.

connectSiblingToGrandparent(
        parentNode: ILayoutNode,
        siblingNode: ILayoutNode
    ) {
        const grandParent = this.findParentOfNode(parentNode.id)!;
        if (grandParent.getChildren('left')?.id === parentNode.id) {
            grandParent.appendNode('left', siblingNode);
        } else {
            grandParent.appendNode('right', siblingNode);
        }
    }

findSiblingNode(parentNode: SplitNodeInstance, panelId: string) {
    const left = parentNode.getChildren('left');
    if (left?.id === panelId) return parentNode.getChildren('right');
    return left;
}

movePannelNode({
    destinyId,
    position,
    sourceId,
}: {
    sourceId: string;
    destinyId: string;
    position: '상' | '하' | '좌' | '우';
}) {
    const orientation: IOrientaion = ['상', '하'].includes(position)
        ? 'horizontality'
        : 'verticality';
    const sourceNode = this.findPanelNode(sourceId)!;
    const destinyNode = this.findPanelNode(destinyId)!;

    const parentOfSource = this.findParentOfNode(sourceId)!;
    const parentOfDestiny = this.findParentOfNode(destinyId)!;

    if (parentOfDestiny.id === parentOfSource.id) {
        if (parentOfSource.getChildren('left')?.id === sourceId) {
            parentOfSource.appendNode('left', destinyNode);
            parentOfSource.appendNode('right', sourceNode);
        } else {
            parentOfSource.appendNode('left', sourceNode);
            parentOfSource.appendNode('right', destinyNode);
        }
    } else {
    
    // 부모가 같지 않은 이동일 경우 
        const siblingOfSource = this.findSiblingNode(
            parentOfSource,
            sourceId
        )!;

        this.connectSiblingToGrandparent(parentOfSource, siblingOfSource);

        const newSplitNode = new SplitNode({
            id: String(Math.random() * 10000),
            orientation,
            ratio: 0.5,
        });
        newSplitNode.appendNode('left', destinyNode);
        newSplitNode.appendNode('right', sourceNode);

        if (parentOfDestiny.getChildren('left')!.id === destinyId) {
            parentOfDestiny.appendNode('left', newSplitNode);
        } else {
            parentOfDestiny.appendNode('right', newSplitNode);
        }
    }
}

 

2. 각 컴포넌트에 draggable을 추가해준 뒤 (1)번 포스팅에서 구해놨던 드랍 시 어떤 사분면일지 결정 할 함수를 생성해준다. 

이제 1번 포스팅에서 구해놨던 사분면에서의 위치를 찾는 함수와 그림자를 생성해주는 함수를 복사해온다.

export type CalculateQuadrantProps = {
    startX: number;
    startY: number;
    width: number;
    height: number;
    currentX: number;
    currentY: number;
};
const calculateQuadrantPosition = ({
    currentX,
    currentY,
    startX,
    startY,
    height,
    width,
}: CalculateQuadrantProps): IPosition => {
    const inclineY = Math.round(
        ((startX - currentX) * height) / width + startY + height
    );
    const declineY = Math.round(
        ((currentX - startX) * height) / width + startY
    );

    // console.log(`inc:${inclineY}, dec:${declineY},cur:${currentY}`);
    if (currentY <= inclineY && currentY <= declineY) return '상';
    if (currentY <= inclineY && currentY >= declineY) return '좌';
    if (currentY >= inclineY && currentY <= declineY) return '우';
    return '하';
};

export type IPosition = '상' | '좌' | '우' | '하';
const displayShadowInDroppable = (
    position: IPosition,
    ref: RefObject<HTMLDivElement>
) => {
    let shadow: string;
    switch (position) {
        case '상':
            shadow = '0px -10px 10px rgba(0, 0, 0, 1)';
            break;
        case '좌':
            shadow = '-10px 0px 10px rgba(0, 0, 0, 1)';
            break;
        case '우':
            shadow = '10px 0px 10px rgba(0, 0, 0, 1)';
            break;
        default:
            shadow = '0px 10px 10px rgba(0, 0, 0, 1)';
    }
    ref.current?.style.setProperty('box-shadow', shadow);
};

3. DnD시 메서드에 따른 DnD 동작이 잘 되는지 확인해준다. 

useDragNDrop 이라는 커스텀 훅으로 방대해진 코드를 관리해줄 것이다.

필요한 상태로는 드래그 시 매 움직임마다의 불필요한 동작을 막기 위한 throttle,

드래깅 요소의 id를 담을 상태, 드랍될 곳의 id를 담을 상태, drop될 곳의 위치 값을 저장할 상태 => 이 3가지는 변화시 리렌더링 방지를 위해 ref로 할당 

const [throttle, setThrottle] = useState(false);
const currentDraggingItemId = useRef('-1');
const droppableTagetItemId = useRef('-1');
const dropedPosition = useRef<IPosition | undefined>();

이 커스텀훅에서 만들 이벤트 함수는 

drag start : 드래깅 중인 것의 상태 저장

const dragStartHandler = (id: string) => {
        currentDraggingItemId.current = id;
};

drag enter : 드랍 될 곳의 상태 저장

const dragEnterHandler = (targetId: string) => {
        if (currentDraggingItemId.current === targetId) return;
        droppableTagetItemId.current = targetId;
    };

drag over : 현재 드래깅 중인 요소가 드랍 될 요소의 상하좌우 중 어느 위치인지 판단하고 ui에 쉐도우를 입히는 것으로 직관적 효과 부여

const dragOverHandler = (
        targetId: string,
        ref: RefObject<HTMLDivElement>,
        calculateProps: CalculateQuadrantProps
    ) => {
        if (targetId === currentDraggingItemId.current) return;
        if (throttle === true) return;
        setThrottle(true);
        setTimeout(() => {
            setThrottle(false);
        }, 800);

        const position = calculateQuadrantPosition(calculateProps);
        displayShadowInDroppable(position, ref);
        dropedPosition.current = position;
    };

drag leave : drag over 에서의 ref를 떠났으므로 그림자 제거

const dragLeaveHandler = (ref: RefObject<HTMLDivElement>) => {
        ref.current?.style.setProperty('box-shadow', 'none');
};

drop : 드랍 이벤트가 끝나는 곳으로 여기에서 상태값들 초기화 및 tree 모양 변화 (위에서 생성한 메서드 실행)

replaceTree는 불변성을 위해 생성한 함수로 페이지 컴포넌트에서 동작을 구현한다.

const dropHandler = (ref: RefObject<HTMLDivElement>) => {
    if (
        currentDraggingItemId.current === droppableTagetItemId.current ||
        droppableTagetItemId.current === '-1'
    )
        return;
    ref.current?.style.setProperty('box-shadow', 'none');

    replaceTree(
        currentDraggingItemId.current,
        droppableTagetItemId.current,
        dropedPosition.current!
    );

    currentDraggingItemId.current = '-1';
    droppableTagetItemId.current = '-1';
};

위의 함수들은 리렌더링 시 재생성되게 되므롤 최적화를 위해서 메모이제이션 기법으로 감싼 후 return 해줬다.

import { BinaryTreeInstance } from '@/lib/binary/binary-tree';
import {
    calculateQuadrantPosition,
    displayShadowInDroppable,
    IPosition,
    type CalculateQuadrantProps,
} from '@/lib/binary/binary-utils';
import { RefObject, useCallback, useMemo, useRef, useState } from 'react';

export type ReturnTypeDragNDrop = ReturnType<typeof useDragNdrop>;

const useDragNdrop = (
    layoutTree: BinaryTreeInstance | undefined,
    replaceTree: (
        sourceId: string,
        destinyId: string,
        position: IPosition
    ) => void
) => {
    const [throttle, setThrottle] = useState(false);
    const currentDraggingItemId = useRef('-1');
    const droppableTagetItemId = useRef('-1');
    const dropedPosition = useRef<IPosition | undefined>();

    const dragStartHandler = (id: string) => {
        currentDraggingItemId.current = id;
    };

    const dragEnterHandler = (targetId: string) => {
        if (currentDraggingItemId.current === targetId) return;
        droppableTagetItemId.current = targetId;
    };

    const dragOverHandler = (
        targetId: string,
        ref: RefObject<HTMLDivElement>,
        calculateProps: CalculateQuadrantProps
    ) => {
        if (targetId === currentDraggingItemId.current) return;
        if (throttle === true) return;
        setThrottle(true);
        setTimeout(() => {
            setThrottle(false);
        }, 800);

        const position = calculateQuadrantPosition(calculateProps);
        displayShadowInDroppable(position, ref);
        dropedPosition.current = position;
    };

    const dragLeaveHandler = (ref: RefObject<HTMLDivElement>) => {
        ref.current?.style.setProperty('box-shadow', 'none');
    };

    const dropHandler = (ref: RefObject<HTMLDivElement>) => {
        if (
            currentDraggingItemId.current === droppableTagetItemId.current ||
            droppableTagetItemId.current === '-1'
        )
            return;
        ref.current?.style.setProperty('box-shadow', 'none');

        replaceTree(
            currentDraggingItemId.current,
            droppableTagetItemId.current,
            dropedPosition.current!
        );

        currentDraggingItemId.current = '-1';
        droppableTagetItemId.current = '-1';
    };

    const stableDragStartHandler = useCallback(dragStartHandler, []);
    const stableDragEnterHandler = useCallback(dragEnterHandler, []);
    const stableDragLeaveHandler = useCallback(dragLeaveHandler, []);
    const stableDragOverHandler = useCallback(dragOverHandler, [throttle]);
    const stableDropHandler = useCallback(dropHandler, [replaceTree]);

    return useMemo(
        () => ({
            dragStartHandler: stableDragStartHandler,
            dragEnterHandler: stableDragEnterHandler,
            dragLeaveHandler: stableDragLeaveHandler,
            dragOverHandler: stableDragOverHandler,
            dropHandler: stableDropHandler,
        }),
        [
            stableDragStartHandler,
            stableDragEnterHandler,
            stableDragLeaveHandler,
            stableDragOverHandler,
            stableDropHandler,
        ]
    );
};

export default useDragNdrop;

페이지 컴포넌트에서는 replacePlaceOfNodes를 아래와 같이 구현하여 상태의 불변성을 유지해줌.

const replacePlaceOfNodes = useCallback(
        (sourceId: string, destinyId: string, position: IPosition) => {
            setLayoutTree((tree) => {
                const { height, width } = tree!.getSize();
                const newTree = new BinaryTree(tree!.getTree(), width, height);
                newTree.movePannelNode({
                    sourceId,
                    destinyId,
                    position,
                });
                return newTree;
            });
        },
        []
);

이제 다이나믹 컴포넌트에 DND 커스텀 훅에서 반환하는 함수들을 할당해주면 동작이 원하는대로 구현

import { memo, useRef } from 'react';
import AComponent from './a-component';
import BComponent from './b-component';
import CComponent from './c-component';
import DComponent from './d-component';
import { IWillRenderComponent } from '@/lib/binary/binary-tree';
import { ReturnTypeDragNDrop } from '@/hooks/use-dragNdrop';

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

interface DynamicComponentProps {
    sectionDate: IWillRenderComponent;
    dragNDropMethod: ReturnTypeDragNDrop;
}

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

const DynamicComponent = memo(function C({
    sectionDate,
    dragNDropMethod,
}: DynamicComponentProps) {
    const { componentName, height, id, left, top, width } = sectionDate;
    // console.log(componentName);
    const {
        dragStartHandler,
        dragEnterHandler,
        dragLeaveHandler,
        dragOverHandler,
        dropHandler,
    } = dragNDropMethod;
    const sectionRef = useRef<HTMLDivElement>(null);
    const childrenRef = useRef<HTMLDivElement>(null);

    const Component = componentMap[componentName];
    return (
        <div
            ref={sectionRef}
            draggable
            onDragStart={() => dragStartHandler(id)}
            onDragEnter={() => dragEnterHandler(id)}
            onDragLeave={() => dragLeaveHandler(childrenRef)}
            onDragOver={(e) => {
                e.preventDefault();
                const [currentX, currentY] = [e.pageX, e.pageY];
                dragOverHandler(id, childrenRef, {
                    startX: left,
                    startY: top,
                    currentX,
                    currentY,
                    width,
                    height,
                });
            }}
            onDrop={() => dropHandler(childrenRef)}
            className="absolute border border-black p-4"
            style={{
                width,
                height,
                top,
                left,
            }}
        >
            <Component childrenRef={childrenRef} />
        </div>
    );
});

export default DynamicComponent;

초기 렌더링

1. D 컴포넌트를 A컴포넌트에 드랍한 결과

2. D 컴포넌트를 B에 드랍

 3. B를 D의 오른쪽에 드랍

 

 

번외) 커스텀 훅인 DND가 반환하는 함수들을 메모이제이션 했고 다이나믹 컴포넌트도 memo로 캐싱했음에도 한 구간에서 이동이 발생하면 전체가 리렌더링 됐다. 이것을 해결하기 위해 좀 더 최적화를 해봐야함

1. 스로틀링 구현을 state로 해서 드래깅 시 리렌더링이 계속 생겼음. useRef 관리로 대체

2. 노드를 이동 시켰을 때, 아예 새로운 트리로 setter에 리턴하게 돼서 렌더링 엔진이 변화가 없는 컨텐츠도 전체를 리렌더링 해줬음. 그래서 다이나믹 컴포넌트를 memo로 감싼 것에서 두번째 파라미터에 prev의 값과 next의 값이 같다면 true가 되어 메모이제이션 된 컴포넌트를 가져와서 리렌터링 되지 않게 막음 .

import { memo, useRef } from 'react';
import { AComponent, BComponent, CComponent, DComponent } from './index';
import { IWillRenderComponent } from '@/lib/binary/binary-tree';
import { ReturnTypeDragNDrop } from '@/hooks/use-dragNdrop';

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

interface DynamicComponentProps {
    sectionDate: IWillRenderComponent;
    dragNDropMethod: ReturnTypeDragNDrop;
}

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

const DynamicComponent = memo(
    function C({ sectionDate, dragNDropMethod }: DynamicComponentProps) {
		//....
	},
    (prev, next) => {
        return (
            prev.sectionDate.id === next.sectionDate.id &&
            prev.sectionDate.top === next.sectionDate.top &&
            prev.sectionDate.left === next.sectionDate.left &&
            prev.sectionDate.width === next.sectionDate.width &&
            prev.sectionDate.height === next.sectionDate.height
        );
    }
);

export default DynamicComponent;