스프레드시트를 최적화 하자

Table of Contents

성능을 언젠가는 고려해야 하지 않을까?

일단 성능은 따로 고려하지 말고 일단 만드세요. 그러고 나서 문제가 생기면 그 때 고민하면 됩니다.

웹 개발 공부를 하면서 가장 많이 들었던 말이다. 실제로 요즘은 유저 기기 성능이 많이 올라왔고, 어지간한 부분은 패키지나 브라우저가 알아서 해 주기 때문에 개발자는 당장 성능에 대한 고려를 할 필요는 없어졌다.
그런데 회사에서 개발을 하던 중 진짜로 성능 문제를 해결해야 하는 시간이 찾아왔다. 꽤 오래된 일인데, 회고 글을 몇 번 씩 썼다 지웠다 하다가 이제라도 정리해 본다.

레어데이터 소개

너무 즐거운 경험과 인사이트를 줬던 내 첫 프로젝트

내가 참여하던 프로젝트는 일종의 스프레드시트와 비슷한 솔루션이다. 데이터베이스 스키마를 정의하듯 열에 타입을 정의하고, 각각의 셀은 해당 타입에 맞는 입력 UI를 팝오버로 보여주는 식이다. Tanstack TableTanstack Virtual을 사용하여 레이아웃 구성 및 가상화를 적용하였고, 각 셀은 타입별 입력 UI 컴포넌트를 prop으로 지정된 타입에 따라서 조건부로 렌더링해 주도록 되어 있었다.

행과 열에 모두 가상화가 적용되어 있으니 괜찮으리라 생각했는데, 데이터의 규모가 커지면 입력은 커녕 탐색도 어려워지는 문제가 있었다. 일단 그 임계점이 지금 사용자들의 상황과는 멀었기 때문에 신경쓰지 않고 있었으나 제품이 점점 확장되면서 더 이상은 성능 문제를 외면할 수 없게 되었다.

원인을 파악해 보자

성능 문제의 원인을 파악하기 위해 React Developer Tools와 구글 크롬 개발자 도구를 사용했다.

React Devtool

리액트 개발자 도구의 렌더링 하이라이트

체크하면 렌더링되는 컴포넌트가 빛난다.

리액트 개발자 도구의 옵션에는 리렌더링되는 컴포넌트의 테두리가 반짝이게 하는 옵션이 있다. 이 옵션을 켜고 성능 문제가 있는 시트를 탐색해 보았고, Tanstack이 제공하는 예시와 비교해서 리렌더링에 어떤 차이가 있는지 살펴보았다. 우선 Tanstack 예시는 스크롤 될 때 마다 스크롤 영역의 Container 부분만이 리렌더링되는 반면, 우리 프로젝트는 매 스크롤마다 모든 셀이 각각 리렌더링되었다.

Tanstack Virtual 예시

크롬 개발자 도구

크롬 개발자 도구 프로파일링

또 개발자 도구의 Performance 탭에서 성능 프로파일링도 진행하였다. 자바스크립트의 GC(가비지 컬렉팅)는 제어가 불가능하고 불규칙하게 이루어지기 때문에 아주 정확한 지표는 아니지만, 노드 개수, 메모리 크기 등의 추세를 확인하고 병목 지점을 확인하는 데에는 도움이 된다.
이 보고서에서 내가 얻은 인사이트는 노드 개수와 이벤트 리스너 수가 지나치게 많다는 점이었다. 메모리 크기도 매우 높았지만 당장 개발 모드에서는 React와 Emotion이 실제 배포 환경과 다르게 동작해서 더 심각하게 나타나는 것으로, 당장은 신경쓰지 않기로 했다.

문제를 해결해 보자

이벤트 리스너 줄이기

내가 가장 눈여겨 본 부분은 이벤트 리스너였다. 당시 각 셀은 유저가 포커스를 주면 자기 타입에 맞는 UI를 보여주도록 되어 있었다. 즉, focused 상태에 해야 할 일을 셀이 가지고 있었다. 따라서 유저는 한번에 하나의 셀만을 포커스할 수 있음에도 화면에 보이는 모든 셀은 자기가 포커스 되었을 때 해야 할 일을 기다리고 있던 셈이다.

