제목, 태그, 카테고리로 검색

모든 글
약 13분 분량 프로젝트/인디밸런싱

테이블 드래그 성능 최적화: O(N)에서 O(1)로

목차

1. 문제 상황 인식

1.1 정상 상태 정의

  • 스프레드시트 형태의 테이블에서 마우스 드래그로 셀 범위를 선택할 때, 사용자의 마우스 움직임에 즉각적으로 반응하여 선택 영역이 부드럽게 확장되어야 해요
  • 드래그 중 프레임 드랍 없이 60fps 유지
  • 100행 x 10열 규모의 테이블에서도 지연 없이 동작

1.2 문제 상황

  • 환경: React 18 + TanStack Table 기반 스프레드시트 컴포넌트 (SheetTable.tsx, 약 2000줄)
  • 현상: 테이블에서 마우스 드래그로 셀 범위 선택 시 눈에 띄는 지연(lag) 발생
  • 재현 조건: 10행 이상의 테이블에서 빠르게 드래그할 때 프레임 드랍 체감

1.3 문제의 심각성

  • 사용자 경험 저하: 스프레드시트의 핵심 기능인 범위 선택이 불쾌한 경험을 제공
  • 확장성 문제: 데이터가 늘어날수록 성능 저하가 기하급수적으로 악화
  • 경쟁력 저하: Excel, Google Sheets 등 기존 솔루션 대비 현저히 떨어지는 반응성

2. 원인 분석

2.1 분석 방법

React DevTools Profiler와 코드 정적 분석을 통해 렌더링 병목 지점을 식별했어요

2.2 발견된 문제점

문제 1: O(N) 시간복잡도의 셀 상태 조회

// 기존 코드 - 매 셀 렌더링마다 O(N) 조회
const isCellSelected = useCallback(
(rowId: string, columnId: string) => {
return selectedCells.some((c) => c.rowId === rowId && c.columnId === columnId);
},
[selectedCells]
);
// 셀 렌더링 내부
const isMultiSelected = isCellSelected(row.original.id, col.id);
const isFillPreview = fillPreviewCells.some(
c => c.rowId === row.original.id && c.columnId === col.id
);

분석:

  • 100개 셀이 선택된 상태에서 500개 셀을 렌더링하면: 500 × 100 = 50,000번의 비교 연산이 발생해요
  • Array.some()은 최악의 경우 배열 전체를 순회하므로 O(N)이에요
  • 드래그 중 매 프레임마다 이 연산이 반복돼요

문제 2: 과도한 useMemo 의존성

// 기존 코드 - 28개의 의존성
const columns = useMemo<ColumnDef<Row>[]>(() => {
// 컬럼 정의 로직 (약 400줄)
}, [
sheet.columns,
sheet.rows,
editingCell,
// ... 25개 더
fillPreviewCells, // 드래그마다 변경됨
moveTargetCell, // 드래그마다 변경됨
]);

분석:

  • fillPreviewCells, moveTargetCell 등 드래그 상태가 의존성에 포함되어 있어요
  • 마우스 이동 시마다 전체 컬럼 정의가 재생성돼요
  • 컬럼 재생성 → 테이블 재렌더링 → 모든 셀 재렌더링의 연쇄 반응이 일어나요

문제 3: Throttle 미적용

// 기존 코드 - 모든 mousemove 이벤트 처리
const handleCellMouseEnter = useCallback(
(rowId: string, columnId: string) => {
if (!isDraggingRef.current) return;
const rangeCells = calculateDragSelection(...);
setSelectedCells(rangeCells); // 매번 state 업데이트
},
[calculateDragSelection]
);

분석:

  • mousemove 이벤트는 초당 수백 번 발생할 수 있어요
  • 매 이벤트마다 setSelectedCells 호출 → 리렌더링이 트리거돼요
  • 브라우저 렌더링 주기(60fps = 16.67ms)보다 빈번한 상태 업데이트가 발생합니다

3. 해결 방안 연구

3.1 학습 자료

  • MDN Web Docs: Set 자료구조의 시간복잡도 분석
  • React 공식 문서: useMemo 최적화 가이드
  • Web.dev: 렌더링 성능 최적화 패턴
  • Lodash 소스코드: throttle 구현 원리

