프론트엔드 개발을 하다 보면 언젠가는 마주치게 되는 과제가 있습니다. 무한스크롤로 계속 아이템을 누적해서 보여주다가 어느 순간 페이지가 버벅거리기 시작하거나, 대시보드에서 테이블로 많은 데이터를 스크롤로 표시해야 할 때 말이죠.
Instagram이나 X 같은 앱에서 피드를 아래로 계속 내리다 보면 수백 개의 게시물이 쌓이는데도 매끄럽게 동작하는 걸 본 적 있을 겁니다. 혹은 관리자 대시보드에서 수천 줄의 로그를 테이블로 보여주면서도 스크롤이 자연스럽게 동작하는 경험도 있을 거고요. 이런 상황에서 브라우저가 느려지지 않는 비밀이 바로 가상스크롤입니다.
React Virtualized, React Window, @tanstack/react-virtual, React Virtuoso 같은 라이브러리를 사용해보신 분들은 이미 가상스크롤의 효과를 경험해봤을 텐데요. 하지만 내부에서 정확히 어떤 일이 일어나는지 알고 계신가요?
문제는 커스터마이징이 필요하거나 직접 구현해야 할 때입니다. 기존 라이브러리로는 해결되지 않는 특별한 요구사항이 생겼을 때, 혹은 라이브러리 설정을 어떻게 조정해야 할지 판단이 서지 않을 때 막막해지죠. 현업에서 개발하다 보면 이런 상황을 피할 수 없습니다.
게다가 2024년에는 CSS만으로도 기본적인 가상스크롤 효과를 낼 수 있는 새로운 방법이 등장했습니다. 이 글에서는 가상스크롤의 동작 원리부터 최신 CSS 기법까지, 실무에서 바로 활용할 수 있는 내용들을 다뤄보겠습니다.
가상스크롤이 필요한 상황들을 구체적으로 살펴보면, 생각보다 우리가 자주 마주치는 경우들입니다.
대용량 데이터를 한번에 보여줘야 하는 경우
관리자 페이지에서 사용자 목록이나 주문 내역을 테이블로 보여줄 때를 생각해보세요. 처음에는 페이지네이션으로 나눠서 보여주려고 했지만, 기획자나 사용자가 "한 화면에서 다 보고 싶다"고 요청하는 경우가 있습니다. Excel처럼 스크롤로 쭉 내려가면서 데이터를 확인하고 싶어하죠. 이때 수백 개의 행을 그냥 렌더링하면 브라우저가 버벅거리기 시작합니다.
무한 스크롤에서 계속 쌓이는 검색 결과
쇼핑몰이나 검색 사이트에서 무한스크롤을 구현할 때도 마찬가지입니다. 처음 20개 상품을 보여주는 건 문제없지만, 사용자가 계속 스크롤을 내려서 200개, 500개가 쌓이면 상황이 달라집니다. 특히 각 상품 카드에 이미지가 여러 개 있거나 복잡한 UI가 포함되어 있다면 메모리 사용량이 급격히 증가하죠.
로그 화면처럼 엄청난 스크롤이 발생하는 상황
개발자 도구나 서버 모니터링 대시보드에서 로그를 실시간으로 보여주는 화면을 만들어본 적 있나요? 로그는 특성상 계속해서 새로운 줄이 추가되고, 사용자는 이전 로그를 확인하기 위해 위로 스크롤을 올리기도 합니다. 수천 줄의 로그가 DOM에 쌓이면 브라우저는 감당하기 어려워집니다.
실시간 업데이트되는 피드
채팅 애플리케이션이나 실시간 알림 피드도 비슷한 문제를 겪습니다. 새로운 메시지가 계속 추가되면서 기존 메시지들은 위로 밀려나지만, 모든 메시지를 DOM에 유지하면 메모리 사용량이 계속 늘어납니다. 특히 이미지나 파일이 포함된 메시지들이 많을 때는 더욱 심각해지죠.
모바일 환경에서의 성능 한계
데스크톱에서는 괜찮았던 성능이 모바일에서는 문제가 되는 경우도 많습니다. 모바일 브라우저는 메모리와 CPU 성능이 제한적이기 때문에, 같은 양의 데이터라도 훨씬 더 느리게 동작할 수 있습니다. 특히 저사양 안드로이드 기기에서는 몇백 개의 아이템만 렌더링해도 스크롤이 버벅거릴 수 있어요.
이런 상황들에서 가상스크롤은 단순히 '성능 최적화'를 넘어서 '서비스의 사용 가능성'을 결정하는 핵심 기술이 됩니다.
가상스크롤의 핵심 아이디어는 간단합니다. "화면에 보이는 것만 렌더링하자"는 것이죠. 하지만 이를 실제로 구현하려면 몇 가지 까다로운 문제들을 해결해야 합니다.
스크롤바 UX의 핵심
가장 먼저 해결해야 할 문제는 스크롤바입니다. 사용자가 스크롤바를 보고 "전체 길이"를 직감적으로 느낄 수 있어야 하죠. 1000개 아이템이 있다면 스크롤바도 그만큼 작아져야 하고, 스크롤 위치도 정확해야 합니다.
스크롤바 크기 = (화면에 보이는 아이템 수 / 전체 아이템 수) × 100%
스크롤 위치 = (현재 첫 번째 아이템 인덱스 / 전체 아이템 수) × 100%
이 문제를 해결하는 방법은 전체 컨테이너의 높이를 미리 계산해두는 것입니다:
전체 높이 = 아이템 개수 × 각 아이템의 예상 높이
그리고 실제 아이템들은 렌더링하지 않고, 대신 padding-top
과 padding-bottom
으로 스크롤 공간을 확보해두는 거죠. 이렇게 하면 네이티브 스크롤을 그대로 활용하면서도 자연스러운 스크롤 경험을 제공할 수 있습니다.
보이는 영역과 안 보이는 영역
가상스크롤에서는 전체 데이터를 세 영역으로 나눠서 생각합니다:
[ 위쪽 안보이는 영역 ] ← padding-top으로 공간 확보
[ 현재 보이는 영역 ] ← 실제 DOM에 렌더링
[ 아래쪽 안보이는 영역 ] ← padding-bottom으로 공간 확보
전체 1000개 아이템이 있고, 화면에는 5개씩 보인다고 가정해봅시다:
전체 데이터: [0, 1, 2, 3, ..., 999]
현재 스크롤 위치에서 보이는 부분만 슬라이싱:
items.slice(245, 250) = [245, 246, 247, 248, 249]
실제 렌더링:
padding-top: 245 × itemHeight ← 위쪽 244개 아이템의 공간
┌─────────────────────┐
│ 아이템 245 │ ← 실제 DOM 요소 (5개만)
│ 아이템 246 │
│ 아이템 247 │
│ 아이템 248 │
│ 아이템 249 │
└─────────────────────┘
padding-bottom: 750 × itemHeight ← 아래쪽 750개 아이템의 공간
레이아웃 변화 없는 교체
사용자가 스크롤을 내려서 아이템 245가 화면 위로 완전히 사라진 순간을 봅시다:
스크롤 진행 중 (아이템 245가 화면 위로 넘어감):
[아이템 245] ↑ (화면 밖으로)
┌─────────────────────┐ ← 화면 상단
│ 아이템 246 │
│ 아이템 247 │
│ 아이템 248 │
│ 아이템 249 │
└─────────────────────┘ ← 화면 하단
이 순간 데이터 슬라이싱과 패딩을 조정합니다:
교체 전:
items.slice(245, 250) = [245, 246, 247, 248, 249]
- 아이템 246이 배열의 1번째 인덱스 (두 번째 요소)
교체 후:
items.slice(246, 250) = [246, 247, 248, 249] ← 245 제거
- 아이템 246이 배열의 0번째 인덱스 (첫 번째 요소)로 이동
실제 화면에서는 이렇게 보입니다:
교체 후 (레이아웃상 변화 없음):
┌─────────────────────┐ ← 화면 상단
│ 아이템 246 │ ← 정확히 같은 위치 (인덱스 1→0으로 변경)
│ 아이템 247 │ ← 정확히 같은 위치 (인덱스 2→1로 변경)
│ 아이템 248 │ ← 정확히 같은 위치 (인덱스 3→2로 변경)
│ 아이템 249 │ ← 정확히 같은 위치 (인덱스 4→3로 변경)
└─────────────────────┘ ← 화면 하단
padding-top: 246 × itemHeight (1줄만큼 증가)
padding-bottom: 751 × itemHeight (1줄만큼 증가)
핵심은 렌더링되는 아이템들의 배열 인덱스는 모두 바뀌었지만, 화면에서는 레이아웃상 차이가 전혀 없다는 것입니다. 패딩 조정으로 정확히 같은 위치에 같은 내용이 표시되어 사용자는 자연스럽게 스크롤한 것처럼 느끼죠.
버퍼 영역으로 자연스러운 스크롤
스크롤이 빠르게 진행될 때를 대비해서 보이는 영역보다 조금 더 많이 렌더링해두는 것이 좋습니다:
실제 구현에서는 버퍼를 추가:
[ 위쪽 버퍼 (2개) ] ← 미리 렌더링
[ 화면에 보이는 영역 (5개) ]
[ 아래쪽 버퍼 (2개) ] ← 미리 렌더링
총 9개 DOM 요소로 빠른 스크롤에도 자연스럽게 대응
예를 들어 실제로는 243번부터 252번까지 렌더링해두지만, 화면에는 245-249만 보이게 하는 방식입니다. 이렇게 하면 스크롤이 빨라져도 깜빡임 없이 부드러운 경험을 제공할 수 있습니다.
이렇게 구현하면 1000개든 10000개든 상관없이 항상 일정한 DOM 요소 개수를 유지하면서도 자연스러운 스크롤 경험을 제공할 수 있습니다. 사용자는 마치 모든 요소가 다 렌더링되어 있는 것처럼 느끼지만, 실제로는 필요한 부분만 그려지고 있는 거죠.
지금까지 가상스크롤의 복잡한 구현 원리를 살펴봤습니다. 데이터 슬라이싱, 패딩 조정, 인덱스 관리까지... 꽤 많은 로직이 필요하죠. 하지만 2024년부터는 훨씬 간단한 방법이 등장했습니다.
브라우저 렌더링의 원리
먼저 브라우저 렌더링 과정을 간단히 살펴봅시다. 우리가 HTML 요소를 만들면 브라우저는 다음 과정을 거칩니다:
HTML 파싱 → 렌더트리 생성 → 레이아웃 계산 → 페인팅 → 컴포지팅
여기서 중요한 건 화면 밖에 있는 요소들도 레이아웃 계산에 포함된다는 점입니다. 1000개 리스트 아이템이 있다면 화면에 5개만 보여도 나머지 995개의 위치와 크기까지 모두 계산하고 있었던 거죠.
접근 방식의 차이
기존 가상스크롤과 CSS content-visibility
의 가장 큰 차이는 관리 주체입니다.
이제 컨테이너가 복잡한 계산을 할 필요 없이, 각 아이템이 알아서 자신의 렌더링 여부를 결정하는 거죠.
content-visibility가 무엇인가?
CSS content-visibility
는 요소의 콘텐츠를 언제 렌더링할지 브라우저에게 알려주는 속성입니다. display: none
과 비슷하게 요소를 숨길 수 있지만, 중요한 차이점이 있습니다.
content-visibility
의 세 가지 값:
visible
: 기본값, 평소와 같이 렌더링hidden
: 콘텐츠를 완전히 숨김 (display: none과 유사)auto
: 화면에 보이지 않으면 렌더링을 최대한 지연우리가 관심 있는 건 auto
값입니다. 이 값을 사용하면 요소가 뷰포트에 가까워질 때까지 레이아웃과 페인팅 작업을 건너뛸 수 있습니다.
딱 하나만 추가하면 되는 간편함
.list-item {
content-visibility: auto;
contain-intrinsic-size: auto 200px;
}
이게 전부입니다. 복잡한 JavaScript 로직도, 데이터 슬라이싱도, 패딩 계산도 필요 없어요.
contain-intrinsic-size와 함께 사용하는 이유
content-visibility: auto
만 사용하면 한 가지 문제가 생깁니다. 렌더링하지 않는 요소들의 크기를 브라우저가 알 수 없어서 스크롤바가 이상하게 동작할 수 있거든요.
contain-intrinsic-size
는 이 문제를 해결합니다:
contain-intrinsic-size: auto 200px;
이 속성은 "렌더링하지 않는 요소의 예상 크기는 200px이고, 한 번이라도 렌더링된 적이 있으면 그때의 실제 크기를 기억해서 사용해"라고 브라우저에게 알려줍니다. 이렇게 하면 스크롤바도 자연스럽게 동작하고, 레이아웃 시프트도 방지할 수 있죠.
라이브러리 vs CSS 해결책
물론 React Virtualized 같은 라이브러리가 여전히 더 강력합니다. 정교한 버퍼링, 동적 높이 처리, 복잡한 스크롤 동작까지 세밀하게 제어할 수 있거든요. 하지만 content-visibility
는 다른 장점들이 있습니다:
라이브러리 도입이 어려운 상황의 대안
팀에서 새로운 라이브러리 도입을 꺼리거나, 기존 코드베이스를 크게 변경하기 어려운 상황이라면 content-visibility
부터 시작해보는 것도 좋은 선택입니다. 당장 성능 개선 효과를 볼 수 있고, 나중에 필요하다면 본격적인 가상스크롤 라이브러리로 교체할 수도 있거든요.
<!-- 기존 코드에 CSS만 추가하면 됨 -->
<div class="item-list">
<div class="list-item">아이템 1</div>
<div class="list-item">아이템 2</div>
<!-- ... 수백 개 아이템 -->
</div>
.list-item {
content-visibility: auto;
contain-intrinsic-size: auto 100px;
}
이렇게 하면 기존 HTML 구조는 전혀 건드리지 않고도 성능 개선을 경험할 수 있습니다. CSS 한 줄로 가상스크롤의 기본적인 효과를 낼 수 있는 셈이죠.
가상스크롤의 핵심은 결국 "화면에 안 보이는 것은 그리지 않는다"는 단순하면서도 강력한 아이디어입니다.
이 개념은 웹 개발에만 국한되지 않습니다. 게임에서는 플레이어의 시야 범위 밖에 있는 오브젝트들을 렌더링하지 않고, DB 다이어그램 툴에서는 현재 화면에 보이는 다이어그램만 선택적으로 그립니다. 볼 필요가 없으면 렌더링 안 하는 게 당연하고, 그게 성능 최적화의 기본이죠.
상황에 맞는 선택
현업에서는 상황에 따라 적절한 방법을 선택하면 됩니다:
JavaScript 가상스크롤 라이브러리: 복잡한 요구사항이나 최대 성능이 필요할 때
CSS content-visibility: 간단한 성능 개선이나 기존 코드 변경을 최소화하고 싶을 때
두 방식 모두 "필요한 것만 렌더링한다"는 같은 철학을 공유하지만, 접근 방법이 다릅니다. 컨테이너가 중앙에서 관리하느냐, 개별 요소가 스스로 판단하느냐의 차이죠.
가상스크롤의 원리를 이해하는 것은 단순히 성능 최적화 기법 하나를 배우는 게 아닙니다. 브라우저 렌더링 과정, DOM 조작의 비용, 데이터와 뷰의 분리 같은 프론트엔드 개발의 핵심 개념들을 자연스럽게 익힐 수 있습니다. 무엇보다 라이브러리를 단순히 가져다 쓰는 것과, 내부 동작을 이해하고 필요에 따라 커스터마이징할 수 있는 것은 완전히 다른 수준의 개발 역량이니까요.
안녕하세요 최근 사이드로 멘토링을 받고 있습니다.많관부
신청란: https://fe-resume.coach?utm_source=velog&utm_medium=blog&utm_campaign=resume_tool
게임개발에서 Occlusion Culling 기법과 닮아있는 프론트엔드 기법이네요잉