시계열 데이터의 시각화 방법은 여러가지가 있지만 그 중 캔들차트는 많은 금융 데이터 시각화에 널리 사용되고 있는 기본 그래프이다. 차트를 분석하여 투자를 하는 차티스트가 있을만큼 캔들차트는 시계열의 행동 예측에 있어 중요한 데이터 시각화 방법을 제공한다. 캔들차트의 구체적인 구현 방법을 소개하고 싶지만 구현 코드가 수천줄에 달하고 지표 생성, 그리기 함수가 많아서 불가피하게 주요 아이디어만 소개한다. (자세한 구현 사항은 프로젝트의 일부를 이식해 데모 버전을 만들어 두었습니다. 깃허브에서 확인 가능 합니다. 구현화면은 데모 버전으로 레이아웃 등 최적화가 되어있지 않지만 이곳에서 확인 가능합니다.)
D3.js로 그래프를 그리기 위해 컴포넌트 분할이 꼭 필요한 것은 아니다. 전통적으로 리액트 이전의 D3.js 사용자는 한 파일에 모든 로직(초기화, 업데이트, 상호작용 등)을 담는 방식이 일반적이었다. 이는 실제로 잘 정돈된다면 유지보수도 어렵지 않으며, 라이프사이클 이슈나 성능 문제가 필연적으로 발생하는 것은 아니다.
하지만 리액트와 결합 시 컴포넌트화는 큰 이점이 있다. 각 기능(레이아웃, 데이터 그리기, 상호작용 등)을 별도 컴포넌트로 나누면, 코드 재사용성, 유지보수성, 테스트 용이성이 높아진다. 리액트와 D3를 결합할 때는 D3가 DOM 직접 제어, 리액트는 가상DOM을 관리하기 때문에 역할 분리가 명확한 구조가 오류를 줄이고, 상태관리도 용이하다.
컴포넌트 구조는 구현 목표의 기능, 규모, 개인의 코딩 스타일에 따라 크게 달라질 것이다. 동적 데이터에 대한 업데이트, 여러 데이터의 비교 시각화, 상호작용등을 고려하기 위해 각 요구사항의 규모에 따라 "레이아웃 그리기", "데이터 그리기", "상호작용하기", "(데이터)업데이트" 로 나눈면 어지러운 상황을 피할 수 있겠다.
하지만 D3.js는 애초에 enter-update-exit의 자체 패턴이 존재하고, DOM요소를 직접 조작하는 반면, 리엑트는 리엑트 나름대의 생애주기관리 패턴을 제공하고, 가상돔을 조작한다는 면에서 D3.js요소의 역할을 여러 컴포넌트에 분배하고 관리하는게 쉬운 작업은 아니다. 가령, 데이터의 일부만 바뀌었는데 컴포넌트 렌더링이 트리거 되면서 전체 그래프 요소들이 rerednering 된다던가, useEffect 혹이 이벤트 핸들러에 전달한 함수가 React의 최신 state/props를 참조하지 않는 경우가 자주 있다. 결국 react, D3.js가 제공하는 각각의 생애주기 관리 패턴 사이에서 그래프의 기능이 의도한대로 동작하게 하는 것은 복잡성을 증가시킨다.
이번 캔들차트는 과거 데이터(정적 데이터)를 다루고 이를 사용자의 요구에 따라 동적 시각화한다. 그리는 요소들이 많기 때문에 비슷한 함수들이 나열될 수 있지만, 상호작용은 줌, 해당거래로 이동과 같이 간단한 요소들이 많아서
컴포넌트는 `chartLayout`과 `draw`로 나눈다. `chartLayout`에서 리렌더링이 필요없는 요소를 관리하고 시가,고가,저가,종가를 포함한 캔들데이터와 지표데이터를 받아 `draw`에 전달한다. `draw`에서는
전달받은 데이터들을 시각화하고 줌, 목표지점 이동등의 간단한 상호작용을 관리한다.
AI가 코딩에 널리 사용되면서, 함수 작성이 많이 편해졌다. 아직 프로젝트의 구조 파악이나 문제해결 아이디어등이 많이 부족하지만, 구조를 잡아주고 함수를 배치할 수만 있다면 일반함수 작성은 AI에 맡겨 조수처럼 부리며 개발하기 편해졌다. 모든 코딩이 그러하겠지만 D3.js는 사용법을 알아서 구조만 잡는다면 일반함수와의 싸움이라, 특히 AI효율이 높은 것 같다. (하지만 질문 표본이 적어서 그런지, "어떤 기능 추가해줘", "이런 문제 해결해줘"와 같은 질문에는 취약하다.) 라이브러리 제공 그래프 기준 D3.js가 일반 라이브러리 대비 구현시간이 5-10배 정도 걸렸다고 하면, AI를 사용하고 나서는 3-5배 밖에(?) 안걸리는 느낌이다.
`ChartLayout`의 구조는 다소 간단하다. `ChartLayout`이 데이터 조작에 대한 책임을 지지 않으므로 svg를 결정하고, 리렌더링이 필요없거나 미리 생성 가능한 축, 베이스라인, 가이드라인 등을 렌더링한다. 렌더링이 완료되면 그 위에 그릴 캔들, 지표 등 본격적인 데이터 시각화를 위해 `Draw`컴포넌트를 호출한다.
안에서는 svg를 정의하고 기본 레이아웃을 잡는다. 이하 코드도 이런 작업들의 연속이다. 아직 canvas context는 생성하지 않았다.
`Draw` 컴포넌트는 전달받은 데이터를 시각화하고 간단한 상호작용(zoom, fucos)을 처리한다. `Interaction`컴포넌트에 상호작용을 일임하여도 되지만 일단 `Interaction`에 일임하려고 하면 전달인자나 생애주기 패턴 등 복잡성이 증가하여 구조를 잡는 과정에서 생성만하고 실제로는 아직 사용하지 않았다. (개발이 진행중이라, 이후 업데이트를 고려해 추가만 시켜놓았다.)
canvas context를 생성하기 전에 데이터를 화면에 뿌리기 위한 scale을 정해줘야 한다. 아무리 canvas가 svg에 비해 성능적 우위가 있다지만 데이터를 처리하는데 한계가 있기 마련이고 부하를 덜어주기 위해 사용자가 보는 현재 범위의, 최대 10일치 데이터만 필터링 하기로 한다. 이를 위해 `getVisible-` 함수를 사용하고 이를 토대로 scale을 얻어 데이터 시각화를 위한 준비를 마친다.
캔버스 context인 `ctx`를 생성 한 후, 데이터 시각화를 위한 요소들을 그리는 함수들은 호출한다. 이후 listeningRect를 추가하여 mouseover등의 유저상호작용을 구현하고 zoom 동작을 구현하게 된다. draw함수 안에는 많은 하위 함수들이 존재하고 이 하위함수를 생성하는 일을 AI에게 일임하여 시간을 크게 절약할 수 있다. 하지만 draw함수를 배치, listeningRect생성 후 상호작용 구상 등은 개발자의 몫이다.
zoom함수내부에서는 위 작업의 반복이다. 사용자가 설정한 줌 레벨에 맞춰 visible data를 다시 필터링하고 canvas context에 draw함수를 호출하여 데이터를 그린다. focus동작은 기존 요소들을 이어받아 해당 위치로 이동하는 동작 대신 기존 ctx를 지우고 다시 그리는 방식을 선택했다. 이 때문에 focus동작에 에니메이션을 추가하진 못하지만 zoom동작을 보다 간단하게 유지할 수 있다는 장점이 있다.
이전까지 작성했던 D3.js시각화는 보통 500-1000줄 이내의 독립적인 상호작용을 포함한 그래프 였지만 이번에 조금 복잡한 D3.js 요소를 리엑트에 적용해 봤다. 아직은 생애주기 패턴이나 상호작용 등의 앱 동작에 대한 일관성을 유지하기엔 구조적 한계가 있음을 느꼈다. 그럼에도 이번 구현으로 더 다양한 패턴과 구조를 시도하면서, 일관성을 갖춘 시각화를 구현해야겠다는 과제를 남긴 시도였다.