3.2 적용 가능한 기법 검토

기법장점단점채택 여부
Set 자료구조O(1) 조회, 구현 간단메모리 약간 증가O
Throttle이벤트 빈도 제한반응성 약간 감소O
Virtual Scrolling대용량 데이터 처리구현 복잡도 높음X (추후 검토)
Web Worker메인 스레드 분리통신 오버헤드X

4. 구현

4.1 Set 기반 O(1) 조회 구조 도입

// 셀 키 생성 유틸리티
const cellKey = (rowId: string, columnId: string) => `${rowId}:${columnId}`;
// 배열과 함께 Set 유지
const [selectedCells, setSelectedCells] = useState<{rowId: string; columnId: string}[]>([]);
const selectedCellsSet = useMemo(
() => new Set(selectedCells.map(c => cellKey(c.rowId, c.columnId))),
[selectedCells]
);
// O(1) 조회
const isCellSelected = useCallback(
(rowId: string, columnId: string) => selectedCellsSet.has(cellKey(rowId, columnId)),
[selectedCellsSet]
);

설계 결정:

  • 기존 배열 구조를 유지해서 기존 로직 변경을 최소화했어요
  • useMemo로 Set을 파생해서 불필요한 재생성을 방지했어요
  • 문자열 키를 사용했는데, Map보다 단순한 구조로 충분하거든요

4.2 Throttle 유틸리티 구현

function throttle<T extends (...args: Parameters<T>) => void>(
fn: T,
delay: number
): T {
let lastCall = 0;
let timeoutId: ReturnType<typeof setTimeout> | null = null;
return ((...args: Parameters<T>) => {
const now = Date.now();
const remaining = delay - (now - lastCall);
if (remaining <= 0) {
if (timeoutId) {
clearTimeout(timeoutId);
timeoutId = null;
}
lastCall = now;
fn(...args);
} else if (!timeoutId) {
timeoutId = setTimeout(() => {
lastCall = Date.now();
timeoutId = null;
fn(...args);
}, remaining);
}
}) as T;
}

설계 결정:

  • 16ms 간격(60fps 기준)을 선택했어요
  • trailing edge 호출을 보장해서 마지막 마우스 위치를 반영해요
  • lodash 의존성 추가 대신 직접 구현해서 번들 크기를 최적화했어요

4.3 드래그 핸들러에 Throttle 적용

const handleCellMouseEnterThrottled = useMemo(
() => throttle((rowId: string, columnId: string) => {
if (!isDraggingRef.current || !dragStartCellRef.current) return;
const rangeCells = calculateDragSelection(dragStartCellRef.current, { rowId, columnId });
setSelectedCells(rangeCells);
setSelectedCell({ rowId, columnId });
}, 16),
[calculateDragSelection]
);

5. 결과 검증

5.1 정량적 개선

지표개선 전개선 후개선율
셀 상태 조회 복잡도O(N)O(1)-
100셀 선택 시 비교 연산 (500셀 테이블)~50,000회~500회99% 감소
드래그 중 state 업데이트 빈도~200회/초~60회/초70% 감소

5.2 정성적 개선

  • 드래그 시 프레임 드랍 현상이 해소됐어요
  • 대용량 테이블에서도 부드러운 선택 경험을 제공해요

5.3 검증 방법

Terminal window
npm run build # 타입 에러 및 빌드 오류 없음 확인

6. 핵심 교훈

6.1 자료구조 선택의 중요성

  • 동일한 기능도 자료구조에 따라 O(N) vs O(1) 차이가 발생해요
  • 렌더링 루프 내부의 작은 비효율이 전체 성능에 큰 영향을 미칩니다

6.2 React 최적화 원칙

  • useMemo 의존성은 최소한으로 유지해야 해요
  • 빈번한 상태 변경은 throttle/debounce로 제어하는 게 좋아요
  • 렌더링 성능 문제는 대부분 불필요한 리렌더링에서 기인하거든요

6.3 측정 기반 최적화

  • 추측이 아닌 프로파일링 결과를 기반으로 병목 지점을 식별해야 해요
  • 개선 전후 정량적 비교로 효과를 검증합니다

