이번에 회사에서

차트 라이브러리를 결정하고 알아볼 필요가 있었다.

처음에는 그까이꺼 뭐, 라이브러리 특히나 차트인데. 분명 내 입맛에 맞는 차트 라이브러리 하나 없겠어? 라는 안일한 생각에 빠졌다.

하지만, 그게 웬걸?

역시나.... 난관에 부딪혔다.

물론, 구현 자체에는 큰 어려움이 없었지만, 가장 어려운 점은 현재 우리 회사 프로젝트 및 세부 스펙과 디자인 기획에서의 요구 사항을 적절하게 수용할 수 있는 것이 중요했다.

그래서 이번에는 그때 내가 겪은 고민의 지점들과 그때 했던 생각들 그리고 이를 토대로 어떻게 결정을 짓고 해결해 나갔는 지에 대해 한 번 적어보려고 한다.

누군가는 회고하고 돌이켜 보는 것만큼 중요한 성장이 없다고 했듯... 나 역시 지금 빨리 기억이 증발해 버리기 전에 회고해 봐야겠다.

1. 요구사항 분석.

차트 라이브러리를 사용하는 데 있어 요구 사항은 크게 개발적인 요소와 기획, 디자인 적인 요소를 나뉘었다.

  1. 개발적인 요소
    • Npm 다운로드 수 및 지속적인 유지 보수.
    • 친절한 공식 문서의 유무.
  2. 기획, 디자인적인 요소
    • 디자인과 기획에 맞는 화면을 그려줄 수 있는 가?
    • 총 2가지 종류(라인, 도넛)의 차트를 지원하는가?
    • 라인 차트의 경우, 개별 데이터 마다 마우스 호버 시에 커스텀 툴팁이 가능한가?

이를 토대로 하나씩 차트 관련 라이브러리를 검색하고 살펴보기 시작했다.

2. 시장 분석.

그 결과, 몇 개로 압축되었다.
라이브러리 마다 각자의 장단점이 있었고, 그에 따라 선택의 기준에 부합하는 지 하나씩 살펴봤다.

1) Chartjs

먼저, 차트 js. 아무래도 가장 오래된 만큼, 유지보수나 커스텀 적인 요소도 모두 가능했다.

하지만, 그럼에도 불구하고, 아쉬운 점은 아무래도 리액트에서 사용하기 위해서는 wrapping한 라이브러리를 다시 사용해야 했다.

거기에 더해, 역시나 가장 난해할 것으로 예상 되었던 라인 차트 커스텀 툴팁이 조금 난해했다.

아래 코드가 chartjs로 구현해 본 코드다.

사실 이렇게 하면 구현은 가능하다.

  <Line
        data={chartData}
        options={{
          interaction: {
            intersect: false,
            mode: 'nearest',
            axis: 'xy',
          },
          layout: {
            padding: {
              bottom: 40,
              top: 30,
            },
          },
          scales: {
            x: {
              grid: { color: '#efefef' },
              ticks: {
                color: '#acacac',
              },
            },
            y: {
              grid: { color: '#efefef' },
              ticks: {
                color: '#acacac',
              },
            },
          },
          maintainAspectRatio: true,
          responsive: true,
          plugins: {
            legend: {
              position: 'bottom',
              labels: {
                font: {
                  family: 'AppleSDGothicNeo',
                },
                color: `${theme.colors.gray.text19}`,
                padding: 30,
                usePointStyle: true,
                boxPadding: 12,
                boxHeight: 6,
              },
            },
          },
        }}
      />

다만, options 자체에 저렇게 전부 다 넣어줘야만 했고, 이는 사실 유지 보수의 관점에서 좋지 못했다.

이게 왜 유지 보수의 관점에서 좋지 않은 지, 한 참을 고민도 해보고 팀원들과 얘기도 해 봤다. 그 과정에서 이유는 더 명확해졌다.

저렇게 하게 되면, 이 컴포넌트의 툴팁은 커스텀 화가 아닌, line이라는 컴포넌트의 options에 종속되어서 새로운 변경 사항이 있을 때, 유연한 대처가 어렵다는 것이었다.

물론, options를 따로 모듈화 시켜서 분리 후, 개별적으로 함수로 처리해서 쓴다면 가능은 하겠지만, 그럴 경우, chartjs의 개별 타입을 전부 일일이 지정해 주는 번거로움이 발생할 것 이다.

