이 글에서 다루는 것
블로그 TIL 목록처럼 항목이 늘어나는 페이지에서 "전부 한 번에 렌더"는 DOM·초기 비용이 커진다. 동시에 첫 화면은 서버 HTML에 실제 카드가 있어야 SEO·LCP에 유리하다. 무한 스크롤과 가상화를 구분하고, SSR 첫 slice → hydration 후 virtual list 전환 패턴을 한 작업으로 정리한다.
1. flatten → SSR 첫 slice → virtualizer 전환
배경지식 (개념)
- 무한 스크롤: 스크롤 끝에 도달하면 데이터를 더 로드(또는 노출 상한을 늘림).
- 가상화(windowing): 보이는 행만 DOM에 올려 DOM 개수 상한을 유지.
- Hydration: 서버 HTML에 React가 이벤트·상태를 붙이는 과정.
어떤 상황이었나
블로그 TIL 목록이 늘어나면서 "전부 한 번에 렌더" 방식의 DOM·초기 렌더 비용이 커졌다. 연·월로 그룹된 nested 구조라 virtualizer에 바로 넣기도 애매했다.
반면 첫 방문자에게는 빈 shell이 아니라 실제 카드가 있는 HTML이 필요했다. virtualizer만 SSR하면 뷰포트 밖 행이 HTML에서 빠질 수 있다.
목표는 네 가지였다 — 서버 HTML에 첫 10건 실제 카드 포함(첫 paint), 끝에 도달하면 10건씩 노출 상한 증가(무한 스크롤), 보이는 구간만 DOM으로 유지(DOM 상한, TanStack Virtual), nested 연·월 → 1차원 row(month-header / til-item)(구조).
핵심 작업
- flatten: 연·월 nested 구조를
month-header/til-item1차원 row로 펼친 뒤 TanStack Virtual에 연결했다. - SSR: 서버는 목록 메타만 직렬화해
initialRows(첫 10건)와 전체rows를 클라이언트에 넘겼다. - 정적 gate: hydration 전에는 정적 목록 컴포넌트로 첫 slice를 HTML에 보장했다.
- 전환:
useLayoutEffect이후 virtual list로 스위치했다. - 무한 스크롤: 스크롤 끝에서
visibleCount를 10씩 늘리는 무한 스크롤과 결합했다.
교훈
- 무한 스크롤 ≠ 가상화 — 전자는 로드·노출할 데이터 양, 후자는 DOM 행 수를 제한한다.
- 가변 높이 카드는
estimateSize만으로 스크롤 점프 →measureElement보정 필요. - virtualizer 단독 SSR은 첫 HTML이 부족할 수 있어, 첫 paint가 중요하면 정적 gate 패턴이 낫다.
- 후속: 항목이 매우 많아지면 API 페이징으로 클라이언트 전체 배열 전달 축소, e2e로
/til첫 HTML·스크롤 검증.
→ List virtualization and windowing · SSR hydration mismatch