7. 2차 최적화 (Excel 수준 성능)

7.1 추가 문제 발견

1차 최적화 후에도 드래그 시 미세한 지연이 존재했어요. 오픈소스 스프레드시트(Handsontable, AG Grid, Google Sheets)를 분석한 결과 추가 최적화 기법을 발견했어요.

7.2 적용 기법

기법 1: requestAnimationFrame 기반 Throttle

// 기존: setTimeout 기반 throttle (16ms 고정)
// 개선: requestAnimationFrame 기반 (브라우저 렌더링 사이클과 동기화)
function rafThrottle<T extends (...args: any[]) => void>(fn: T): T {
let rafId: number | null = null;
let lastArgs: any[] | null = null;
return ((...args: any[]) => {
lastArgs = args;
if (rafId === null) {
rafId = requestAnimationFrame(() => {
rafId = null;
if (lastArgs) {
fn(...lastArgs);
}
});
}
}) as T;
}

장점:

  • 브라우저 V-Sync와 동기화되어 프레임 드랍을 최소화해요
  • 백그라운드 탭에서 자동으로 일시 중지돼요
  • setTimeout 대비 더 정확한 타이밍을 제공합니다

기법 2: DOM 직접 조작 (React 렌더링 우회)

// 드래그 중에는 React 상태 대신 DOM 직접 조작
const handleCellMouseEnterThrottled = useMemo(
() => rafThrottle((rowId: string, columnId: string) => {
if (!isDraggingRef.current) return;
const rangeCells = calculateDragSelection(...);
// React 상태 업데이트 대신 DOM 직접 조작
const tableContainer = tableContainerRef.current;
if (tableContainer) {
// 이전 선택 스타일 제거
tableContainer.querySelectorAll('[data-cell-selected="true"]').forEach(el => {
el.removeAttribute('data-cell-selected');
(el as HTMLElement).style.background = '';
(el as HTMLElement).style.outline = '';
});
// 새 선택 스타일 추가
rangeCells.forEach(cell => {
const cellEl = tableContainer.querySelector(
`[data-cell-id="${cellKey(cell.rowId, cell.columnId)}"]`
) as HTMLElement;
if (cellEl) {
cellEl.setAttribute('data-cell-selected', 'true');
cellEl.style.outline = '2px solid var(--primary-blue)';
}
});
}
// ref에 저장 (마우스업 시 React 상태로 동기화)
pendingSelectionRef.current = rangeCells;
}),
[calculateDragSelection]
);

핵심 아이디어:

  • 드래그 중에는 React Virtual DOM Diffing + Re-render 사이클을 우회해요
  • 마우스 업 시에만 React 상태를 동기화합니다
  • 셀에 data-cell-id 속성을 추가해서 빠른 DOM 쿼리가 가능해요

기법 3: CSS will-change 힌트

// 셀에 GPU 가속 힌트 추가
style={{
// ... 기존 스타일
willChange: 'background, outline',
}}

효과:

  • 브라우저가 해당 속성 변경을 미리 최적화해요
  • GPU 레이어 분리로 리페인트 비용이 감소합니다

7.3 추가 개선 결과

지표1차 최적화 후2차 최적화 후개선율
드래그 중 React 리렌더링매 프레임0회 (마우스업 시 1회)100% 감소
스타일 업데이트 방식Virtual DOM Diff직접 DOM 조작-
프레임 동기화setTimeout (불완전)requestAnimationFrame완전 동기화

7.4 Excel/Google Sheets 수준 달성 방법론

  1. React 렌더링 최소화: 빈번한 UI 업데이트는 DOM 직접 조작으로 처리해요
  2. 브라우저 API 활용: requestAnimationFrame, will-change를 적극 활용합니다
  3. 마지막에만 상태 동기화: ref로 중간 값을 저장하고, 완료 시에만 state를 업데이트해요

변경 파일

  • src/components/sheet/SheetTable.tsx
    • throttle → rafThrottle 유틸리티 함수 변경
    • cellKey 유틸리티 함수 추가
    • selectedCellsSet, fillPreviewCellsSet 추가
    • 드래그 중 DOM 직접 조작 로직 추가
    • pendingSelectionRef, pendingSelectedCellRef 추가
    • 셀에 data-cell-id 속성 추가
    • CSS will-change 속성 추가

