Intro
최근 엔터프라이즈급 웹 서비스를 개발하면서 가장 많이 들었던 요구사항은 이것이었다. "기존에 쓰던 엑셀 파일, 워드 문서 양식을 웹에서 그대로 쓰고 싶어요. 기능은 똑같이, 속도는 더 빠르게."
B2B 어드민이나 데이터 플랫폼을 개발하다 보면 단순히 데이터를 조회하는 것을 넘어, 웹상에서 엑셀 시트를 직접 편집(SpreadJS)하거나 문서를 공동 작성(OnlyOffice)해야 하는 고난도 요구사항과 마주하게 된다.
DOM 엘리먼트가 수천 개씩 쏟아지는 무거운 라이브러리들을 React 환경에 녹여내면서 겪었던 렌더링 성능 이슈와 메모리 누수(Memory Leak)를 해결한 과정을 기록해본다.
1. SpreadJS: 렌더링 제어권 가져오기 (suspendPaint)
엑셀의 기능을 웹으로 옮겨놓은 SpreadJS는 기능이 강력한 만큼 무겁다. 특히 우측 설정 패널(Binding Path Panel)을 열고 닫을 때마다 캔버스 전체가 다시 그려지면서 화면이 버벅이는 현상이 발생했다.
⛔️ 문제 상황
데이터 바인딩을 변경하거나 시트 설정을 바꿀 때마다 SpreadJS는 즉시 화면을 갱신(Repaint)하려고 한다. 수천 개의 셀이 있는 상태에서 패널을 조작할 때마다 리렌더링이 일어나니 UX가 뚝뚝 끊길 수밖에 없었다.
✅ 해결: 그리기, 잠시만 멈춰!
브라우저의 리플로우(Reflow)를 줄이는 원리와 비슷하게, SpreadJS도 "작업 끝날 때까지 그리지 마"라고 명령할 수 있다.
- workbook.suspendPaint(): 렌더링 일시 중지. (변경 사항을 메모리에만 반영)
- workbook.resumePaint(): 렌더링 재개. (변경된 내용을 한 번에 그리기)
이 패턴을 적용하여 데이터 세팅 로직을 감싸주니, 패널 조작 시 발생하던 딜레이가 사라졌다. 대량의 DOM 조작이나 Canvas 드로잉 작업에서는 '배치(Batch) 처리'가 성능의 핵심임을 다시 한번 깨달았다.
/* SpreadJS 성능 최적화: 커맨드 실행 제어 */
// 바인딩 패널 데이터 주입 및 실행
const updateBindingLayout = (bindingPath, isPanelClosed) => {
// 1. 데이터 세팅
designer.setData(TREE_NODE_FROM_JSON, JSON.stringify(bindingPath));
// 2. 커맨드 실행 함수 가져오기 (방어 코드)
const executeCommand = getExecuteFnc();
// 3. 패널이 닫혀있지 않을 때만 실행하여 불필요한 연산 방지
if (!isPanelClosed) {
executeCommand(designer);
}
}
const getExecuteFnc = () => {
// 템플릿 디자인 모드 커맨드 객체 조회
const command = GC_Designer.Spread.Sheets.Designer.getCommand(TEMPLATE_DESIGN_MODE);
// 커맨드가 없거나 실행 함수가 없으면 빈 함수 반환 (Safe Navigation)
if (!command || !command.execute) return () => {};
return command.execute;
}
// Tip: 대량 데이터 로드 시 패턴
const loadHeavyData = (workbook) => {
workbook.suspendPaint(); // 🎨 렌더링 중지 (핵심!)
try {
workbook.clearSheets();
// ... 수만 건의 데이터 바인딩 로직 ...
} finally {
workbook.resumePaint(); // 🎨 한 번에 그리기
}
}
2. Data Handling: 복잡한 객체 다루기
그리드(ag-grid)나 시트(SpreadJS)에 데이터를 바인딩하려면, 백엔드에서 받은 JSON 데이터를 라이브러리가 좋아하는 형태로 가공해야 한다. 엔터프라이즈 데이터는 깊이(depth)가 깊거나 구조가 복잡한 경우가 많다.
단순 for문 대신, ES6+ 메서드를 적극 활용해 데이터 무결성을 체크하고 구조를 변환했다.
- Object.entries & reduce: 객체를 순회하며 특정 조건에 맞는 데이터만 필터링하거나 새로운 포맷으로 집계할 때 사용.
- some: 대량의 데이터 중 'Error' 상태나 '필수 값 누락'이 하나라도 있는지 빠르게 검증할 때 사용 (조건 만족 시 즉시 종료되므로 성능상 이점).
// 예: 복잡한 응답 데이터에서 특정 플래그(flag)가 true인 항목만 추출하여 그리드 Row로 변환
const gridRows = Object.entries(rawData).reduce((acc, [key, value]) => {
if (value.isValid) {
acc.push({ id: key, ...value.content });
}
return acc;
}, []);
// 예: 데이터 유효성 검사 (하나라도 빈 값이 있으면 저장 불가)
const isInvalid = Object.values(formData).some(val => val === null || val === '');
3. OnlyOffice: 외부 라이브러리와 React 생명주기
웹에서 워드 문서를 편집하는 OnlyOffice를 React 컴포넌트로 래핑(Wrapping)해서 사용하는 과정에서 문제가 생겼다. 페이지를 이동했다가 돌아오면 에디터가 중복 생성되거나, 다운로드 이벤트가 중첩되어 실행되는 현상이었다.
React의 Virtual DOM 바깥에서 동작하는 외부 라이브러리(3rd-party)는 React가 알아서 치워주지 않는다. 개발자가 직접 청소(Cleanup)해야 한다.
✅ 해결: useEffect의 Cleanup 함수 활용
에디터 인스턴스를 ref에 담아 관리하고, 컴포넌트가 언마운트(Unmount)될 때 반드시 destroyEditor()를 호출하여 메모리를 해제했다.
import { useRef, useEffect } from 'react';
const DocumentEditorContainer = ({ id, url, config, userToken }) => {
// 에디터 인스턴스를 저장할 ref
const docEditorRef = useRef(null);
// 에디터 로딩 완료 핸들러
const onAppReady = () => {
// 전역 객체(window.DocEditor)에서 현재 인스턴스를 찾아 연결
// Optional Chaining(?.)으로 안전하게 접근
docEditorRef.current = window.DocEditor?.instances?.[id];
};
const onDownloadAs = () => {
// ref를 통해 에디터 API 직접 호출
docEditorRef.current?.downloadAs('docx');
};
useEffect(() => {
// Mount 시점: OnlyOffice 로드 (Component 내부 로직에 의해 자동 실행됨)
// Unmount 시점: Cleanup (중요!)
return () => {
if (docEditorRef.current) {
docEditorRef.current.destroyEditor(); // 인스턴스 파괴
docEditorRef.current = null; // 참조 해제
}
};
}, []);
return (
<DocumentEditor
id={id}
documentServerUrl={url}
config={{
...config,
events: {
onAppReady,
onDownloadAs,
}
}}
/>
);
};
Outro
이번 개발 경험을 통해 "라이브러리를 가져다 쓰는 것"과 "라이브러리를 제어하는 것"의 차이를 확실히 느꼈다.
- 성능 최적화: 무거운 라이브러리일수록 렌더링 사이클(suspendPaint)을 직접 제어해야 사용자 경험(UX)을 지킬 수 있다.
- 데이터 핸들링: 복잡한 비즈니스 로직을 UI에 녹여내기 위해서는 자유자재로 객체와 배열을 다루는 JS 기본기가 필수다.
- 메모리 관리: React 생명주기에 맞춰 외부 인스턴스를 생성하고 파괴하는 습관이 안정적인 애플리케이션을 만든다.
다음 포스팅에서는 ag-grid의 Row Virtualization 원리를 분석해보고, 대용량 데이터 스크롤 성능을 극대화하는 방법에 대해 정리해봐야겠다. 끝!
'Frontend' 카테고리의 다른 글
| 대용량 데이터 렌더링: ag-grid의 Row Virtualization 동작 원리 분석 (0) | 2026.01.13 |
|---|---|
| 분산 문서 시스템 통합 과정에서의 대용량 파일 처리 및 전송 최적화 (0) | 2025.11.24 |
| React Portal과 Window.open으로 PIP 플레이어 구현하기 (1) | 2025.11.19 |
| EventSource vs EventSourcePolyfill: SSE로 프로그래스바 구현하기 (1) | 2025.05.14 |
| 서버와 클라이언트 간 지속적 통신 방법(Polling, Long Polling, WebSocket, SSE) 비교 (1) | 2025.01.08 |