직접 엑셀을 구현하며 경험한 React 최적화 기법들
대규모의 데이터를 처리할 수 있는 리액트 엑셀 컴포넌트를 개발하며 경험한 최적화 기법들을 소개합니다.
얼마 전 저는 엑셀과 유사한 스프레드시트 기능을 구현해야 했습니다. 단순히 데이터를 표시하는 것에서 그치지 않고 수천개의 셀을 실시간으로 편집할 수 있어야 했습니다. 시작은 간단했지만, 곧 React의 성능 한계와 마주하게 되었는데요. 이 글에서는 제가 경험한 문제점과 다양한 최적화 전략을 공유하고자 합니다.
저희 팀이 구현해야 했던 것은 구글 스프레드시트와 비슷한 엑셀 형태의 기능이었습니다. 가로축은 시간, 세로축은 분류 체계를 나타내는 고정된 구조였고, 각 셀은 날짜와 분류 코드와 함께 문자열, 숫자, 불리언 값을 가질 수 있었습니다. 겉보기에는 단순해 보였지만, 구현 과정에서는 생각보다 많은 기술적 난관이 있었습니다.
📌 요구사항과 직면한 문제
가장 큰 문제는 성능이었습니다. 서비스 특성상 수천 개, 구체적으로는 약 5,000개 정도의 셀을 한 화면에 표시해도 자연스러워야 했는데, 단순히 DOM 요소를 셀 단위로 모두 렌더링하면 성능이 급격히 떨어졌습니다. 데이터가 변경될 때마다 모든 셀이 리렌더링되었고, 각 셀에 이벤트 리스너까지 붙어 있어 상호작용 성능도 나빴습니다. 특히 모든 셀이 “입력 가능한 상태” 여야 했기 때문에 input 요소나 focus 제어와 같은 DOM 상호작용 로직이 셀마다 중복되어 존재했습니다. 그 결과 화면 반응 속도와 타이핑 경험 모두 부자연스러웠습니다.
Konva와 Canvas 도입
이 문제를 해결하기 위해, 처음에는 DOM 요소를 최적화하는 방향을 고민했지만, 수천 개의 셀을 개별 DOM으로 관리하는 것은 한계가 명확했습니다. 결국 브라우저가 DOM 트리를 계산하는 과정을 최소화하기 위해 GPU 가속이 가능한 Canvas 기반 렌더링을 도입하기로 했습니다. Canvas API는 성능적으로 매력적이지만 개발 경험(DX)이 좋지 않습니다. 그래서 중간 계층으로 Konva를 선택했습니다. Konva는 HTML Canvas API를 훨씬 편하게 사용할 수 있도록 추상화된 라이브러리이고, react-konva를 사용하면 React 컴포넌트처럼 Canvas 요소를 다룰 수 있습니다.
🎨 렌더링 성능 최적화 전략
Canvas로 전환했다고 해서 모든 성능 문제가 자동으로 해결되는 것은 아닙니다. Canvas를 쓰러라도 셀 하나하나를 모두 그린다면 여전히 무겁습니다.
그래서 화면에 보이는 셀만 그리는 방식으로 최적화했습니다. 컨테이너의 크기와 스크롤 위치 등을 감지하여 이에 따라 화면을 그리는 것입니다. 이렇게 하면 수천 개의 셀을 렌더링할 필요 없이 화면에 보이는 것만 렌더링하면 됩니다.
더 나아가, 아예 셀 자체를 그리지 않고 셀을 구분하는 선만 그리는 방식을 적용했습니다. 예를 들어 5x5 그리드를 셀 단위로 그리면 25번의 그리기 연산이 필요하지만, 선만 그리면 가로줄 4개, 세로줄 4개, 총 8번만 그리면 됩니다. 즉, 그리드가 커질수록 연산량은 곱연산이 아니라 합연산처럼 증가해, 그릴 셀이 많아질수록 효율이 더 높아졌습니다.
문제는 입력이었습니다. 기존에는 셀마다 독립된 input 요소가 있었지만, Canvas에서는 이런 구조가 불가능합니다. 이를 해결하기 위해 화면 전체에 단 하나의 input 요소만 두고, 클릭한 셀 위에 해당 input을 위치시키는 방식을 사용했습니다. 이 구조를 위해 Canvas 레이어를 4단계로 나눴습니다.
- CellSelectionLayer — 선택된 셀 위에 input 요소를 렌더링하고, 해당 input에서 발생한 값을 데이터에 반영합니다.
- InteractionLayer — 클릭 이벤트를 처리하고, 클릭 위치로부터 어떤 셀이 선택되었는지 계산합니다. 선택 정보는 전역 store에 저장합니다.
- DisplayLayer — 셀의 값을 그립니다.
- BackgroundLayer — 셀 경계선과 배경을 그립니다.
레이어를 나눈 이유는 단순합니다. 데이터 변화를 구독하는 영역을 세분화하여, 변화가 발생한 부분만 리렌더링하기 위함입니다. 예를 들어 값이 변하더라도 BackgroundLayer는 변하지 않으니, 해당 레이어는 다시 그릴 필요가 없습니다.
왜 canvas여야 하나
이러한 최적화는 이론상 DOM 기반에서도 구현이 가능합니다. 하지만 Canvas를 선택한 이유는 DOM과 Canvas의 구조적 차이에 있습니다.
DOM은 HTML과 CSS를 기반으로 하며, 브라우저는 항상 DOM Tree
와 CSSOM
을 유지합니다.
위치, 크기, 스타일이 변하면 브라우저는 Reflow
(레이아웃 계산)와 Repaint
(화면 갱신)를 거칩니다. 이 과정은 특히 요소가 많을 때 연산량이 기하급수적으로 증가합니다.
반면 Canvas는 비트맵 버퍼에 직접 픽셀을 그립니다. 브라우저는 Canvas 내부의 픽셀 데이터를 구조적으로 기억하지 않으므로 DOM 트리나 레이아웃 계산이 필요 없습니다. 게다가 CPU 자원을 많이 쓰는 DOM에 비해 GPU 가속이 가능해 대량의 객체를 처리하는 데에 보다 이점이 있다고 판단했습니다.
👆 인터랙션 성능 개선
렌더링 최적화 이후에도 여전히 셀 편집 과정에서 성능 문제가 남아 있었습니다. 원인은 크게 두 가지였습니다.
첫째, 너무 많은 이벤트 리스너가 존재했습니다.
둘째, 데이터 변경 시 관련 컴포넌트가 전부 리렌더링되는 React 특성 때문이었습니다.
1. 이벤트 리스너 줄이기
react-konva
를 사용하면 Canvas 내 요소를 React 컴포넌트로 제어할 수 있습니다. 하지만 Konva는 각 요소마다 이벤트 리스너를 등록하는데, 이는 DOM 트리에서 발생하는 성능 저하와 비슷한 문제를 만듭니다. 이를 해결하기 위해 Konva의 listening=false
옵션을 사용해 개별 리스너를 제거하고, 전체 Canvas에 단 하나의 리스너만 두어 직접 클릭 좌표를 계산하는 방식으로 변경했습니다. 이렇게 하면 불필요한 리스너 생성과 메모리 사용을 크게 줄일 수 있었습니다.
2. 불필요한 리렌더링 막기
React는 상태가 변하면 관련 컴포넌트를 전부 리렌더링합니다. memo를 사용해도 의존성 관리가 복잡하고, 수정 시 누수가 발생하기 쉽습니다.
이 문제를 해결하기 위해 상태 관리 라이브러리인 Zustand의 useShallow
기능을 사용했습니다.
이 훅은 스토어 내에서 제가 실제로 구독하고자 하는 '값'만 얕은 비교를 통해 감지하고, 그 값이 변경되었을 때만 컴포넌트를 리렌더링합니다.
예를 들어, 아래와 같은 코드를 사용했습니다:
const isSelected = useShallow((state) => !!state.selection);
이렇게 하면 selection
객체 내부의 일부 값이 변경되더라도, 객체의 존재 여부만 검사하기 때문에 불필요한 리렌더링을 방지할 수 있습니다.
크롬 익스텐션인 React Developer Tools를 사용하면 리렌더링되는 컴포넌트를 확인하면서 정밀하게 최적화 작업을 해볼 수 있습니다.
🧮 데이터 연산 줄이기
렌더링과 인터랙션 성능을 개선한 뒤에도 데이터 초기화와 변경 과정에서 병목이 있었습니다.
원인은 수천 개의 객체를 담은 배열을 전부 순회해야 하는 구조였습니다. 예를 들어 { x, y, value }[]
형태의 배열에서 특정 셀을 찾으려면 find로 전체를 순회해야 합니다.
수천 개의 셀에서 이런 연산을 반복하면 성능이 급격히 나빠집니다.
이를 해결하기 위해 데이터를 Map
으로 저장했습니다. Map은 키-값 구조로, 순회와 조회가 빠르고 순서를 보장합니다.
그리고 데이터 변이 시 .set()
메서드로 참조를 유지한 채 값만 변경하도록 했습니다.
이렇게 참조를 유지하면서 변수 재할당을 피할 경우 React 컴포넌트가 변경을 감지하지 못할 수 있는데, Zustand의 useShallow
를 사용하면 리렌더링 시점을 직접 제어할 수 있기 때문에 상관없습니다.
복합 키 조건이 필요한 경우에는 미리 키를 가공해 여러 개의 Map을 유지했습니다. 예를 들어:
data
:x
+y
를 키로 설정해 좌표 단일 조회dataByX
:x
축 단위 데이터 조회dataByY
:y
축 단위 데이터 조회
이렇게 하면 x축 합계 같은 연산을 빠르게 계산할 수 있었습니다.
낙관적 업데이트로 사용자 경험 개선하기
최적화 작업은 성능 개선에서만 그치지 않습니다. 사용자에게 끊김없는 경험을 제공하기 위한 기술적 선택도 최적화의 일환입니다.
셀 수정 시 서버 응답을 기다리면 0.5초만 늦어져도 체감 속도가 떨어지기 때문에, 입력 즉시 화면에 반영하고 이후 서버 응답에 맞춰 상태를 조정하는 낙관적 업데이트(Optimistic Update) 를 적용했습니다. 이렇게 하면 사용자는 값이 바로 적용된다고 느끼고, 변경된 셀만 서버에 전송해 트래픽도 줄일 수 있습니다.
마무리하며
- 이외에도 세부적으로 다양한 최적화 작업을 통해 유사 스프레드시트를 만들 수 있었다.
- 최적화를 하면서 다시금 역시 React의 성능은 구리다(…)는 점을 깨달았다.
- 최적화는 까다롭지만 퀘스트 하듯 하나씩 해결해 나가는 재미가 있다. 님들도 해보세요.