가상 리스트

Virtual list

스크롤 위치를 보고 지금 화면에 보이는 몇 개(+여유 버퍼)만 실제 DOM에 그리는 긴 목록 기법.

다른 이름: Windowed listVirtualized listWindowing
···
html
<div class="vlw">
  <div class="vlmeta">렌더된 행: <b id="vlcount">0</b> / 10,000</div>
  <div class="vlvp" id="vlvp">
    <div class="vlsp" id="vlsp"><div class="vlrows" id="vlrows"></div></div>
  </div>
</div>
css
.vlw{position:absolute;inset:12px;display:flex;flex-direction:column;gap:5px}
.vlmeta{flex:none;font-size:10px;color:var(--muted)}
.vlmeta b{color:var(--accent);font-variant-numeric:tabular-nums}
.vlvp{flex:1;min-height:0;overflow-y:auto;border:1px solid var(--line);border-radius:8px;background:var(--surface)}
.vlsp{position:relative}
.vlrows{position:absolute;left:0;right:0;top:0}
.vlrow{height:24px;display:flex;align-items:center;padding:0 9px;border-bottom:1px solid var(--line);font-size:10px;color:var(--fg)}
.vlrow:nth-child(even){background:color-mix(in srgb, var(--line) 35%, transparent)}
js
const ROWH = 24, COUNT = 10000, BUFFER = 3;
const vp = document.getElementById('vlvp'), sp = document.getElementById('vlsp'), rows = document.getElementById('vlrows'), countEl = document.getElementById('vlcount');
sp.style.height = (COUNT * ROWH) + 'px';
function render() {
  const top = vp.scrollTop, viewH = vp.clientHeight || 140;
  const start = Math.max(0, Math.floor(top / ROWH) - BUFFER);
  const end = Math.min(COUNT, Math.ceil((top + viewH) / ROWH) + BUFFER);
  rows.style.transform = 'translateY(' + (start * ROWH) + 'px)';
  let html = '';
  for (let i = start; i < end; i++) html += '<div class="vlrow">#' + (i + 1) + ' — row item</div>';
  rows.innerHTML = html;
  countEl.textContent = String(end - start);
}
vp.addEventListener('scroll', render);
render();
let dir = 1;
setInterval(() => {
  const max = sp.clientHeight - vp.clientHeight;
  vp.scrollTop = Math.max(0, Math.min(max, vp.scrollTop + dir * 260));
  if (vp.scrollTop <= 0 || vp.scrollTop >= max) dir *= -1;
}, 500);

행이 수천~수만 개인 목록을 전부 DOM에 그리면 브라우저가 버벅입니다. 가상 리스트는 스크롤 위치를 보고 지금 화면에 보이는 몇 개(+여유분 버퍼)만 실제 DOM 요소로 그리고, 나머지는 존재하지 않는 것처럼 취급합니다. 전체 목록의 스크롤 길이를 흉내 내려고 보이지 않는 "자리표시자" 컨테이너를 실제 전체 높이(행 수 × 행 높이)로 만들어 두고, 그 안에서 보이는 행들만 translateY로 제자리에 배치합니다.

아래 데모의 카운터가 스크롤해도 10~20 근처에 머무는 게 핵심입니다 — 목록 데이터는 10,000행이지만 DOM에는 항상 화면에 들어오는 만큼만 존재합니다. 페이지네이션과 비교하면, 페이지네이션은 사용자에게 몇 번째 "페이지"인지 명시적으로 보여주고 클릭으로 이동하지만, 가상 리스트는 페이지 구분 없이 하나로 이어진 목록처럼 매끄럽게 스크롤되면서 내부적으로만 행을 교체합니다.

행 높이가 제각각이면(가변 높이) 구현이 훨씬 까다로워집니다 — 스크롤 위치만으로 몇 번째 행인지 계산할 수 없어 각 행의 실측 높이를 캐싱하는 별도 로직이 필요합니다. 스크린리더 사용자를 위해서는 컨테이너에 aria-setsize/aria-posinset처럼 전체 개수와 지금 위치를 알리는 처리를 더하는 게 좋습니다.