1. Problem Recognition

1.1 Expected Behavior

  • When selecting cell ranges by mouse drag in a spreadsheet table, the selection area should expand smoothly in immediate response to mouse movement
  • Maintain 60fps without frame drops during drag
  • No delay even in 100-row x 10-column tables

1.2 Problem

  • Environment: React 18 + TanStack Table spreadsheet component (SheetTable.tsx, ~2000 lines)
  • Symptom: Noticeable lag when selecting cell ranges via mouse drag
  • Reproduction: Frame drops felt when dragging quickly in tables with 10+ rows

1.3 Severity

  • UX degradation: Core spreadsheet feature (range selection) provides unpleasant experience
  • Scalability issue: Performance degrades exponentially as data grows
  • Competitiveness: Significantly worse responsiveness compared to Excel, Google Sheets

2. Root Cause Analysis

2.1 Analysis Method

Identified rendering bottlenecks through React DevTools Profiler and static code analysis

2.2 Issues Found

Issue 1: O(N) Cell State Lookup

// Original code - O(N) lookup per cell render
const isCellSelected = useCallback(
(rowId: string, columnId: string) => {
return selectedCells.some((c) => c.rowId === rowId && c.columnId === columnId);
},
[selectedCells]
);

Analysis:

  • Rendering 500 cells with 100 selected: 500 x 100 = 50,000 comparison operations
  • Array.some() traverses the entire array in worst case: O(N)
  • This computation repeats every frame during drag

Issue 2: Excessive useMemo Dependencies

// Original code - 28 dependencies
const columns = useMemo<ColumnDef<Row>[]>(() => {
// Column definition logic (~400 lines)
}, [
sheet.columns, sheet.rows, editingCell,
// ... 25 more
fillPreviewCells, // Changes every drag
moveTargetCell, // Changes every drag
]);

Analysis:

  • Drag states like fillPreviewCells, moveTargetCell included in dependencies
  • Entire column definition regenerated on every mouse move
  • Column regeneration → table re-render → all cells re-render cascade

Issue 3: No Throttle

// Original code - processes every mousemove event
const handleCellMouseEnter = useCallback(
(rowId: string, columnId: string) => {
if (!isDraggingRef.current) return;
const rangeCells = calculateDragSelection(...);
setSelectedCells(rangeCells); // state update every time
},
[calculateDragSelection]
);

Analysis:

  • mousemove events can fire hundreds of times per second
  • Every event calls setSelectedCells → triggers re-render
  • State updates more frequent than browser rendering cycle (60fps = 16.67ms)

3. Solution Research

3.1 References

  • MDN Web Docs: Set data structure time complexity analysis
  • React official docs: useMemo optimization guide
  • Web.dev: Rendering performance optimization patterns
  • Lodash source code: throttle implementation

3.2 Technique Evaluation

TechniqueProsConsAdopted
Set data structureO(1) lookup, simple implementationSlight memory increaseYes
ThrottleLimits event frequencySlight responsiveness reductionYes
Virtual ScrollingHandles large datasetsHigh implementation complexityNo (future review)
Web WorkerSeparates main threadCommunication overheadNo

4. Implementation

4.1 Set-Based O(1) Lookup

// Cell key utility
const cellKey = (rowId: string, columnId: string) => `${rowId}:${columnId}`;
// Maintain Set alongside array
const [selectedCells, setSelectedCells] = useState<{rowId: string; columnId: string}[]>([]);
const selectedCellsSet = useMemo(
() => new Set(selectedCells.map(c => cellKey(c.rowId, c.columnId))),
[selectedCells]
);
// O(1) lookup
const isCellSelected = useCallback(
(rowId: string, columnId: string) => selectedCellsSet.has(cellKey(rowId, columnId)),
[selectedCellsSet]
);

Design decisions:

  • Keep existing array structure → minimize existing logic changes
  • Derive Set via useMemo → prevent unnecessary recreation
  • String keys → simpler than Map and sufficient

