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

18 hours ago 8

토스증권 PC의 종목 상세 페이지에 접속해 보신 적이 있나요? 종목 상세 페이지에는 차트, 호가, 주문하기 등과 같은 여러 패널들이 여러분을 반겨줍니다.

이때 각 패널들은 고정된 위치에 있지 않아요. 마우스로 드래그하면 패널의 위치를 옮길 수도 있고 패널의 크기를 조절할 수도 있습니다. 그리고 화면 편집 버튼을 눌러 패널을 추가하거나 삭제할 수도 있고, 이렇게 움직인 패널들의 레이아웃 상태는 나중에 다시 접속을 해도 저장되어 있습니다.

이 레이아웃 기능은 어떻게 구현되어 있을까요? 보통 이렇게 복잡한 인터랙션을 가진 UI를 구현할 때는 “어떤 라이브러리를 쓰지?”를 먼저 생각하곤 하죠. 그런데 토스증권 PC에서는 이 기능을 구현할 때 라이브러리를 사용하지 않고 모두 직접 구현했습니다.

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

복잡한 요구사항

2024년 중순, 토스증권 PC 서비스가 정식 출시를 위해 한창 달려가고 있었어요. 그런데 그때까지 개발되어 있던 종목 상세 화면의 레이아웃 UI는 현재와 달랐어요. PO와 디자이너분들은 토스증권 PC의 종목 상세 화면을 굉장히 중요한 핵심 기능으로 인식하고 있었는데, 이 화면이 구현되지 않았음을 굉장히 아쉬워하고 있었고 저도 이에 충분히 공감했어요. 그래서 이 기능을 구현하기로 결정했어요.

PO와 디자이너분들이 기대하는 완전한 그리드 레이아웃을 만들기 위해선 다음 기능들이 필요했어요.

  • 패널 추가/제거
  • 패널 크기 변경
  • 패널 종류별 최소 크기 지원
  • 적절한 초기 레이아웃
  • 레이아웃을 로컬 또는 서버에 저장
  • 터치 스크린(태블릿)도 지원
  • 패널의 디자인의 자유로운 커스텀
  • 그 외 등등...

꽤나 복잡해 보이죠? 구현이 복잡할 것으로 예상되었기 때문에 당연히 이에 맞는 적절한 라이브러리를 사용해야겠다고 생각하고 어떤 라이브러리가 적절할지 찾아 나서기 시작했어요.

직접 구현 vs 라이브러리 커스텀

문제는 열심히 찾아보아도 요구사항을 모두 만족시키는 라이브러리를 찾을 수 없었어요! 원하는 기능이 모두 있으면 디자인을 자유롭게 커스텀할 수 없고, 디자인을 커스텀할 수 있으면 사용성이 직관적이지 않았어요.

모든 요구사항을 만족시키는 라이브러리가 없는 상황에서, 다음 2개의 선택지 중 하나를 골라야 했어요.

  1. 라이브러리 코드를 커스텀하기
  2. 직접 구현하기

어떤 게 더 유리할까요? 지금까지 라이브러리를 사용할 때의 제 경험을 토대로 다음과 같은 그래프를 생각했어요.

어떤 문제를 해결할 때 그 문제를 해결하는 것을 상정하고 만들어진 라이브러리를 사용하면 문제를 쉽게 해결할 수 있어요. 라이브러리가 있는데도 직접 다 구현을 하게 되면 바퀴를 재발명하는 상황이 되어 불필요하게 시간을 더 쓰는 결과가 만들어져요. 이 경우에는 라이브러리를 사용하는 것이 직접 구현하는 것보다 리소스 소모가 더 적어서 유리해요.

그런데 주어진 문제의 요구사항이 복잡해져서 라이브러리가 상정하고 있는 문제의 범위를 넘어선다면 어떻게 될까요? 그러면 라이브러리를 그대로 사용할 수 없고 코드를 커스텀해서 사용해야 해요. 그런데 라이브러리를 커스텀하게 되면 라이브러리의 원래 구조가 새로운 요구사항에 잘 들어맞지 않는다거나, 라이브러리가 업데이트되었을 때 따라가기가 어려워지거나 하는 등 여러 어려움이 발생해요. 따라서 이렇게 커스텀을 하는 순간부터 소모 리소스가 기하급수적으로 증가한다고 생각했어요.

