JK.dev

가상 스크롤링 훝어보기

가상 스크롤링 훝어보기

0. 가상 스크롤링이란?

프론트엔드 개발자라면 가상 스크롤링(Virtual Scrolling)을 한 번쯤은 들어봤을 겁니다. 이는 리스트 데이터를 다루는 서비스에서 성능 최적화의 핵심 기법으로 자주 언급되는데요, “화면에 보이는 부분만 렌더링해서 성능을 최적화하는 기술”이라는 점은 널리 알려져 있습니다. 이 글에서는 가상 스크롤링의 기본 원리에 대해 자세히 살펴보겠습니다.

1. 왜 가상 스크롤링이 필요한가?

브라우저는 화면에 표시되는 요소가 많아질수록 렌더링 비용이 기하급수적으로 늘어납니다.

(*아래는 각 컴포넌트에서 무거운 연산 및 CPU 성능을 의도적으로 낮춰 테스트한 결과입니다.)

가상 스크롤 예시
가상 스크롤 성능 비교

1700ms ~ 3000ms 구간에서 CPU 점유가 거의 90%에 도달하고 프레임이 멈춘 구간들이 존재합니다.

이처럼 여러 DOM 노드가 한 번에 그려지면 브라우저가 입력 이벤트나 애니메이션을 적시에 처리하지 못하고 사용자는 스크롤이 “끊기거나 멈춘 것처럼” 체감하게 됩니다.

따라서 대규모 리스트를 다룰 때는 불필요한 DOM 생성을 줄이고, 실제로 보이는 영역만 렌더링하는 가상 스크롤링이 반드시 필요합니다.

2. 가상 스크롤링의 핵심 원리

가상 스크롤링의 핵심 아이디어는 “리스트 전체를 렌더링하지 말고, 화면에 보여야 할 아이템만 그린다.” 입니다.

즉, 브라우저가 실제로 관리하는 DOM 노드 수를 최소화하는 것이 목적입니다. 이를 위해 다음과 같은 원리가 적용됩니다.

2.1 뷰포트 기반 렌더링

수많은 리스트 아이템 중 실제로 화면(뷰포트)에 보이는 아이템만 DOM에 렌더링하는 방식입니다. 목표는 DOM 수 최소화 → 레이아웃·페인트·메모리 비용 감축 → 스크롤 jank 제거입니다.

뷰포트 기반 렌더링 1
  1. scrollOffset, 뷰포트 크기를 바탕으로 화면에 보여야 할 구간이 결정되고
  2. 아이템이 구간에 포함되면 마운트, 벗어나면 언마운트합니다.

2.2 위치 계산

각 아이템이 차지해야 할 위치를 계산합니다.

  1. 상태 수집
    1. 스크롤 위치(offset), 뷰포트 높이(viewport), 전체 아이템 개수(count), 아이템 크기(height)
  2. 가시 범위 산출: 뷰포트에 보이는 인덱스 구간을 계산
  3. 아이템 위치 결정: 각 아이템의 시작 위치에 배치
    1. transform: translateY(index * height)
  4. 컨테이너 높이만큼 확보
    1. 컨테이너의 높이 = height * count
  5. 재계산 트리거: 스크롤마다 위 과정을 재실행

2.2 동적 높이 처리

고정된 높이로만 구성된 리스트만 다룬다면 간단하겠지만 채팅 메시지, 사진 등은 콘텐츠 길이에 따라 높이가 달라집니다.

카카오톡

동적 높이는 아이템의 높이를 추정하고, 렌더링된 이후에 실제 측정값으로 변경하는 방식으로 처리됩니다.

아래에서 처리 단계를 살펴보겠습니다.

단계1. 추정으로 시작: 아이템별 (평균/보수치)로 추정

아이템 별로 높이가 다를 때, 임의의 높이로 가정하고 위치, 총 길이를 계산합니다.

ex) 아이템 높이를 50px로 추정했을 때

viewport2
index추정 높이시작 위치
050px0px
150px50px
250px100px

추정 높이는 1. 초기 렌더링시 추정치로 스크롤 영역 계산 2. 실제 렌더링되지 않은 아이템은 지연 측정하기 위해 사용됩니다.

단계2. 실측으로 보정

DOM이 그려지면 실제 높이를 읽고 측정값으로 갱신합니다.

viewport3
index실제 높이시작 위치
0500
18050
230130

단계3. 앵커 유지(점프 방지)

동적 높이 측정으로 뷰포트 위쪽의 높이가 바뀌면, 화면이 점프합니다.

따라서 앵커(사용자가 보고 있던 지점) 위쪽 합계가 Δ만큼 달라지면 scrollTop += Δ로 화면 좌표를 보존합니다.

앵커 유지

단계4. 가시 범위 계산

고정 높이라면 현재 스크롤에서 시작 인덱스를 찾기 위해 index = offset / height 를 쓰면 되지만,

가변 높이에서는 단순 계산이 불가능하고 직접 찾아야 합니다.

이때 단순 선형 탐색보다 속도가 빠른 이진 탐색으로 시작 인덱스를 빠르게 찾습니다.

단계5. 높이 측정 최소화

아이템 높이 읽기쓰기가 섞여 강제 레이아웃이 생기는 것을 막습니다.

왜 ‘강제 레이아웃’이 발생하나?

브라우저는 프레임 단위(약 16ms)로 레이아웃 계산을 합니다.

이는 다음의 단계로 이뤄지는데,

  1. JS 실행 → 2) 스타일 계산 → 3) 레이아웃(reflow) → 4) 페인트 → 5) 합성

브라우저는 성능을 위해 스타일 변경이 있더라도 다음 프레임 직전에 계산합니다.

그런데 JS가 그 직후에 레이아웃을 필요로 하는 ‘읽기’(예: getBoundingClientRect, offsetHeight, scrollTop, computedStyle.width 등)를 호출하면,

브라우저는 “최신 값을 돌려줘야 하니까” 레이아웃을 강제로 계산합니다.

const h = el.offsetHeight; // [읽기] 레이아웃 강제 발생
el.style.height = h + 10 + 'px'; // [쓰기] 스타일 변경 → 레이아웃 무효화
const w = el.offsetWidth; // [읽기] 다시 정확한 값 필요 → 또 레이아웃 강제

따라서, 읽기와 쓰기를 분리하고 requestAnimationFrame으로 프레임 단위로 동작을 수행합니다.

let h, w;

requestAnimationFrame(() => {
  // [읽기 단계] 모든 측정 먼저
  h = el.offsetHeight;
  w = el.offsetWidth;

  requestAnimationFrame(() => {
    // [쓰기 단계] DOM 업데이트만
    el.style.height = h + 10 + 'px';
    el.style.width = w + 20 + 'px';
  });
});

3. 정리하며

가상 스크롤은 단순히 “보이는 것만 그린다”에 그치지 않고, 동적 높이 처리, 앵커 유지, 강제 레이아웃 최소화 등 다양한 기술이 모여 완성되는 기법입니다.

단순히 라이브러리를 가져다 쓰기보다, 원리를 이해하고 상황에 맞게 적용·개선하며 사용자 경험을 주도적으로 만들어갈 수 있는 개발자가 되도록 지향해야 하지 않을까 싶습니다.

공유: