[Javascript] 잡다 직원 수 그래프를 개선한 방법

배준형·2024년 1월 2일
1
post-thumbnail

서문

잡다 서비스에서는 기업 정보를 제공합니다. 그 중 직원 수, 입사자 수, 퇴사자 수를 그래프로 보여주는 섹션이 있는데, UI/UX 관점에서 불편하고 도움이 되지 않았다고 생각합니다. 그래프 영역이 예상한 것과 다르게 너무 작았고, 해당 연, 월에 몇 명의 직원이 존재하는지 알고 싶으면 hover가 아닌 점(Point) 요소를 클릭했어야 했습니다.

결과적으로 그래프는 click 대신 mouse event에 따라 직원 수를 보여주도록 수정되었고, 좀 더 부드럽게 보일 수 있도록 인터랙션이 추가되어 개선되었습니다. 완전히 개선되었다고 할 수는 없지만, 직전과 비교했을 때 그래도 조금은 보기 편해지고 자연스러워진 개선 방법에 대해 알아보겠습니다.


작업 전 - click에 따라 직원 수 보여주기

기존 작업되어 있었던 그래프는 상호작용으로 Click 했을 때 직원 수를 보여주는 것이 전부였습니다.

// LineChart.tsx
<button
  onClick={() => handleClick(index)}
>
  <div>{ToolTip 내용}</div>
</button>

만약 활성화되어 있는 월 외에 다른 달에는 직원 수가 몇 명인지 보기 위해선 그래프 위의 점(Point)을 클릭했어야 했습니다. 생각보다 집중해야 하고 보기에도 불편합니다.


1차 개선 - Mouse Enter Event 추가하기

첫 번째 개선으로 Mouse Enter Event를 추가했습니다.

기존 onClick props로 넣었던 함수를 onMouseEnter props에 똑같이 추가만 하면 되었기에 수정은 많지 않았고, 적어도 클릭한 것보단 간단하게 내용을 확인할 수 있었습니다.

// LineChart.tsx
<button
  onClick={() => handleClick(index)}
  onMouseEnter={() => handleClick(index)}
>
  <div>{ToolTip 내용}</div>
</button>

그래도 여전히.. 불편합니다. 위 캡쳐에서도 느껴지듯 빠르게 움직일 때는 제대로 바뀌지 않는 것을 볼 수 있습니다.


2차 개선 - 각 영역에 Mouse pointer hover시 Tooltip 보여주기

두 번째 개선으로 영역을 hover 했을 때도 해당 영역에 가까운 쪽으로 Tooltip을 보여주도록 수정했습니다.

기존 작업되어 있던 구조는 아래와 같았는데요.

하나의 div 태그가 큰 사각형 영역을 갖고, 해당 영역의 div 요소의 순번(index)이 활성화되었을 때 div 영역의 right side 쪽이 활성화되는 구조였습니다. 각각의 영역에 hover 시 동작하도록 하면서 더 가까운 쪽의 요소를 보여주고 싶었습니다.

const handleMouseMove = (e: React.MouseEvent<HTMLDivElement>, index: number) => {
  if (!(e.target instanceof HTMLDivElement)) {
    return;
  }
  const { clientX, target } = e;
  const rect = target.getBoundingClientRect();
  const mid = (rect.left + rect.right) / 2;
  const isMouseOverRightArea = mid < clientX; // mouse pointer의 X축 위치로 좌우측 어느 쪽에 더 가까운지 판단합니다.

  if (isMouseOverRightArea) {
    handleMouseEnter?.(index);
  } else {
    handleMouseEnter?.(index === 0 ? 0 : index - 1);
  }
};
  • type guard 개념을 사용하여 Div 요소에만 동작합니다.
  • getBoundingClientRect 메서드를 호출하여 요소의 위치와 크기에 대한 정보를 얻고, 이를 이용해 중간 지점을 계산합니다.
  • hover라는 이름을 쓰긴 했지만, javascript에 onHover라는 기능은 없기에 mouseMove event를 활용합니다.
  • throttle, requestAnimationFrame 등의 키워드로 더욱 최적화가 가능할 것입니다.