그리드 레이아웃 UI의 요구사항은 어떨까요? 저는 요구사항의 복잡도와 라이브러리의 상황들을 종합해 봤을 때, 이 경우에는 라이브러리 사용보다 직접 구현하는 게 더 유리하다고 판단했어요.

따라서 그리드 레이아웃 UI는 라이브러리 없이 바닥부터 직접 구현하기로 결정했어요.

레이아웃을 데이터로 표현하기

그리드 레이아웃 UI를 구현할 때 처음에 가장 막막했던 부분은 패널들의 레이아웃을 어떻게 데이터로 표현할 것인지었어요.

이때 가장 쉽게 떠올릴 수 있는 방법은 각 패널들의 위치와 크기 정보를 좌표로 표현하는 방법이에요.

type Panels = Array<{ x: number; y: number; w: number; h: number; }

이렇게 패널들을 픽셀값의 좌표로 표현하면 모든 레이아웃의 모양을 표현해낼 수 있습니다. 좌표로 표현된 패널들을 DOM에 그리는 것도 상당히 간단해요. 컨테이너 역할을 하는 <div>position: relative 스타일을 부여하고, 각 패널들에 대한 <div> 를 만들어 position: absolute 스타일을 부여하고 top , left , width , height 속성들을 적절하게 설정하면 돼요.

그러면 이 방식을 사용해서 레이아웃 데이터를 관리하면 될까요? 이렇게 좌표로 표현하는 방식은 직관적이지만, 아쉽게도 한 가지 치명적인 문제가 있어요. 패널들을 이동하거나 크기를 변경하면 좌표값들을 적절히 바꿔줘야 하는데, 이때 한 패널의 값만 바뀌는 게 아니라 다른 패널들도 함께 변경되어야 해요. 그런데 좌표로 표현하는 방식에서는 이 값을 어떻게 바꿔줘야 할지 규칙을 정해주기가 굉장히 난감해요.

분할 관점으로 접근

그리드 레이아웃 UI의 패널들을 관찰해보면 조금 다른 방법으로 접근할 수 있는데요, 패널들은 서로 겹치지 않으면서 패널 사이 간격 외엔 빈 공간이 없도록 배치되어야 해요. 그러면 패널들을 모두 모으면 하나의 큰 직사각형이 되고, 이걸 반대로 하면 커다란 직사각형 도화지를 두 조각으로 만드는 것을 반복해서 패널들을 만들 수 있어요.

이때 한번 패널 조각을 자를 때마다 자른 위치에 원 하나를 놓고 그걸 양쪽 선으로 이어볼게요. 그러면 위의 그림을 아래와 같이 그릴 수 있어요.

그런데 이 모양 어딘가 익숙하지 않으신가요? 맞아요, 자료 구조에서 등장하는 이진 트리에요. 패널들의 레이아웃은 이진 트리 구조로 표현할 수 있었어요.

레이아웃 이진 트리

그리드 레이아웃을 표현하기 위해서 두 가지 종류의 트리 노드를 정의했어요. 하나는 화면에 보이는 하나의 패널 그 자체를 의미하는 “패널 노드” 에요. 그리고 다른 하나는 패널 조각을 자르는 것을 의미하는 “스플릿 노드” 에요. 이때 스플릿 노드는 분할이 가로로 이루어졌는지 세로로 이루어졌는지에 대한 정보와, 분할하는 비율값(0~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; }

이제 이 두 가지 종류의 노드들을 사용하면 패널들의 배치 상태를 이진 트리로 완전하게 표현할 수 있어요. 다음 그림에서 왼쪽의 이진 트리는 오른쪽의 그리드 레이아웃과 정확히 같은 상태를 나타내요. (스플릿 노드 밑의 숫자로 분할 비율을 표시했어요)

패널 조작

이진 트리로 그리드 레이아웃을 표현하면 패널들의 크기 변경 또는 이동을 수월하게 처리할 수 있다는 장점이 있어요.

패널의 크기를 조절하는 처리는 간단해요. 유저가 패널의 크기를 조절하려 패널 사이의 틈을 마우스로 드래그했다고 가정해볼게요. 이때 이진트리에서 그 틈에 해당하는 스플릿 노드의 분할 비율값만 변경해주기만 하면 패널의 크기 조절을 처리할 수 있어요.

