본문으로 건너뛰기
Tech Blog

가상 리스트가 DOM에서 덜어내는 것들

글 복사 완료!

수천 행짜리 리스트가 뻑뻑해질 때, 가상화는 뭘 빼고 뭘 다시 채워야 하는 걸까요

·11분·

수천 행짜리 테이블을 한 번 그려본 적 있다면, 스크롤이 이상하게 뻑뻑해지는 순간을 기억하실 거예요. 저도 처음엔 "렌더링이 왜 이렇게 무겁지" 싶어서 React DevTools Profiler만 뒤적였거든요. 근데 범인은 React가 아니라 그 아래 깔려 있는 DOM이었어요. 가상 리스트는 이 DOM을 덜어내는 기술이에요. 다만 덜어낸 만큼, 브라우저가 원래 해주던 일을 개발자가 대신 떠맡아야 합니다.

리스트가 무거워지는 순간

행이 몇천 개를 넘어가면, 각 행이 아무리 단순해도 DOM 노드 수가 곧 비용이 돼요. Lighthouse 는 이걸 "Avoid an excessive DOM size" 감사로 체크해요. 공식 문서에 따르면 body 기준으로 800노드 근처에서 경고가 뜨고 1,400노드를 넘으면 실패 처리됩니다.

"A large DOM tree often includes many nodes that aren't visible when the user first loads the page." - Lighthouse docs

처음 화면을 띄운 시점에 사용자가 볼 필요도 없는 노드들이 잔뜩 실려 있다는 이야기예요. 눈에 안 보여도 브라우저는 그걸 전부 들고 있거든요.

Lighthouse 가 설명하는 부담은 크게 셋이에요. 첫째, 초기 로드 때 화면 밖 노드까지 전부 내려받아 파싱합니다. 둘째, 상호작용이 있을 때마다 레이아웃과 스타일을 전체 트리 위에서 다시 계산하죠. 셋째, document.querySelectorAll 같은 쿼리가 참조하는 노드가 많아지면서 메모리 압박도 커져요.

접근성 트리는 DOM 을 기반으로 만들어져요. 그래서 노드가 많다는 건 접근성 비용에도 영향을 준다는 뜻이고, 이 얘기는 뒤에서 다시 돌아옵니다.

보이는 것만 그리는 windowing

web.dev 는 이 아이디어를 windowing 이라고 부릅니다. 이름 그대로, 전체 리스트 위에 창을 하나 띄워놓고 그 창에 들어오는 만큼만 그리는 거예요.

"List virtualization, or 'windowing', is the concept of only rendering what is visible to the user." - web.dev

사용자에게 보이는 것만 그린다는 원칙 하나로 정리할 수 있죠. 나머지는 높이만 차지할 뿐 실제 DOM 에는 없어요.

동작은 단순합니다. 스크롤 위치로 지금 화면에 들어오는 인덱스 범위를 계산하고, 그 범위의 항목만 렌더합니다. 전체 리스트의 총 높이는 별도의 컨테이너에 height 로 주입해서, 스크롤바는 10,000행짜리처럼 보이게 만들어요. 실제로 그려지는 건 화면에 들어오는 20~30행 정도고요.

비어 보이는 순간을 줄이려고 뷰포트 바깥 N개를 미리 렌더하는 설정을 overscan 이라고 합니다. 스크롤이 빠를 때 빈 영역이 번쩍이는 걸 막아주는데, 너무 크게 잡으면 가상화의 의미가 흐려져요.

TanStack Virtual 은 headless 라이브러리라서 마크업을 직접 구성해요. 공식 문서도 이 점을 못 박고 있어요.

"It is not a component therefore does not ship with or render any markup or styles for you."

컴포넌트가 아니기 때문에 마크업이나 스타일을 대신 그려주지 않는다는 뜻이에요. 위치 계산만 알려줄 테니 레이아웃은 직접 짜라는 쪽에 가깝죠.

배치 방식은 보통 position: absolute + transform: translateY 로 잡고, 부모 컨테이너에 contain: strict 를 주면 브라우저가 바깥 영역 계산을 생략해서 한 번 더 절약할 수 있어요.

크기를 알 수 없을 때

가상화의 모든 수식은 "i번째 행의 높이가 얼마인가" 에서 출발합니다. 그래서 행 높이를 어떻게 알아내느냐에 따라 구현이 세 갈래로 갈려요.

1

고정 크기

모든 행이 같은 높이라 공식 하나면 끝납니다. estimateSize 가 상수를 돌려주면 돼요.

useVirtualizer({
  count,
  getScrollElement,
  estimateSize: () => 35,
});
2

사전에 아는 가변

행마다 높이는 다르지만 렌더 전에 이미 알고 있을 때. 데이터에 높이 필드가 붙어 오는 경우가 흔해요.