<div
  onMouseEnter={(e) => handleMouseMove(e, index)}
  onMouseMove={(e) => handleMouseMove(e, index)}
>
  <button
    onClick={() => handleClick(index)}
    onMouseEnter={() => handleClick(index)}
  >
    <div>{ToolTip 내용}</div>
  </button>
</div>

최초 그래프보다 훨씬 더 편리해진 것 같습니다. 적어도 점을 클릭하기 위해 집중할 필요가 없어졌고, 단순히 마우스를 hover 하듯 움직여 그래프를 확인할 수 있게 되었습니다.


3차 개선 - Scale을 연간 데이터 중 minimum, maximum 값에 따라 조절

1) Y축 범위 수정

눈썰미가 좋으신 분들은 이미 느끼셨겠지만, 여태까지의 그래프 Y 축 데이터 범위는 0명 ~ 5,000명까지이고, 실제 데이터는 1,100 명 수준에서 움직이기에 그 차이가 시각화로 표현되지 않습니다. 실제 데이터가 분포되어 있는 구간보다 Y 축의 범위가 훨씬 크기 때문인데요.

값이 작다면 적절한 Scale로 보여질 수도 있지만, 값이 커질수록 증감이 눈에 안 보이므로 Y 축 데이터의 범위가 0명에서부터 시작할 필요가 없고, 실제 값에 비해 너무 큰 값을 최대값으로 잡지 않아야 증감이 눈에 보일 것입니다.


기존 코드

// 연간 데이터 중 최대값을 기준으로 Y축 Gap을 설정
const getUnit = () => {
  const values = getValue().map((d) => d ?? 0);
  const max = Math.max(...values);
  const exponent = 10 ** (max.toString().length - 1);
  return Math.ceil((Math.ceil(max / exponent) * exponent + 3 * exponent) / 5);
};

// 설정된 데이터 Gap을 바탕으로 Y축 Name 구성
const getColumns = () => {
  return new Array(6).fill(null).map((_, i) => {
    const convertNum = (i * getUnit()).toLocaleString();
    return `${convertNum}`;
  });
};

기존 코드에서는 getUnit 함수를 호출하여 Y 축 Gap을 지정하고, getColumns 함수를 호출하여 Y 축을 구성하고 있습니다. 여기서 몇 가지 문제점이 보입니다.

  • getUnit 함수에서 exponent 값이 의미하는 바는 알겠지만, 반환되는 값은 어떻게 도출되었는지 유추하기가 어렵습니다.
  • getUnit 함수의 Unit 계산은 10의 제곱근으로 계산하므로 max 값이 커질수록 불필요하게 반환값도 커집니다.
    • 예를 들어 max값이 120,000 명이라면 exponent: 100,000, Y 축 최댓값: 500,000 명이 되어 버립니다.
  • getColumns 함수 내에서 getUnit 함수는 여러 번 호출될 필요는 없습니다. map 함수 바깥으로 이동한 후 1회만 호출하여 이미 계산된 Unit 값을 활용하는 것이 좋아 보입니다.
  • getColumns 함수는 index를 이용해 각각의 값을 계산하다 보니 index 값이 0인 경우는 항상 존재하므로 항상 0명이 최솟값이 되어 버립니다. 만약 삼성전자와 같이 직원 수가 10만 명이 넘는다면 그래프는 단순한 일자 그래프가 될 것입니다.

개선 후

// 연간 데이터에서 최소, 최대값을 구한 후 10%의 여유 값을 갖는 min, max 값을 반환
// 단, 각각의 자릿수에 따라 최대 자리수의 아래는 올림, 내림 처리하여 반환
const getAxisMinMax = () => {
  const values = getValue().filter((d) => d !== null)
  const valuesWithoutNull = values.length === 0 ? [0] : values;
  const max = Math.max(...valuesWithoutNull);
  const min = Math.min(...valuesWithoutNull);
  const range = max - min;
  const pad = Math.round(range * 0.1); // 10% 여유 공간
  const axisMin = round(Math.max(min - pad, 0), 'floor');
  const axisMax = round(max + pad, 'ceil');
  return { axisMin, axisMax };
};