이를 토대로, 결국 chartjs는 우리의 선택지에서 멀어지게 되었다.

2) ApexChart

ApexChart 역시 chartjs와 비슷한 커스텀 툴팁 구조였다...

그래서 아쉽지만, 선택할 수 없었다.

3) nivo

다음으로 알아본 것은 nivo였는데, nivo는 정말 문서가 너무 좋았다.

마치 스토리북 처럼 직접 커스텀 한 부분이 어떻게 코드로 구현되는 지 알 수 있었다.

하지만, 선택할 수는 없었다.

보이는 것처럼, 아무래도 options 객체 형식이기도 했고, 당시 d3.js의 종속성 이슈로 계속 이슈가 올라왔지만, 이에 대한 대처가 제대로 되지 않아, nextjs 빌드 시에 계속 깨지는 이슈가 해결되지않았다.

그래서 너무 위험 부담이 커서 도입이 꺼려졌다.

4) Rechart

그래서 결국 우리 팀이 최종 차트로 선택한 라이브러리는 Rechart였다.

리차트를 코드로 구현해 보면 아래와 같다.


<Container $w={w} $m={m} $h={h}>
      <LineChart
        width={730}
        height={300}
        data={data}
        margin={{ top: 10, right: 30, left: 20, bottom: 0 }}
      >
        <CartesianGrid strokeDasharray="0" />
        <XAxis dataKey="date" height={50} />
        <YAxis />
        <Tooltip cursor={false} wrapperStyle={{ display: 'none' }} />
        <Legend
          width={730}
          align="center"
          height={19}
          iconType="circle"
          iconSize={11}
          formatter={renderColorfulLegendText}
        />

        {LineData.map((value) => (
          <Line
            key={value.dataKey}
            strokeWidth={2}
            dot={false}
            dataKey={value.dataKey}
            stroke={value.strokeColor}
            activeDot={<CustomLineActiveDot />}
          />
        ))}
      </LineChart>
    </Container>

위의 chartjs와 비교해 봤을 때, 확실히 선언적인 리액트 구조와 닮아있다.

그러다 보니, 기존에 chartjs는 리액트 사용시 랩핑 추가 라이브러리 종속성을 가져가야 했는데, 그것도 없다.

위의 코드에서 나와 있는 것처럼, options 객체가 없고, 각각 분리된 컴포넌트 구조다.

이게 왜 어떻게 유지 보수성이 좋다고 하는 건가? 반문할 수도 있다.
예를 들어, 디자인 적인 요구 사항이 바뀌어 새로운 형태의 디자인 요구 사항이 있다고 할 때, 우리는 lineChart 전체에 종속 받는 것이 아니라, 개별 각 요소 컴포넌트에서 변경해 주면 된다.

이를 토대로 우리가 가장 변경이 잦을 것 같았던 tooltip 부분도 변경이 유연한 구조라고 판단이 들었다.

이런 저런, 이유들로 결국 차트 라이브러리 하나를 선택하는 데 있어서도 꽤나 많은 연구와 시간이 들었다.

하지만, 그 자체로도 정말 재밌었고, 각 팀의 비즈니스의 사정에 따라 여러 가지 요건을 따져보고 라이브러리를 도입하는 것이 필요하겠다는 것을 다시 새삼 깨달았다.

3. 새로운 challenger!

그냥 이렇게 잘 끝났으면 좋았겠지만...

우리에게는 또 다른 Challenger가 나타나고 말았다.

물론, 개발은 항상 고난의 연속이지만... 이제는 그 고난에 대해서 그리고, 어떻게 그 고난을 해결했는 지에 대해 한 번 적어보겠다.

1) 문제의 커스텀.

다시 한 번 문제의 요구 조건을 살펴보자.

위의 예시 그림처럼, 비즈니스의 요구 조건은 여러 형태의 데이터를 보여주는 라인차트 내에서 특정 지표에 마우스 호버시, 툴팁을 띄워주는 것이었다.

에이 그럼 뭐 쉽겠네? 저게 뭐가 어려워? 할 수 있다. 나도 처음에는 그렇게 생각했다.

하지만...

그게 막상 쉽지는 않았다.

왜냐면, 아래의 조건들로 인해 힘들었다.