estimateSize: (index) => rows[index].height
3

동적, 미지수

행을 그려봐야 높이를 알 수 있는 상황. 일단 추정값으로 배치한 뒤 measureElement 로 실측하고 보정합니다.

<div
  ref={virtualizer.measureElement}
  data-index={item.index}
>
  {content}
</div>

세 번째 경우는 ref 가 붙은 엘리먼트가 실제 렌더되고 나면 실측값으로 내부 캐시를 갱신하고, 주변 항목들의 start 위치를 다시 계산합니다. data-index 가 있어야 가상화기가 어느 인덱스의 측정인지 알 수 있어요.

react-window 를 써본 분이라면 FixedSizeListVariableSizeList 가 이 축과 그대로 맞물린다는 걸 알아채셨을 거예요. 이름만 다를 뿐, 추상화의 결은 거의 같습니다.

접근성이 깨지는 지점

가상화가 기술적으로 재미있는 건 여기서부터예요. DOM 에 실제로 존재하는 항목이 줄어들면, 보조 기술이 "몇 번째 항목이고 전체는 몇 개인가" 를 잘못 계산하게 됩니다.

"When only a subset of items ... are loaded into the DOM, the browser calculates the number of items based only on those present." - MDN

브라우저는 자기가 실제로 들고 있는 노드만 세거든요. 10,000행짜리 리스트에서 20행만 렌더하면, 스크린리더는 "20개 중 5번째" 라고 읽어버립니다.

복구 방법은 몇 갈래가 있는데요, 각 항목에 aria-setsize 로 전체 개수를, aria-posinset 으로 1부터 시작하는 위치를 직접 박아주는 게 가장 직접적이에요. 이러면 보조 기술이 DOM 숫자 대신 이 값을 읽죠. 전체 개수를 모를 때는 aria-setsize-1 을 넣을 수 있습니다.

그리고 선택형 리스트라면 WAI-ARIA Authoring Practices 의 Listbox 패턴을 참고해 role="listbox"role="option" 을 얹고, 키보드 포커스는 aria-activedescendant 로 논리적으로만 이동시키죠. 실제 DOM 포커스를 옮기지 않으니까 스크롤로 사라진 항목에 포커스가 묶일 일도 없어요.

그 전에 가능하면 네이티브 <select> 를 먼저 고려하는 것도 잊지 마세요. ARIA 의 첫 번째 규칙이 "가능하면 네이티브 요소를 써라" 거든요. 드롭다운 정도의 규모면 굳이 가상화를 만들 이유가 없을 때가 많아요.

가상화가 바꿔놓는 작은 것들

성능과 접근성 말고도 가상화는 브라우저의 기본 동작 몇 가지를 조용히 깨뜨립니다.

Ctrl+F 페이지 내 찾기가 대표적이에요. 화면 밖 행은 DOM 에 없으니까 텍스트 검색이 걸리지 않습니다. 사용자가 "아까 봤던 그 이름" 을 찾으려다 못 찾고 당황하게 되죠. 검색이 필요한 리스트라면 별도의 검색 입력을 제공하는 수밖에 없어요.

구조 선택자도 신뢰하기 어려워집니다. :last-child, :nth-child(odd), CSS 카운터 전부 현재 DOM 에 있는 노드를 기준으로 돌아가거든요. 10,000행의 마지막이 화면에 없으면 :last-child 는 엉뚱한 행을 가리킵니다. 줄무늬 스타일은 인덱스 기반으로 직접 계산해서 className 을 붙이는 게 안전해요.

react-window 같은 라이브러리는 행 위치를 인라인 스타일로 주입합니다. 외부 CSS 에서 그 속성을 덮어쓰기가 까다롭고, 디자인 시스템과 충돌하기도 해요. 또 해시 링크로 특정 항목에 점프하거나, 브라우저가 뒤로 가기 시 스크롤 위치를 복원하려 해도 해당 노드가 없을 수 있어서 포커스 기억이 어긋납니다.

언제 쓸지 기준도 이 맥락에서 자연스럽게 잡혀요. 수천 행이 있고 스크롤이 핵심 상호작용일 때. 500~1,000행 수준이라면 DOM 이 조금 커지더라도 브라우저에게 맡기는 쪽이 총합으로 싸게 먹히는 경우가 많습니다.

가상화는 보이는 만큼만 그리는 절약 기술이에요. 그 대가로 브라우저가 원래 해주던 "전체가 여기 있다" 는 약속을 개발자가 대신 지켜야 합니다. aria-setsize 한 줄, overscan 의 숫자 하나, measureElement 가 붙은 ref 하나. 덜어낸 DOM 만큼을 이런 장치들로 다시 채워 넣는 작업이 결국 가상 리스트의 본체예요.

참고 자료

관련 글