// 10%의 여유 값을 갖는 min, max 값을 5로 나누어 활용합니다.
const getUnit = () => {
  const { axisMin, axisMax } = getAxisMinMax();
  return Math.ceil((axisMax - axisMin) / 5);
};

// 계산된 Unit을 바탕으로 Y축 데이터를 구성합니다.
const getColumns = () => {
  const { axisMin } = getAxisMinMax();
  const unit = getUnit();
  return new Array(6).fill(null).map((_, i) => {
    const convertNum = (axisMin + i * unit).toLocaleString();
    return `${convertNum}`;
  });
};
  • getAxisMinMax 함수를 추가하여 데이터의 min, max 값에서 10% 여유 값을 갖는 min, max 값을 반환합니다.
  • getUnit 함수는 getAxisMinMax 함수의 반환값을 그대로 사용하여 5등분 합니다.
  • getColumns 함수에서 getUnit 함수가 map 순회 돌면서 여러 번 호출되는 점, 항상 최솟값이 0명이 되는 점 등이 개선되었습니다.

10의 제곱근을 사용하여 exponent 값을 계산 후 Gap을 반환하는 것보다 어떤 관점에서 값이 도출되었는지 더 쉽게 파악할 수 있을 것 같습니다.


결과

0명 ~ 5,000명이었던 Y 축 범위는 1,000명 ~ 1,200명으로 변경되었습니다. 약 1,100명 수준에서 변경사항이 있었다면 증감은 더 눈에 들어올 것입니다.

다만, 해당 그래프의 X, Y 축과는 별개로 내부에서 사용되는 그래프는 svg 태그로 직접 만든 컴포넌트를 사용 중이었습니다. 그래서 Y 축이 변경된 만큼 그래프도 위로 튀어 버렸는데요. 이에 따라 svg 태그도 수정된 Y 축 데이터 범위에 맞게 수정되어야 합니다.


2) 그래프 Y축 Scale 적용하기

기존 코드에선 svg 태그에 path 태그를 이용하여 Line을 그렸습니다. 각각의 Point는 개선 전의 getUnit 함수의 Unit을 활용하여 직접 계산을 했었는데요.

const getLineData = (data: ChartData[]) => {
  if (!data.length) return '';
  return data.reduce(
    (prev, { column }, i) => (column === null ? prev : `${prev} ${getPoint(i, column)} ${getChar(i)}`),
    'M',
  );
};
<path d={lineData} stroke={stroke} />

Path 태그의 d props로 해당 값을 넘겨주어 차트를 그려주고 있었습니다. 기존 Y 축 범위는 항상 0부터 시작했으므로 관련 계산이 포함되어 있었을 것인데요. 이를 개선하기 위해선 척도(Scale)의 개념을 알아야 합니다.


데이터의 min, max 값이 있다면 SVG 태그의 크기가 800 x 600일 때 데이터의 min, max 값을 0~600(Y축)까지의 값으로 mapping하는 과정이 필요합니다. 직전 회사에서 자체적으로 진행했던 사내 세미나에서 D3에 대한 세션을 발표했었는데, 그때 배웠던 D3 scale 개념을 그대로 적용시켰습니다.

https://d3js.org/d3-scale


/**
 * 보여줄 그래프의 최소, 최대값과 데이터의 최소, 최대값을 받아서 실제 그래프에 표시될 값을 계산하는 함수를 반환
 * @param viewMin 그래프 최소값
 * @param viewMax 그래프 최대값
 * @param dataMin 데이터 최소값
 * @param dataMax 데이터 최대값
 * @returns
 */
const createScaler = (viewMin: number, viewMax: number, dataMin: number, dataMax: number) => {
  // 실제 scale logic이 들어가는 내부 함수
  return (value: number) => {
    // 비율 계산
    const ratio = (value - dataMin) / (dataMax - dataMin);

    // viewMin ~ viewMax 사이 값 return
    return viewMin + ratio * (viewMax - viewMin);
  };
};
  • 매개변수가 4개나 되어서 객체로 하나의 매개 변수만 넘기는 것이 헷갈림을 방지할 수 있겠지만 여기선 순서대로 넘기는 것으로 하겠습니다.