이번에는 패널을 이동시키는 경우를 살펴보기 위해, 다음 그림에서 유저가 3번 패널을 드래그해서 1번 패널의 밑으로 이동시키는 상황을 가정해 볼게요.

그러면 레이아웃 이진 트리에서 3번 패널이 원래 위치에서 빠지니 3번 패널 노드를 원래 자리에서 제거할 수 있어요. 이러면 빨간색 스플릿 노드도 필요 없어지니 제거할 수 있고, 대신 4번 노드를 노란색 스플릿 노드의 오른쪽에 끌어올려서 연결해요. 그리고 이제 1번 패널 자리를 가로로 분할해서 3번 패널을 그 밑에 넣어야 하니, 1번 패널 노드 자리에 새로운 보라색 스플릿 노드를 만들어야 해요. 그리고 보라색 스플릿 노드의 왼쪽에는 1번 패널을 연결하고 오른쪽에는 3번 패널을 연결하면 패널의 이동 처리가 완료돼요.

이때 한 패널 위에 다른 패널을 드래그해서 놓을 때, 어느 방향으로 끼워 넣을지는 어떻게 결정할 수 있을까요? 패널의 각 모서리를 연결하는 직선을 X자 모양으로 한번 그려볼게요. 그러면 마우스 포인터가 두 직선이 만드는 삼각형들 중 어디 위에 있냐에 따라 패널을 어느 방향으로 끼워 넣어야 할지를 알 수 있어요.

이제 마우스 포인터가 어느 삼각형 영역에 있는지를 알아내기 위해서는 고등학교 수학을 살짝 사용하면 돼요. 두 직선에 대한 부등식을 세워 마우스 포인터의 위치 좌표가 각 직선의 위에 있는지 아래에 있는지를 확인하면 어느 삼각형 영역에 마우스가 있는지를 결정할 수 있어요. 예를 들어 아래 그림에서는 마우스 포인터가 파란색 직선보다 아래에 있고 초록색 직선보다 위에 있기 때문에 패널의 오른쪽에 다른 패널을 넣는 상황이라고 판단할 수 있어요.

화면에 그리기

이진 트리로 표현한 레이아웃 트리는 좌표 방식 표현과 다르게 실제 화면에 어떻게 그릴지 바로 직관적으로 떠오르진 않아요. 그러나 좌표 방식은 화면에 CSS로 손쉽게 그릴 수 있어요. 따라서 레이아웃 트리를 좌표 표현으로 변환하고 이걸 다시 화면에 그려서 두 가지 방법의 장점을 모두 가져가는 방법을 떠올릴 수 있어요.

그러면 각 패널들에 대한 좌표는 어떻게 계산해야 할까요? 이때 트리 순회 알고리즘을 활용할 수 있어요. 레이아웃이 그려질 화면의 가로와 세로 픽셀 크기가 주어졌을 때, 이진 트리를 재귀적으로 순회해서 모든 패널의 좌표와 크기 정보를 알아낼 수 있어요.

예를 들어 다음과 같은 레이아웃 트리가 주어지고, 그려야 하는 화면의 영역이 가로가 1200px 이고 세로가 900px 인 상황을 살펴볼게요.

이때 트리의 루트인 파란색 스플릿 노드부터 먼저 방문해 볼게요. 화면의 가로가 1200px 인데, 파란색 스플릿 노드는 이 영역을 0.5 = 1:1 로 분할해야 한다고 표시하고 있어요. 그러면 1번 패널의 가로 크기는 자연스럽게 600px이 돼요. 이제 왼쪽 자식인 1번 패널을 방문하면 1번 패널의 크기를 가로 600px, 세로 900px로 결정할 수 있어요.

이제 파란색 스플릿 노드의 오른쪽 자식인 노란색 스플릿 노드를 방문해 볼게요. 노란색 스플릿 노드는 가로 600px, 세로 900px 영역을 차지하고 있고, 위아래로 0.33 = 1:2 비율로 분할해야 한다고 표시하고 있어요. 그러면 영역의 위쪽은 세로가 300px이고 아래쪽은 세로가 600px로 분할되어야 해요. 이제 노란색 스플릿 노드의 왼쪽 자식인 2번 패널을 방문하면 2번 패널의 크기를 가로 600px, 세로 300px로 결정할 수 있어요.