1. 라이브러리 내부에서 존재하는 default 속성들 제거.

기본적으로 라이브러리는 사용자가 뭔가 지정을 해주지 않으면 그들이 만들어 놓은 기본 구조 안에서 돌아가게 되어 있었다.

그건 rechart역시 마찬가지.

위의 영상처럼, 호버 시에 원래 각각의 지점에 해당하는 툴팁의 데이터만 표출이 되어야만 했는데, 그게 아니라 전체 y축 데이터가 전부 노출되었다.

추가로, dot점 역시 노출되지 않아야 했다.

그래서 기존 속성을 죽이고 어떻게 커스텀 시킬 지에 대해 다시 공식 문서, 깃허브 이슈, 구글링을 해야만 했다.

이건 결국 옵션을 공부해야만 했고, 그 과정 속에서 코드로 간단히 구현할 수 있었다.

기존 코드에서 tooltip에서 저 옵션들을 통해서 default tooltip을 숨길 수 있었다.

추가로, line 그래프 내에서 dot들을 없애기 위해서 false로 주고, activeDot쪽에 커스텀 툴팁을 담아줘야 했다.

이렇게 하게 되니, 라인 그래프 내에서 기존 툴팁 관련된 옵션들은 모두 제거 되었다.

하지만, 문제가 다시 하나 더 있었다.

바로 그건 이번에는 아예 아무것도 안 뜨는 거 아닌가? ㅎㅎ

2) 역시 다른 나와 같은 동지는 누군가 존재 한다.

도대체 문제의 원인이 무엇인지 도저히 감이 잡히지 않았다.
여러 시도를 해 봤지만, 원인을 알 수 없었다.

처음에는 z-index 문제인가도 싶어서 바꿔 봤지만, 소용이 없었다. ㅜㅜ

그러다 우연히, 검색을 통해 누군가 codesandbox에 올린 코드를 참조했다.

참조 링크:
https://codesandbox.io/p/sandbox/recharts-custom-tooltip-on-hover-pwoz7o

나와 구현한 것이 거의 코드 상으로 비슷했지만, 가장 큰 차이는 customActiveDot 내부 구조였다.

코드를 비교해 보면 아래와 같다.

참조코드

  <ChakraTooltip
      delayDuration={0}
      label={
        <Box flex="column" justify="center" items="center" css={{ py: "$1" }}>
          <Text size="sm" color="text2">
            {label}
          </Text>
          {Object.entries(data).map(
            ([key, value]) =>
              displayValue(value) && (
                <Text key={`${key}-${value}`} size="sm" color="text1">
                  {value} {key}
                </Text>
              )
          )}
        </Box>
      }
    >
      <circle {...rest} cx={cx} cy={cy} />
    </ChakraTooltip>

vs

내코드

  <TooltipContainer>
        <TooltipText>{date} |</TooltipText>
        {Object.entries(dotKey).map(([value, keys]) => (
          <TooltipText key={`${keys.join('-')}-${value}`}>
            {keys.join(', ')} {value}</TooltipText>
        ))}
      </TooltipContainer>

그러다 문득, 저 chakara-ui를 사용하고 안 하면서 차이가 발생한 것을 봤다. 거의 문제의 핵심에 다가온 느낌... 이었다.

하지만, 단순히 chakar-ui 사용 유무의 차이는 아닐 것이고, 분명 근원적인 차이가 있겠다 생각이 들었다.

3) 라이브러리 소스 코드에 답이 있다.

그래서 chakartooltip이 어떻게 구성된 것인지 하나씩 뜯어봤다.

아래는 chakara-tooltip의 코드 구성이다.

     {tooltip.isOpen && (
          <Portal {...portalProps}>
            <chakra.div
              {...tooltip.getTooltipPositionerProps()}
              __css={{
                zIndex: styles.zIndex,
                pointerEvents: "none",
              }}
            >
              <StyledTooltip
                variants={scale}
                {...(tooltipProps as any)}
                initial="exit"
                animate="enter"
                exit="exit"
                __css={styles}
              >
                {label}
                {hasAriaLabel && (
                  <VisuallyHidden {...hiddenProps}>{ariaLabel}</VisuallyHidden>
                )}
                {hasArrow && (
                  <chakra.div
                    data-popper-arrow
                    className="chakra-tooltip__arrow-wrapper"
                  >
                    <chakra.div
                      data-popper-arrow-inner
                      className="chakra-tooltip__arrow"
                      __css={{ bg: styles.bg }}
                    />
                  </chakra.div>
                )}
              </StyledTooltip>
            </chakra.div>
          </Portal>
        )}