4.2 Throttle Utility

function throttle<T extends (...args: Parameters<T>) => void>(
fn: T, delay: number
): T {
let lastCall = 0;
let timeoutId: ReturnType<typeof setTimeout> | null = null;
return ((...args: Parameters<T>) => {
const now = Date.now();
const remaining = delay - (now - lastCall);
if (remaining <= 0) {
if (timeoutId) { clearTimeout(timeoutId); timeoutId = null; }
lastCall = now;
fn(...args);
} else if (!timeoutId) {
timeoutId = setTimeout(() => {
lastCall = Date.now(); timeoutId = null; fn(...args);
}, remaining);
}
}) as T;
}

Design decisions:

  • 16ms interval (60fps standard)
  • Trailing edge call guaranteed → reflects last mouse position
  • Custom implementation instead of lodash → bundle size optimization

5. Results

5.1 Quantitative Improvement

MetricBeforeAfterImprovement
Cell state lookup complexityO(N)O(1)-
Comparisons with 100 cells selected (500-cell table)~50,000~50099% reduction
State update frequency during drag~200/sec~60/sec70% reduction

5.2 Qualitative Improvement

  • Frame drop during drag eliminated
  • Smooth selection experience even in large tables

6. Key Lessons

6.1 Data Structure Selection Matters

  • Same functionality yields O(N) vs O(1) difference depending on data structure
  • Small inefficiencies inside render loops have outsized impact on overall performance

6.2 React Optimization Principles

  • Keep useMemo dependencies minimal
  • Control frequent state changes with throttle/debounce
  • Rendering issues mostly stem from unnecessary re-renders

6.3 Measurement-Based Optimization

  • Identify bottlenecks through profiling, not guessing
  • Verify effectiveness with quantitative before/after comparison

7. Second Optimization (Excel-Level Performance)

7.1 Additional Issues

Subtle drag delay remained after first optimization. Analysis of open-source spreadsheets (Handsontable, AG Grid, Google Sheets) revealed additional techniques.

7.2 Applied Techniques

Technique 1: requestAnimationFrame-Based Throttle

function rafThrottle<T extends (...args: any[]) => void>(fn: T): T {
let rafId: number | null = null;
let lastArgs: any[] | null = null;
return ((...args: any[]) => {
lastArgs = args;
if (rafId === null) {
rafId = requestAnimationFrame(() => {
rafId = null;
if (lastArgs) fn(...lastArgs);
});
}
}) as T;
}

Advantages:

  • Syncs with browser V-Sync for minimal frame drops
  • Auto-pauses in background tabs
  • More accurate timing than setTimeout

Technique 2: Direct DOM Manipulation (Bypassing React Rendering)

Core idea:

  • During drag: bypass React Virtual DOM Diffing + Re-render cycle
  • Sync to React state only on mouse up
  • Add data-cell-id attribute to cells for fast DOM queries

Technique 3: CSS will-change Hint

style={{ willChange: 'background, outline' }}

Effect: Browser pre-optimizes property changes; GPU layer separation reduces repaint cost

7.3 Additional Results

MetricAfter 1st OptimizationAfter 2nd OptimizationImprovement
React re-renders during dragEvery frame0 (1 on mouse up)100% reduction
Style update methodVirtual DOM DiffDirect DOM manipulation-
Frame syncsetTimeout (imperfect)requestAnimationFramePerfect sync

7.4 Methodology for Excel/Google Sheets Performance

  1. Minimize React rendering: Use direct DOM manipulation for frequent UI updates
  2. Leverage browser APIs: requestAnimationFrame, will-change
  3. Sync state only at the end: Store intermediate values in refs, update state on completion

Changed Files

  • src/components/sheet/SheetTable.tsx
    • throttle → rafThrottle utility function change
    • cellKey utility function added
    • selectedCellsSet, fillPreviewCellsSet added
    • Direct DOM manipulation logic during drag added
    • pendingSelectionRef, pendingSelectedCellRef added
    • data-cell-id attribute added to cells
    • CSS will-change property added
Author
작성자 @범수

오늘의 노력이 내일의 전문성을 만든다고 믿습니다.

댓글