일반 스프레드 시트라면 여러 셀을 포커스할 수 있어야 하지만, 우리 프로덕트는 도메인 특성상 그런 기능이 필요할 일이 없었다. 우리는 이 부분에 착안해서 모든 셀이 값만을 표시하도록 변경하고 유저의 동작을 처리하는 커서 컴포넌트를 하나만 두어 이벤트 수를 크게 줄일 수 있었다.

노드 개수 줄이기

위의 조치로 노드 개수가 줄어들긴 했지만 스프레드시트는 지속적인 기능 추가와 개선으로 불필요한 div가 지나치게 많았다. 셀 하나 당 7~8개의 div요소가 중첩되어 있었는데, 셀 자체를 새롭게 만들면서 이 부분도 신경써서 모두 제거했다.

추가로 CSS-in-JS인 Emotion도 제거하였다. 노드 수가 줄어들었어도 테이블 특성상 매우 많은 HTML 요소를 렌더링해야 하는데, CSS-in-JS를 사용한다면 그 각각의 요소에 대해 자바스크립트를 통한 스타일링이 이루어져야 한다. EmotionBest Practice 문서에서도 렌더링해야 하는 styled component 수가 많은 경우, 모든 HTML 요소를 각각의 styled component로 정의하기보다 css selector를 사용하여 통합할 것을 권장하고 있다. 그래서 우리는 테이블 내의 모든 요소를 최상위 table 요소에 몰아 넣고, 자식 요소들은 모두 native HTML element를 사용해서 구성하였다.

const Table = styled('table')({
  // ...
  ['& > thead']: {
    // ...
  }
  ['& > tbody']: {
    // ...
  }
})

const Component = () => {
  return (
    <Table>
      <thead>
        <tr>
          <th>항목</th>
          {/* ... */}
        </tr>
      </thead>
      <tbody>
        <tr> {/* ... */} </tr>
      </tbody>
    </Table>
  )
}

처음에는 마크업이 직관성 없게 보일까봐 걱정을 많이 했는데, 테이블 내의 가장 복잡한 로직인 사용자와 상호작용하는 부분이 커서 레이어로 분리되었기 때문에 망설임 없이 스타일을 제거할 수 있었다. 이 조치로 인해 이모션이 수 많은 요소들의 스타일을 CSS string으로 변환하는 과정이 생략되어 메모리 크기도 함께 줄어든 것을 확인하였다.

진짜로 좋아졌나요?

리팩토링 후 크롬 개발자 도구 프로파일링

작업 후 다시 프로파일링을 진행했다. 대충 봐도 꽤 많이 줄어들었고, 내부 테스트에서도 반응이 좋았다. 우선 입력이 아예 불가능한 수준인 시트에서도 자유롭게 탐색과 입력이 가능했다. 하지만 얼마나 개선되었는지 정확히 측정할 수는 없을까?

Profiler API의 BaseDuration 비교

어떻게 하면 더 이쁘게 찍을 수 있었을까..

리액트의 Profiler API를 사용하여 성능 측정을 진행하였다. 여러 성능 지표를 알 수 있는데, actualDurationbaseDuration중 메모이제이션을 포함한 렌더링 시간이 전자, 제외한 시간이 후자이다. 우리는 새로 작업을 하면서 메모이제이션을 최대한 제거하였기 때문에 BaseDuration을 콘솔에 찍어 확인했다. 사진의 왼쪽이 비포, 오른쪽이 애프터이다. 비교해 보니 리팩토링 후 렌더링 시간이 약 78%정도 줄어들었음을 확인할 수 있었다. 뻥 좀 보태서 5배 빨라졌다고 말할 수 있지 않을까!?

마치며

앞서 말했듯 나는 계속해서 성능보단 개발 경험과 코드 퀄리티에만 집중했기 때문에, 이렇게 빨리 성능 문제를 만날 줄은 생각도 못했다. 아무튼 신입 개발자의 마인드(당시 4달차)로 새로운 것을 마구 시도하고 싶은 마음에 리서치부터 개선 방안까지 열심히 준비해서 팀원들에게 공유했고, 팀원들도 너무 긍정적으로 함께 해 주어서 너무 귀중한 경험을 할 수 있었다.

Refs.