여기서 주목할 점은 바로 react 사용자라면 익숙한 portal이다. 그래서 저 Portal이 의심이 갔다.

/**
 * Portal that uses a custom container
 */
const ContainerPortal: React.FC<ContainerPortalProps> = (props) => {
  const { children, containerRef, appendToParentPortal } = props
  const containerEl = containerRef.current
  const host = containerEl ?? (isBrowser ? document.body : undefined)

  const portal = React.useMemo(() => {
    const node = containerEl?.ownerDocument.createElement("div")
    if (node) node.className = PORTAL_CLASSNAME
    return node
  }, [containerEl])

  const forceUpdate = useForceUpdate()

  useSafeLayoutEffect(() => {
    forceUpdate()
  }, [])

  useSafeLayoutEffect(() => {
    if (!portal || !host) return
    host.appendChild(portal)
    return () => {
      host.removeChild(portal)
    }
  }, [portal, host])

  if (host && portal) {
    return createPortal(
      <PortalContextProvider value={appendToParentPortal ? portal : null}>
        {children}
      </PortalContextProvider>,
      portal,
    )
  }

  return null
}

export function Portal(props: PortalProps) {
  const { containerRef, ...rest } = props
  return containerRef ? (
    <ContainerPortal containerRef={containerRef} {...rest} />
  ) : (
    <DefaultPortal {...rest} />
  )
}

역시나 내 짐작이 확신이 되는 순간이었다. 너무 기뻤다. ㅎㅎ

결국 React의 포탈을 쓰고 있는 것이었고, 그렇다면 나도 chakara-ui 구조처럼 portal을 쓰면 되겠다고 생각했다.

그래서 구조를 다음과 같이 수정해 봤다.

   <ReactModal
        isOpen={isActiveDot}
      >
        <TooltipContainer>
          <TooltipText>{date} |</TooltipText>
          {Object.entries(dotKey).map(([value, keys]) => (
            <TooltipText key={`${keys.join('-')}-${value}`}>
              {keys.join(', ')} {value}건
            </TooltipText>
          ))}
        </TooltipContainer>
      </ReactModal>
      <circle
        fill="transparent"
        r={5}
        cx={cx}
        cy={cy}
        onMouseEnter={() => {
          setIsActiveDot(true);
        }}
        onMouseLeave={() => {
          setIsActiveDot(false);
        }}
      />

기존에 portal을 활용해 만들어 진 ReactModal이라는 컴포넌트로 감싸 줬다.

이후, circle이라는 svg를 감춰두고, 그 감춰진 것에 마우스 hover 여부에 따라 툴팁이 show되는 지 여부를 state로 처리했다.

이에따라, 툴팁이 등장하게 만들어 줬다.

4) 정리해 보자면..

아래 그림처럼 line chart graph는 svg 형태로 이루어져 있었다.

그래서 그 안에 내부에 종속되어 있는 툴팁의 경우, 아무리 내가 발버둥 처도 동일한 선상의 위치에서 띄울 수가 없었던 것이다.

그래서 포탈로 감싸서, 내부에 종속하고 있지만, 마치 svg와 동일한 위치에 있는 것처럼 끌어올린 것이다.
아래는 크롬 인스펙터로 해당 구조를 파악해 본 것이다.

4. 끝으로.

이번 차트 라이브러리 선택부터 구현까지...

상당히 많은 인사이트를 얻은 경험이었다고 생각한다.

크게는 어떤 의사 결정에 대해 고려 요소를 무수히 많이 생각해야 한다는 점과,

작게는 결국 우리가 사용하는 모든 기술은 어떤 원리와 구조 속에서 동작하고 그에 대해 더 깊게 탐구해 봐야만,

별거 아닌 것 같은 문제도 근본적으로 해결할 수 있다는 깨달음을 얻었다.

오늘도 어제보다 나은 성장을 위해 그러면

다음 시간에도 뭔가 또 다른 아하! 모먼트를 가져오겠다.

긴 글 읽어주셔서 감사하다.

profile
개발도 예능처럼 재미지게~

0개의 댓글