해당 함수는 value를 매개변수로 하는 함수를 반환하는 고차함수인데요. 실제 보여주려는 SVG 태그의 크기만큼 활용하기 위해 사용됩니다.


// value 값을 매개변수로 하는 scale 함수를 반환합니다.
const scaleY = createScaler(0, height, axisMin, axisMax);

// Y 좌표를 구할 때 척도를 활용하여 값을 계산합니다.
const getY = (value: number) => {
  return height - scaleY(value);
};

// X좌표는 그대로 활용하고, Y 좌표는 척도를 이용한 함수를 통해 계산합니다.
const getPoint = (x: number | null, y: number | null) => `${getX(x)} ${getY(y)}`;

결과

Y 축 범위에 맞게 Line이 적절히 수정되었습니다. 이제 직원 수가 많은 기업 정보라 하더라도 그 증감을 알 수 있게 되었습니다.


4차 개선 - 부드러운 전환

2차 개선에서 영역을 hover 했을 때 가까운 쪽의 달을 활성화하기 위해 mouse move event를 활용했습니다. 그러나 여전히 뚝 뚝 끊기는 것 같은 움직임이 있었는데, 개인적으로 무언가 미완성인 것 같은 느낌을 줍니다.

이를 해결하기 위해 세로로 긴 직선을 하나 추가하여 animation을 추가했습니다. index가 활성화되었을 때 해당 index 만큼 위치를 조정하면 되었는데요. 구현 내용은 아래와 같습니다.

// 세로선의 X축 좌표
const [offsetX, setOffsetX] = useState(0);

useUpdateEffect(() => {
  // onIndex 값이 바뀔 때마다 세로선 움직임을 위한 offsetX 값 변경
  if (onIndex === null || !wrapperRef.current) return;
  const index = onIndex === 0 ? 1 : onIndex + 1;
  const target = wrapperRef.current.childNodes[index] as HTMLDivElement | null;
  const targetRect = target?.getBoundingClientRect() ?? { left: 0 };
  const wrapperRect = wrapperRef.current.getBoundingClientRect();
  const diff = targetRect.left - wrapperRect.left - 1;
  setOffsetX(diff);
}, [onIndex]);
// 해당 영역을 스타일링하여 세로 선으로 보이도록 만들어 준 후에 사용합니다.
<div style={{ transform: `translateX(${offsetX}px)` }} />
  • 여기선 style props로 넘겨 주었지만, class를 활용할 수도 있습니다.

결과 (최종 모습)

이제 세로 선이 따라다니면서 현재 위치를 알려줍니다. 부드럽게 전환되기에 활성화된 index가 변화하더라도 자연스럽게 흐름을 따라갈 수 있을 것 같습니다.

  • GIF이기에 움직임이 느려 보이지만 실제로는 빠릅니다..!

정리

잡다 기업 정보 중 직원 수 그래프를 개선했던 방법에 대해 알아보았습니다. 사실 코드 자체가 그리 어렵지는 않아서 코드를 작성한 시간보다 아이디어를 구상하고 설계하는 것에 더 많은 시간을 쓴 것 같습니다.

이번 개선 작업으로 느낀 건, 지금까지의 개발 과정 중 쓸모없는 것은 없었다. 라는 생각이 듭니다. 직전 회사에서 D3 세션을 발표하고 난 이후 D3를 사용할 일은 없었고, 약 1년 가까운 시간이 지났기에 지금 다시 사용한다고 하면 공식 문서를 처음부터 봐야 할 정도입니다. 그런데, D3를 공부하면서 배운 SVG 태그, 척도, 그래프의 이해 등에 대한 내용은 아직 조금이나마 남아있었고, 이에 대한 이해가 있었기 때문에 적절히 그래프를 개선할 수 있었다고 생각합니다.

profile
프론트엔드 개발자 배준형입니다.

1개의 댓글

comment-user-thumbnail
2024년 1월 18일

직원 수 그래프 개선 글 잘 읽었습니다!

답글 달기