목차
토스 앱 안에서 여는 매장 상세 페이지는 메뉴가 많아질수록 진입과 스크롤이 무거워졌습니다. 한 행에 이미지·이름·설명·가격·버튼이 들어가고 카테고리 헤더까지 같은 목록에 섞였습니다. 늦게 뜨는 이미지 때문에 이미 계산한 높이도 계속 달라졌습니다.
처음에는 메뉴 수만 원인이라고 생각하기 쉬웠습니다. 실제로 확인해야 할 대상은 DOM 노드 수, 스타일·레이아웃 작업, 이미지 디코딩, React 렌더였습니다. 가상화는 이 가운데 동시에 유지하는 DOM을 줄입니다. 이미지 용량이나 비싼 컴포넌트 렌더까지 자동으로 해결해 주지는 않습니다.
느리다는 감각만으로는 부족했다
가상화의 효과를 다시 확인할 때는 같은 웹뷰 버전, 기기, 매장 데이터로 전후 조건을 맞춰야 합니다. Android WebView는 Chrome DevTools로 연결해 Performance trace를 남기고 다음 항목을 비교할 수 있습니다.
- 화면 진입부터 메뉴가 조작 가능한 시점까지의 흐름
- 렌더된 행과 전체 DOM 노드 수
- 긴 JavaScript 작업과 style·layout 구간
- 빠르게 위아래로 스크롤할 때 빈 영역과 위치 보정
- 이미지 로드 전후와 화면 회전 뒤의 행 위치
당시 상태를 재현할 원본 trace가 남아 있지 않아 시간이나 FPS는 제시하지 않습니다. 코드에서 확인할 수 있는 변화는 동시에 렌더되는 행이 보이는 구간과 overscan 범위로 줄었다는 점입니다. 실제 웹뷰에서는 이전보다 스크롤이 나아진 것을 체감했습니다.
가상화 전에 검토한 방법
DOM을 줄이는 방법은 가상화만 있는 게 아닙니다.
- 페이지네이션이나 더 보기 버튼은 구현과 접근성이 단순하지만 한 화면에서 메뉴 전체를 훑는 흐름이 끊깁니다.
- 무한 스크롤은 초기 DOM을 줄여도 이전 항목을 계속 쌓으면 결국 같은 문제로 돌아옵니다.
content-visibility는 브라우저가 화면 밖 렌더를 건너뛰게 도울 수 있지만 웹뷰 버전별 지원과 동적 높이 검증이 필요합니다.- 컴포넌트와 이미지 자체를 가볍게 만드는 일은 어느 선택에서도 필요합니다.
이 화면은 한 목록 안에서 카테고리를 오가고 이전 위치로 돌아오는 사용 흐름이 중요했습니다. 이미 프로젝트에서 TanStack Router와 Query를 사용하고 있었고, UI 마크업을 강제하지 않으면서 가변 높이를 측정할 수 있어 TanStack Virtual을 선택했습니다.
행 모델과 렌더링을 맞췄다
카테고리와 메뉴를 서로 다른 배열로 가상화하면 위치 계산과 카테고리 이동이 복잡해집니다. 화면에 나타나는 순서 그대로 하나의 판별 유니온으로 만들고, 행의 안정적인 ID를 key로 사용했습니다.
아래 코드는 제품 데이터와 스타일을 덜어 낸 개념 예시입니다.
estimateSize는 첫 배치의 위치를 잡는 추정치입니다. 실제 높이는 각 행의 data-index와 measureElement로 수집합니다. 추정치를 실제 높이에 가깝게 잡을수록 측정 전후의 보정이 작아지지만 모든 행을 같은 높이라고 가정하면 설명 길이와 카테고리 헤더 때문에 오차가 쌓입니다.
overscan: 6도 정답은 아닙니다. 값이 작으면 빠른 스크롤에서 빈 영역이 보일 수 있고, 크면 미리 렌더할 DOM과 이미지가 늘어납니다. 대상 웹뷰에서 스크롤 속도와 행 렌더 비용을 보며 조정할 값입니다.
동적 높이와 이미지 로드
이미지 로드 뒤 행이 커지면 아래 행의 시작 위치도 바뀝니다. 모든 이미지의 onLoad에서 virtualizer.measure()를 호출하면 전체 측정 캐시를 자주 초기화할 수 있습니다. 우선 이미지 컨테이너에 width·height 또는 aspect-ratio를 주어 로드 전부터 공간을 확보했습니다. 설명이 펼쳐지는 등 실제 행 크기가 달라지는 경우는 measureElement가 관찰한 크기로 갱신하도록 맡겼습니다.
TanStack Virtual의 기본 element observer는 스크롤 컨테이너 크기도 추적합니다. 다만 CSS에서 부모 높이가 정해지지 않으면 h-full만으로 올바른 viewport가 생기지 않습니다. 화면 회전, 소프트 키보드, 웹뷰 인셋 변경 뒤에는 컨테이너의 실제 크기와 행 위치가 맞는지 회귀 검증해야 합니다.
resizeItem으로 행 크기를 직접 넣는 방법도 있지만 같은 인덱스에서 measureElement와 함께 쓰면 서로 다른 값이 경쟁할 수 있습니다. 높이를 애플리케이션이 정확히 아는 행만 수동으로 지정하고 나머지는 측정에 맡기는 식으로 책임을 나눠야 합니다.
스크롤 복원은 픽셀 하나의 문제가 아니었다
상세 화면을 나갔다 돌아올 때 단순히 scrollToIndex를 호출하면 카테고리와 메뉴의 높이가 다른 목록에서 원래 위치가 어긋날 수 있습니다. 반대로 픽셀 offset만 저장하면 이미지나 데이터가 바뀐 뒤 같은 숫자가 다른 행을 가리킵니다.
기존 구현은 TanStack Router의 복원 값과 scrollToIndex를 조합했습니다. 동적 높이가 섞인 목록에서는 인덱스만으로 행 내부 위치까지 복원하기 어려웠습니다. 다시 구현한다면 중첩 스크롤 컨테이너의 offset을 useElementScrollRestoration으로 보관해 Virtual의 initialOffset에 전달하겠습니다. 복원 key에는 매장과 필터 조건을 포함하고, 목록 순서가 달라졌다면 이전 offset을 버리고 위로 돌려보내겠습니다. 잘못된 메뉴를 보여 주는 것보다 낫습니다.
정확도를 더 높이려면 화면 맨 위에 보이던 행 ID와 행 내부 offset을 함께 저장할 수 있습니다. 측정 캐시를 저장하는 API를 사용하는 방법도 있지만 캐시가 현재 데이터 순서와 같은지 검증해야 합니다. 복원 직후 아직 측정되지 않은 행은 estimateSize를 쓰므로 동적 높이가 많으면 후속 보정이 생길 수 있습니다. 이 화면에서는 부드러운 애니메이션보다 위치 안정성이 중요하므로 복원은 즉시 이동이 적합합니다.
SSR에서 initialRect가 해결하지 못하는 것
서버는 실제 웹뷰의 너비와 높이를 모릅니다. initialRect는 서버 렌더링 때 스크롤 컨테이너의 초기 사각형을 가정하도록 돕지만 실제 viewport나 각 행의 높이를 측정해 주지는 않습니다. 잘못된 값은 hydration 뒤 렌더 범위가 다시 계산되면서 화면 이동을 만들 수 있습니다.
initialRect를 넣었다고 전체 메뉴가 서버 HTML에 생기는 것도 아닙니다. 검색 노출, 브라우저 찾기, 인쇄, 스크린 리더가 모든 항목에 접근해야 한다면 가상화된 화면만으로 요구사항을 충족하기 어렵습니다. 이 서비스의 상세 화면은 앱 안의 상호작용이 중심이어서 그 제약을 받아들였지만, 공개 검색 페이지라면 서버 페이지네이션이나 별도 비가상화 표현을 검토해야 합니다.
실패와 빈 상태를 따로 다뤘다
가상화는 데이터 요청의 loading, error, empty 상태를 대신하지 않습니다. 메뉴 쿼리가 실패하면 재시도할 수 있는 오류 화면을, 결과가 비어 있으면 빈 상태를 보여 줍니다. 목록을 만들 수 있는 데이터가 준비된 뒤에만 virtualizer를 렌더했습니다.
측정 오차가 생겨도 화면 전체가 사라지지 않도록 각 행에는 추정 높이가 남아 있습니다. 이미지에는 고정된 자리와 실패 대체 UI를 두었습니다. 카테고리 이동과 스크롤 복원은 ID를 찾지 못하면 첫 행으로 이동하며, 오래된 인덱스를 그대로 사용하지 않습니다.
회귀 점검에는 긴 설명, 이미지 성공·실패, 카테고리만 있는 구간, 화면 회전, 빠른 왕복 스크롤, 뒤로 가기, 필터 변경을 포함해야 합니다. 저사양 Android 한 대에 그치지 않고 지원 범위 안의 WebView 버전과 iOS에서도 같은 시나리오를 확인해야 합니다.
가상화가 공짜는 아니었다
가상화 뒤 DOM 부담은 줄었지만 복잡성은 늘었습니다. sticky 카테고리, 키보드 포커스 이동, 접근성 탐색, 페이지 내 검색은 화면 밖 행이 DOM에 없다는 사실의 영향을 받습니다. 메뉴가 짧은 매장에서는 이 비용을 감수할 이유가 적습니다.
이 경험 이후에는 스크롤이 느리다고 바로 가상화부터 넣지 않습니다. 먼저 React 렌더, 이미지 디코딩, layout 비용을 따로 봅니다. DOM 수가 실제 병목이고, 동적 높이와 스크롤 복원까지 감당할 이유가 있을 때 TanStack Virtual을 선택합니다.
