사내 대시보드의 스케줄 > 엑셀뷰는 월간 기준 최대 50행 × 31열의 셀을 한 화면에 표시하는 대용량 그리드 UI입니다.
초기에는 단순히 "셀 수가 많아서 느린 화면"이라고 생각했지만, 실제 사용자 피드백은 스크롤보다 셀을 클릭하거나 편집 모드로 전환할 때 화면이 늦게 반응한다는 것이었습니다.
Chrome DevTools의 Performance 패널에서 측정한 결과, 셀 클릭과 편집 전환 과정에서 INP가 약 5.8초까지 발생하고 있었습니다.
이번 개선에서는 단순히 특정 코드를 빠르게 만드는 것보다,
실제 병목을 측정하고
가장 큰 비용을 만드는 렌더링 구조를 개선한 뒤
남은 메인 스레드 작업을 단계적으로 줄이고
각 단계마다 성능 회귀 여부를 검증하는
방식으로 접근했습니다.
최종적으로 INP를 5,831ms에서 792ms까지 약 86% 개선했습니다.
기존 엑셀뷰는 화면에 필요한 모든 행과 열을 한 번에 렌더링하고 있었습니다.
50행 × 31열 기준으로 단순 셀만 계산해도 약 1,550개가 존재하며, 실제 DOM에는 셀 외의 wrapper와 관련 컴포넌트까지 함께 구성됩니다.
측정 당시 주요 지표는 다음과 같았습니다.
지표 | 개선 전 |
|---|---|
INP | 5,831ms |
셀 클릭 → 팝오버 | 719ms |
편집 → 편집 모드 | 1,849ms |
DOM cells | 약 1,550개 |
CLS | 0.22 ~ 0.24 |
여기서 중요한 점은 스크롤 성능과 클릭 성능은 같은 문제가 아니라는 것입니다.
스크롤할 때 프레임이 끊기는 문제를 해결하는 것과, 셀을 클릭했을 때 다음 화면이 늦게 그려지는 문제는 서로 다른 병목을 가질 수 있습니다.
이번 작업의 핵심은 후자였습니다.
성능 최적화에서 가장 먼저 한 일은 코드를 수정하는 것이 아니라 측정 환경을 만드는 것이었습니다.
import.meta.env.DEV 조건으로 개발 환경에서만 사용할 수 있는 성능 측정 API를 추가했습니다.
window.__scheduleExcelPerf를 통해 다음 정보를 수집할 수 있도록 구성했습니다.
performance.mark()
performance.measure()
클릭/편집 interaction 샘플
avg / min / max / median
DOM row / cell 개수
baseline 저장
이전 측정값과 비교
이를 통해 단순히 "빨라진 것 같다"가 아니라 변경 전후의 수치를 비교할 수 있었습니다.
Chrome Performance 패널에서는 performance.measure()로 기록한 User Timing을 통해 React 렌더링과 실제 브라우저 작업이 어느 구간에서 발생하는지도 함께 확인했습니다.
다음과 같은 시나리오를 반복해서 측정했습니다.
Performance Record
↓
셀 클릭
↓
팝오버 표시
↓
편집 버튼 클릭
↓
편집 모드 진입
↓
Stop이후 Main Thread의 flame chart와 User Timing을 비교하면서 긴 task가 어디에서 발생하는지 확인했습니다.
특히 다음 작업들이 눈에 띄었습니다.
대량 React component render
layout measurement
CSS-in-JS style serialization
편집 모드 전환에 따른 대규모 mount
여러 영역의 동기 state update
성능 최적화 과정에서 동작을 깨뜨리지 않기 위해 관련 로직도 테스트했습니다.
총 9개의 테스트를 추가하여 다음 영역을 검증했습니다.
scroll sync의 requestAnimationFrame batching
Performance API wrapper
User Timing 기록
baseline / comparison 로직
성능 개선은 숫자를 낮추는 것뿐 아니라 다시 느려지지 않게 만드는 것도 중요하기 때문입니다.
측정 결과 병목은 하나가 아니었습니다.
모든 행과 열을 동시에 렌더링하면서 약 1,550개의 셀이 DOM에 존재했습니다.
각 셀 자체의 렌더링 비용뿐 아니라 React reconciliation과 하위 컴포넌트들의 비용까지 누적되고 있었습니다.
셀 하나를 클릭했을 뿐인데 관련 state 변경으로 인해 넓은 범위의 React tree가 다시 계산되고 있었습니다.
특히 편집 모드에서는 조회용 셀과 편집용 셀의 구조가 크게 달라지면서 비용이 커졌습니다.
EditableScheduleCell에서 렌더링 과정마다 getBoundingClientRect()를 호출하고 있었습니다.
편집 셀이 많아질수록 layout read가 반복되었고, 브라우저가 style/layout을 다시 계산해야 하는 상황이 발생했습니다.
측정 당시 약 200회 수준의 layout measurement가 발생하는 구간도 확인했습니다.
Emotion의 style serialization 역시 대량 셀 렌더링 상황에서는 무시하기 어려운 비용으로 나타났습니다.
개별 컴포넌트에서는 작은 비용이지만, 수백 개의 셀이 동시에 렌더링되면 누적 비용이 커집니다.
편집 모드 진입에서는 단순히 셀 하나의 상태만 변경되는 것이 아니었습니다.
동시에 다음 작업들이 발생했습니다.
ScheduleCell → EditableScheduleCell 교체
GNB mini mode 전환
편집 store reset
ScheduleEditToolBox mount
관련 grouped cell 계산
결국 한 번의 클릭으로 비교적 큰 React update가 발생하고 있었습니다.
가장 먼저 해결한 것은 DOM 자체의 크기였습니다.
@tanstack/react-virtual을 사용해 화면에 실제로 보이는 영역과 overscan 영역만 렌더링하도록 변경했습니다.
기존에는:
50 rows × 31 columns
↓
약 1,550 cells였다면,
개선 후에는:
visible rows + overscan
×
visible columns + overscan
↓
약 273 cells수준으로 줄였습니다.
지표 | 개선 전 | 개선 후 |
|---|---|---|
DOM cells | 1,550 | 약 273 |
셀 클릭 → 팝오버 | 719ms | 192ms |
편집 → 편집 모드 | 1,849ms | 481ms |
INP | 5,831ms | 792ms |
가상화만으로도 가장 큰 폭의 개선이 발생했습니다.
특히 중요한 것은 단순히 렌더링 개수를 줄인 것이 아니라, 클릭 interaction이 발생했을 때 React가 처리해야 하는 tree 자체를 작게 만들었다는 점입니다.
조회(view), 편집(edit), 복사(copy) 모드 모두 동일한 가상화 구조를 사용하도록 구성했습니다.
관련 구현은 다음 컴포넌트와 유틸리티를 중심으로 분리했습니다.
ScheduleExcelVirtualRows
ScheduleExcelTable
scheduleExcelVirtual.constants
focusSelectedCell
그리드와 헤더, 하단 통계 영역은 서로 scrollLeft를 동기화해야 했습니다.
기존에는 scroll event가 발생할 때마다 동기화 작업이 실행되는 구조였습니다.
빠르게 스크롤하면 짧은 시간에 많은 이벤트가 발생할 수 있기 때문에 이를 requestAnimationFrame으로 batching했습니다.
scroll events
↓
여러 번 발생
↓
requestAnimationFrame
↓
프레임당 1회 syncburst scroll 상황에서 측정한 sync operation은
480회 → 4회
로 감소했습니다.
약 99.2% 감소한 수치입니다.
다만 이 최적화는 클릭 INP를 직접 해결한 핵심 요인이라기보다는, 스크롤 과정에서 메인 스레드에 발생하는 불필요한 작업을 줄인 보조적인 개선으로 판단했습니다.
가상화 이후에도 INP는 약 792ms로, 여전히 이상적인 수준과는 차이가 있었습니다.
이제부터는 전체 구조를 바꾸기보다는 DevTools에서 실제로 비용이 발생하는 지점을 하나씩 제거했습니다.
startTransition으로 urgent / non-urgent update 분리편집 모드 전환에는 사용자에게 즉시 보여줘야 하는 작업과 조금 늦게 처리해도 되는 작업이 섞여 있었습니다.
예를 들어:
팝오버 닫기
confirm modal 닫기
편집 모드 전환
GNB mini mode 변경
grouped cell 계산
store reset
편집 관련 UI mount
이를 React의 startTransition으로 분리했습니다.
startTransition(() => {
setMode('edit');
setLeftGnbMiniMode(true);
reset();
});여기서 중요한 것은 startTransition이 실제 작업량을 없애는 기술은 아니라는 점입니다.
대신 사용자의 클릭에 대한 urgent update를 먼저 처리하고, 상대적으로 덜 긴급한 대량 업데이트를 transition으로 분리하여 첫 반응을 빠르게 만드는 데 목적이 있습니다.
다음으로 getBoundingClientRect() 호출을 줄였습니다.
기존 구조에서는 각 EditableScheduleCell이 렌더링될 때 자신의 위치를 측정했습니다.
즉,
EditableCell
EditableCell
EditableCell
...
↓
getBoundingClientRect()
getBoundingClientRect()
getBoundingClientRect()
...처럼 동일한 종류의 layout read가 반복되었습니다.
이를 다음과 같이 변경했습니다.
기본 anchor
↓
고정 크기 사용
실제 measurement가 필요한 경우
↓
선택된 셀 1개만 측정기본 anchor는 160 × 76 상수를 사용하고, 실제 measurement가 필요한 선택 셀만 useLayoutEffect에서 측정하도록 변경했습니다.
추가적인 measurement는 ResizeObserver와 requestAnimationFrame을 활용해 동기적인 layout read가 한 번의 render cycle에 집중되지 않도록 분리했습니다.
셀 컴포넌트가 store의 넓은 상태를 구독하고 있던 부분도 좁혔습니다.
전체 state를 구독하는 대신 셀에 실제로 필요한 flag만 selector로 가져오고 shallow 비교를 사용했습니다.
개념적으로는 다음과 같습니다.
Before
Cell
↓
large store state
↓
상태 변경
↓
많은 Cell re-render
After
Cell
↓
필요한 cellFlags만 subscribe
↓
관련 값이 변경된 경우에만 render가상화와 함께 적용하면서 DOM 수뿐 아니라 각 interaction에서 발생하는 React 업데이트 범위도 줄일 수 있었습니다.
ScheduleCell은 memo를 사용하고 있었지만 parent에서 매 render마다 새로운 callback을 전달하면 memoization 효과가 떨어질 수 있습니다.
특히 다음 interaction handler가 반복적으로 생성되는 구조를 개선했습니다.
onViewCellClick
onEditableCellClick
stable callback을 유지하고 interaction에 필요한 최신 상태는 useRef로 분리했습니다.
이를 통해 불필요한 child render를 줄였습니다.
편집 모드에서 뒤로가기할 때 invalidateQueries가 UI interaction과 같은 흐름에서 기다려지는 부분도 확인했습니다.
기존:
뒤로가기
↓
invalidateQueries await
↓
UI 전환개선:
뒤로가기
↓
UI 전환
↓
invalidateQueries 실행즉, UI를 막을 필요가 없는 query invalidation은 await하지 않고 실행하도록 변경했습니다.
void queryClient.invalidateQueries(...);이 역시 네트워크 작업 자체를 빠르게 만든 것이 아니라 사용자 interaction과 무관한 작업이 UI 업데이트를 붙잡지 않도록 분리한 것입니다.
INP를 개선하면서 CLS도 함께 확인했습니다.
당시 CLS는 약 0.22 ~ 0.24 수준이었습니다.
특히 편집 모드로 전환할 때 GNB와 Filter 영역의 구조가 바뀌면서 레이아웃 이동이 발생했습니다.
다만 CLS를 낮추는 과정에서 무조건 레이아웃을 고정하는 것은 좋은 해결책이 아니었습니다.
실제로 몇 가지 접근을 시도했습니다.
시도 | 결과 |
|---|---|
| 회귀 발생 → 원복 |
| 적용 |
GNB width transition | 미적용 |
GNB mini mode 구조 변경 | 미적용 |
특히 FilterBarSlot에 공간을 유지하는 방식은 CLS 측면에서는 도움이 되었지만, 테이블이 약 57px 아래로 이동하면서 기존 UX와 레이아웃이 달라졌습니다.
따라서 해당 변경은 원복했습니다.
이 과정에서 얻은 중요한 결론은 Web Vitals 점수를 낮추는 것 자체가 최종 목표가 되어서는 안 된다는 것이었습니다.
실제 제품에서는 기존 UX를 깨면서 CLS를 몇 점 낮추는 것보다, 사용자가 체감하는 레이아웃과 interaction을 유지하는 것이 더 중요할 수 있습니다.
최종 측정 결과는 다음과 같습니다.
지표 | 개선 전 | 개선 후 | 개선율 |
|---|---|---|---|
INP | 5,831ms | 792ms | 86% ↓ |
셀 클릭 → 팝오버 | 719ms | 192ms | 73% ↓ |
편집 → 편집 모드 | 1,849ms | 481ms | 74% ↓ |
DOM cells | 1,550 | 약 273 | 82% ↓ |
Scroll sync ops | 480 | 4 | 99.2% ↓ |
측정 조건은 50행 × 31열 월간 엑셀뷰이며, 개발용 Performance API와 Chrome DevTools Performance를 사용했습니다.
이번 작업에서 가장 큰 효과를 만든 것은 의외로 복잡한 최적화 기법이 아니었습니다.
핵심은 DOM을 줄이는 것이었습니다.
전체 결과를 보면:
1,550 cells
↓
행·열 가상화
↓
약 273 cells
↓
React render 범위 감소
↓
layout / style 비용 감소
↓
interaction response 개선이라는 구조적인 개선이 먼저 이루어졌고,
그 이후에
startTransition
layout measurement 제거
store subscription 축소
stable callback
rAF batching을 적용하면서 남은 비용을 줄였습니다.
즉, micro optimization보다 먼저 rendering architecture를 개선한 것이 핵심이었습니다.
최종 INP는 5,831ms → 792ms까지 개선됐지만, 여전히 200ms 기준과는 차이가 있습니다.
여기서 중요한 것은 콘솔에서 측정한 편집 진입 시간과 DevTools에서 측정한 INP가 같은 지표가 아니라는 점입니다.
콘솔에서 측정한 편집 진입 시간 약 481ms는 ScheduleEditToolBox가 ready된 시점까지를 측정한 값입니다.
반면 DevTools의 INP 약 792ms는 사용자의 클릭부터 브라우저가 해당 interaction에 대한 다음 paint를 완료하는 시점까지의 시간을 측정합니다.
즉, 콘솔 측정이 끝난 이후에도 다음과 같은 브라우저 작업이 남아 있습니다.
클릭
↓
React update
↓
ScheduleEditToolBox ready
↓
Emotion 스타일 적용
↓
GNB / 메인 영역 layout
↓
셀 paint
↓
다음 frame따라서 UI가 ready된 시점과 실제 브라우저가 화면을 그린 시점 사이의 비용 때문에 두 수치에는 차이가 발생합니다.
가상화를 적용하면서 DOM 셀을 1,550개 → 약 273개로 줄였지만, 편집 모드로 전환할 때 발생하는 모든 비용을 제거한 것은 아닙니다.
편집 버튼을 클릭하면 다음 작업들이 동시에 발생합니다.
ScheduleCell → EditableScheduleCell 교체조회 모드와 편집 모드가 서로 다른 컴포넌트 구조이기 때문에 visible 영역의 셀들이 교체됩니다.
ScheduleCell
↓ mode 변경
EditableScheduleCell가상화는 동시에 렌더링하는 셀의 개수를 줄여주지만, 이처럼 컴포넌트 타입 자체가 변경되는 비용은 그대로 남습니다.
setLeftGnbMiniMode(true)가 실행되면서 GNB가 240px → 54px로 변경되고, 이에 따라 메인 콘텐츠 영역도 다시 layout됩니다.
ScheduleEditToolBox mount편집 패널과 탭, validation 관련 UI가 새롭게 mount됩니다.
편집 모드 전환에 따라 그리드, 헤더, 하단 통계 등 여러 구독 영역이 함께 업데이트됩니다.
셀과 편집 UI가 mount/re-render되면서 Emotion의 style serialization 및 스타일 적용 비용도 함께 발생합니다.
startTransition으로도 INP가 크게 줄지 않은 이유이 과정에서 startTransition을 적용해 urgent update와 non-urgent update를 분리했습니다.
예를 들어 팝오버를 닫는 것처럼 사용자에게 즉시 보여줘야 하는 업데이트는 먼저 처리하고, 편집 모드 전환과 같은 상대적으로 무거운 업데이트는 transition으로 처리하도록 했습니다.
startTransition(() => {
setMode('edit');
setLeftGnbMiniMode(true);
reset();
});이렇게 하면 클릭 직후 팝오버가 먼저 사라지는 등 체감 반응성은 개선할 수 있습니다.
하지만 startTransition은 작업 자체를 제거하는 것이 아니라 업데이트의 우선순위를 조정하는 것입니다.
편집 모드 전환에 필요한 셀 교체, GNB 변경, 편집 UI mount 등의 작업은 결국 수행되어야 합니다. 따라서 이 작업들이 다음 paint를 완성하는 과정에 포함되면 INP 자체는 크게 낮아지지 않을 수 있습니다.
즉,
startTransition
↓
작업량 감소 X
↓
업데이트 우선순위 조정 O이라는 차이가 있습니다.
이번 작업에서는 기존 UX를 유지하면서 큰 병목을 제거하는 것을 우선했습니다.
그 결과 가상화를 통해 DOM을 크게 줄였고, layout measurement와 동기적인 업데이트를 줄여 INP를 5.8초에서 약 0.8초까지 개선했습니다.
하지만 현재 구조에서는 편집 모드 진입 시 다음 비용이 남아 있습니다.
조회 셀 → 편집 셀 component 교체
GNB mini mode 변경
ScheduleEditToolBox mount
Zustand 연쇄 업데이트
Emotion runtime 비용
이 비용을 더 줄이려면 단순한 micro optimization보다는 컴포넌트 구조 자체를 변경해야 합니다.
예를 들어 조회/편집 상태를 하나의 셀 컴포넌트에서 관리한다면,
Before
ScheduleCell
↕
EditableScheduleCell
After
ScheduleCell
├─ view state
└─ edit state처럼 component tree를 유지하면서 내부 상태만 전환할 수 있습니다.
그 외에도 다음과 같은 방향을 고려할 수 있습니다.
방향 | 기대 효과 | 비용 |
|---|---|---|
단일 셀 컴포넌트 + CSS/상태 전환 | unmount/mount 감소 | 대규모 리팩터링 |
Emotion → class/CSS 기반 스타일 | runtime 스타일 비용 감소 | 단계적 적용 가능 |
편집 UI 별도 레이어로 분리 | 그리드 교체 비용 감소 | 구조 변경 |
GNB mini mode 전환 방식 변경 | layout 비용 감소 | 기존 UX와 충돌 가능 |
따라서 이번 작업에서는 여기서 멈췄습니다.
5.8초 → 0.8초라는 큰 병목 개선 이후에는, 추가적인 수십~수백 ms를 줄이기 위해 화면 구조와 UX까지 변경해야 하는 단계에 들어갔기 때문입니다.
결국 이번 작업에서의 목표는 INP 숫자를 무조건 200ms 이하로 만드는 것이 아니라,
사용자가 실제로 느끼는 interaction을 기준으로 가장 큰 병목을 찾아 제거하고, 성능과 기존 UX 사이에서 합리적인 지점을 찾는 것
이었습니다.
모든 성능 문제를 끝까지 해결하는 것이 항상 좋은 선택은 아니었습니다.
특히 다음 작업은 추가 개선 여지가 있지만 이번 작업에서는 적용하지 않았습니다.
Emotion 전면 제거
조회/편집 컴포넌트 구조 통합
GNB 레이아웃 구조 변경
Filter 영역 레이아웃 변경
편집 모드 진입 architecture 재설계
이유는 현재 성능 개선 효과와 변경 리스크의 균형 때문입니다.
이미 INP가 약 86% 개선되었고, 실제 셀 클릭과 편집 진입 시간도 70% 이상 감소한 상황에서 대규모 architecture 변경을 진행하면 성능 외에 UI 회귀와 유지보수 비용이 발생할 수 있습니다.
따라서 이번 작업에서는 큰 병목을 제거하고, 비용 대비 효과가 낮아지는 지점에서 작업을 마무리했습니다.
이번 성능 개선에서 가장 크게 얻은 교훈은 **"느린 UI를 빠르게 만드는 것보다, 먼저 무엇이 느린지 정확하게 측정하는 것이 중요하다"**는 것이었습니다.
처음에는 단순히 "셀 개수가 많아서 느리다"고 생각할 수 있었지만 실제 측정을 통해 확인해보니,
과도한 DOM
넓은 React update
layout thrashing
CSS-in-JS runtime cost
동기적인 mode transition
등 여러 비용이 동시에 존재했습니다.
그래서 한 번에 모든 코드를 수정하지 않고,
Baseline → 측정 → 가장 큰 병목 제거 → 재측정 → 다음 병목 제거
순서로 접근했습니다.
그 결과,
INP 5,831ms → 792ms
DOM cells 1,550개 → 약 273개
까지 개선할 수 있었습니다.
특히 이번 작업에서 중요했던 것은 단순히 "가상화를 적용했다"는 사실이 아니라, 사용자가 실제로 느끼는 interaction을 기준으로 병목을 정의하고, 측정 가능한 지표를 만든 뒤 최적화의 우선순위를 결정했다는 것입니다.
대용량 그리드에서는 스크롤 성능과 클릭 성능이 반드시 같은 문제는 아닙니다.
따라서 성능 최적화를 시작할 때는 먼저 **"어떤 interaction이 느린가?"**를 정의하고, 그 interaction의 전체 critical path를 측정하는 것부터 시작하는 것이 효과적입니다.