그다음으로 노란색 스플릿 노드의 오른쪽 자식인 빨간색 스플릿 노드를 방문할게요. 이번에는 가로세로 600px 영역을 좌우로 0.5 = 1:1 비율로 분할해야 하는 상황이고, 그러면 이 영역은 각각 가로가 300px 이고 세로가 600px인 두 영역으로 분할돼요. 이제 3번 패널과 4번 패널을 방문하면 두 패널의 크기는 모두 가로 300px, 세로 600px으로 결정돼요.

이렇게 하면 모든 패널의 크기 정보를 알 수 있게 되었어요. 여기에 더해 순회할 때 각 영역의 위치 좌표를 함께 저장하면 모든 패널의 위치와 크기 정보를 계산해 낼 수 있게 돼요.

컴포넌트 구성

이제 이것을 실제 React 컴포넌트로 어떻게 구성했는지를 설명해볼게요.

지금까지 살펴보았던 레이아웃 이진 트리에서 패널의 좌표를 얻어내는 부분은 하나의 모듈로 묶을 수 있어요. 이 모듈을 클래스로 만들었고 “Layout Manager” 라고 이름을 붙일게요.

Layout Manager에서 나온 좌표를 DOM 에 반영하면 패널들을 화면에 그릴 수 있어요. 그리고 DOM에서 오는 마우스 이벤트를 받아서 Layout Manager의 레이아웃 이진 트리에 반영하고 좌표를 다시 계산하면 패널의 이동이나 크기 조절을 처리할 수 있게 돼요.

이 부분을 “Movable Grid” 라는 이름의 React 컴포넌트로 감쌀 수 있어요. 그런데 이때 화면에 패널을 그리려면 한가지 정보가 더 필요해요. 레이아웃 트리와 좌표에서는 패널이 어떤 모양으로 그려져 있는지에 대한 정보는 있지만, 패널 안이 실제로 어떤 역할을 하는지에 대한 정보가 없어요. 그래서 각 패널이 어떤 패널인지를 뜻하는 패널 정보가 Movable Grid 컴포넌트에 추가로 필요해요.

이제 각 패널 안에 차트, 호가 등의 실제 기능을 넣어야 해요. 그런데 이때 Movable Grid는 패널들의 모양만 잡으면 될 뿐, 패널 안에 들어가는 기능의 동작과는 딱히 관련이 없어요. 그래서 Movable Grid는 Headless 컴포넌트의 컨셉을 차용해 패널들을 화면에 그리는 것과 패널을 조작하는 연산만 책임지고, 패널 안에는 외부에서 주입한 React 컴포넌트를 패널 정보를 보고 적절한 위치에 그리도록 구성했어요.

Movable Grid 의 인터페이스를 사용처에서의 코드로 나타내보면 다음과 같아요.

<MovableGrid panelTypes={{ 차트: () => <ChartPanel />, 호가: () => <QuotePanel />, 실시간시세: () => <RealtimePricePanel />, 주문하기: () => <OrderPanel /> }} components={{ MoveGlowBar, ResizeGlowBar }} />

레이아웃 저장/불러오기

위의 내용을 다시 정리해 보면, 레이아웃 이진 트리와 패널 정보가 있으면 화면에 그리드 레이아웃을 그릴 수 있어요. 따라서 이 두 정보를 저장해 두었다가 Movable Grid 컴포넌트 안에 복구하기만 하면 레이아웃을 저장하고 불러오는 기능을 손쉽게 구현할 수 있어요.

실제로 토스증권 PC에서는 레이아웃의 정보를 다음과 같은 형태의 JSON 데이터로 저장하고 있어요.

{ version: "1.0.0", layout: <이진 트리 데이터>, panels: [ { id: "1", type: "차트" }, { id: "2", type: "호가" }, { id: "3", type: "실시간시세" }, { id

결과

이런 방식으로 직접 그리드 레이아웃 UI를 이진 트리 자료구조를 통해 구현한 결과 처음에 상정했던 요구사항을 모두 구현해낼 수 있었어요. 비록 실제 구현할 때는 이 글에서 설명한 부분들 이외의 좀 더 디테일하게 신경 써야 하는 부분들이 있었지만, 이진 트리를 활용한다는 틀을 잡은 후부터는 큰 난관이 없었어요. 그 결과 PO와 디자이너분들이 “상상했던 대로 모두 구현되었다”라는 코멘트도 받을 수 있었답니다.

Read Entire Article