
이직한지 1달동안 고객이 보는 화면 성능 최적화를 보다가
900개의 리스트를 무한스크롤로 구현하셨는데
900개의 리스트가 모두 rendering되는 걸 발견했다.
급하게 만드시느라 DOM 가상화 부분을 안하셨나 생각해서
찾아보니 tanstack Virtural 라이브러리가 설치되어있었고 설정이 되어있었다.
하지만 무엇때문인지 적용되지 않고 있었다.
useWindowVirtualizer이 아니라 다른것을 사용하고 계셔서
사용자가 screen한 영역만 렌더링 되는것이 아니라 그 이외에 영역도 렌더링이 진행되고 있었다.
해당 hook을 사용하고 나니 모든게 렌더링 되지 않아서 원인을 살펴보니
최소 height가 없어서 그랬다. 그래서 처음 list에 보이는 12개의 productCard 높이인 800px을 주었다.
그 결과 하얀화면이 보이고 약간이라도 스크롤해야 list가 보였다.
가상화 라이브러리는 대량의 데이터를 효율적으로 보여주지만, "보이는 영역만 그린다"는 대전제 때문에 뷰포트 계산이 1px이라도 어긋나면 사용자에게 '하얀 화면'을 보여주는 치명적인 단점이 있습니다.
최근 프로젝트에서 TanStack Virtual(v3)을 사용하며 겪은 초기 렌더링 흰 화면 이슈와 이를 해결하기 위해 6차에 걸쳐 진행한 최적화 기록을 공유합니다.
모바일에서는 멀쩡한데, 데스크탑에서만 페이지 진입 시 목록이 비어 있다가 스크롤을 1px이라도 움직여야 상품이 나타나는 현상이 발생했습니다.
원인은 크게 세 가지였습니다.
- Window Scroll 인식 오류: 컨테이너가 아닌 윈도우 스크롤을 사용함에도 가상화 인스턴스가 초기 위치(scrollMargin)를 0으로 오판함.
- 레이아웃 시프트(Layout Shift): 데스크탑의 Sticky 카테고리 바가 접히거나 펼쳐질 때 컨테이너의 절대 위치가 변하지만, ResizeObserver는 이를 감지하지 못함.
- 렌더링 타이밍(Race Condition): React의 상태 업데이트와 TanStack Virtual 내부 캐시 갱신 사이에 미세한 시차가 발생하여 transform 값이 음수로 튀는 현상.
윈도우 레벨 스크롤을 정확히 감지하도록 전환하고, SSR 환경에서 뷰포트 높이가 0으로 잡혀 아이템이 아예 안 그려지는 문제를 막기 위해 initialRect fallback(800px)을 설정했습니다.
scrollMargin을 useState로 관리하려 했으나, TanStack Virtual 내부의 virtualRow.start(캐시값)와 즉시 변하는 state 간의 타이밍 불일치로 리스트가 덜덜 떨리는 현상이 발생했습니다. 결국 Ref 기반 계산 + 강제 Re-render(Tick) 전략을 택했습니다.
카테고리 전환 시 useEffect를 쓰면 화면이 한 번 깜빡인 뒤 스크롤이 위로 올라갑니다. 이를 useLayoutEffect로 옮겨 브라우저가 화면을 그리기(Paint) 전에 스크롤 위치와 데이터를 동기적으로 교체했습니다.
데스크탑 Sticky 바의 애니메이션(grid-template-rows)이 끝나는 시점을 감지하도록 리스너를 추가했습니다. 애니메이션으로 인해 컨테이너 위치가 밀려나면 scrollMargin을 재계산하고 가상화 엔진을 강제로 깨웠습니다.
가장 결정적인 해결책이었습니다. 초기 마운트 시 scrollMargin이 계산되어도 리렌더링이 없으면 엔진은 0을 기준으로 계산합니다. setScrollMarginTick을 통해 마운트 직후 강제로 한 번 더 그리도록 하여 '선 스크롤' 문제를 해결했습니다.
브라우저 환경에서도 가짜 높이(800px)를 주입하면 첫 계산이 틀어질 수 있습니다. SSR에서만 fallback을 쓰고, 클라이언트에서는 실제 window.innerHeight를 사용하도록 수정하여 정확도를 높였습니다.
// scrollMargin 갱신 후 virtualizer 강제 재계산 로직
useLayoutEffect(() => {
if (scrollMarginTick > 0) {
// 1. 아이템 사이즈 캐시 초기화
virtualizer.measure();
// 2. 가상화 엔진의 calculateRange를 강제 호출하기 위해 scroll 이벤트 dispatch
// (TanStack Virtual은 scroll/resize 이벤트에서만 계산을 수행하기 때문)
window.dispatchEvent(new Event('scroll'));
}
}, [scrollMarginTick]);
아직 overscan 기본값 설정이나 itemCount 의존성 최적화 같은 과제가 남아있지만, 이번 트러블슈팅을 통해 얻은 교훈은 명확합니다.
"가상화는 단순히 라이브러리를 쓰는 것이 아니라, 브라우저의 렌더링 사이클(Layout -> Paint -> Composite)과 엔진의 계산 주기를 동기화시키는 작업이다."
특히 TanStack Virtual처럼 성능을 위해 많은 것을 내부 캐시에 위임하는 라이브러리를 쓸 때는, '지금 리액트가 아는 값'과 '엔진이 아는 값'이 일치하는지를 항상 의심해야 합니다.
문제: 데스크탑 레이아웃 시프트로 인한 초기 흰 화면 및 스크롤 전 미출력.
해결:
useWindowVirtualizer + scrollMargin Ref 관리.
useLayoutEffect를 통한 동기적 위치 보정.
transitionend 및 마운트 시점 강제 이벤트 디스패치.
결과: 모든 환경에서 깜빡임 없는 매끄러운 가상화 그리드 구현 완료.