항목이 수천 개인 목록을 그리면 느려진다. 그런데 "느리다"의 원인은 하나가 아니다. DOM 노드 생성 비용, 스타일 계산과 레이아웃 비용, 페인트 비용, 그리고 메모리. 어떤 비용이 지배적인지에 따라 해법이 달라진다.
content-visibility: auto는 화면 밖 요소의 렌더링 작업을 건너뛴다. 가상 스크롤은 화면 밖 요소를 DOM에서 아예 제거한다. 전자는 적용이 쉽고 후자는 효과가 크다.
나는 두 기법을 한동안 같은 것으로 취급했다. 실제로 측정해 보고 나서야 차이를 알았다. content-visibility를 적용해도 DOM 노드는 그대로 있어서 메모리는 줄지 않았고, 노드 수가 수만 개를 넘자 스타일 재계산에서 다시 느려졌다. 반대로 항목이 수백 개 수준이었다면 CSS 한 줄로 충분했을 문제에 가상 스크롤을 넣어 코드를 복잡하게 만든 적도 있다.
무엇이 비싼지 먼저 구분하기
| 비용 | 언제 문제가 되나 | 효과적인 해법 |
|---|---|---|
| DOM 노드 생성 | 초기 렌더에서 수천 개를 한 번에 | 가상 스크롤, 점진 렌더 |
| 스타일 계산·레이아웃 | 노드가 많고 복잡한 선택자 | content-visibility, contain, 선택자 단순화 |
| 페인트·합성 | 큰 이미지, 그림자·필터 다수 | 이미지 지연 로딩, 효과 축소 |
| 메모리 | 노드 수만 개 이상, 모바일 | 가상 스크롤 |
| 스크롤 중 끊김 | 스크롤 이벤트 핸들러가 무거움 | 패시브 리스너, 작업 분할 |
content-visibility — CSS 한 줄로 얻는 것
.list-item {
content-visibility: auto;
contain-intrinsic-size: auto 120px; /* 렌더 전 예상 높이 */
}
/* contain-intrinsic-size 를 빼면 스크롤바 길이가 요동친다.
auto 키워드를 쓰면 한 번 렌더된 뒤 실제 크기를 기억한다. */
동작 원리는 단순하다. 화면에서 충분히 멀리 있는 요소의 스타일 계산·레이아웃·페인트를 건너뛴다. 스크롤해서 가까워지면 그때 렌더한다. DOM에는 그대로 있으므로 검색(Ctrl+F), 접근성 트리, 앵커 링크가 대체로 유지된다는 것이 가장 큰 장점이다.
- CSS 한 줄 — 기존 코드 변경 최소
- 브라우저 내 검색이 동작
- 접근성 도구가 항목을 인식
- 스크롤 위치 복원이 자연스럽다
- DOM 노드는 그대로 — 메모리 절감 없음
- 수만 개 노드에서는 여전히 느림
- contain-intrinsic-size를 잘못 주면 스크롤이 튄다
- 컨테인먼트로 인한 레이아웃 차이 주의
가상 스크롤 — 큰 효과, 큰 책임
가상 스크롤은 보이는 범위의 항목만 DOM에 유지하고 나머지는 제거한다. 수만~수십만 항목도 다룰 수 있지만, 직접 구현하면 챙길 것이 많다.
// 가상 스크롤의 핵심 계산 (고정 높이 기준)
const ITEM_H = 120;
const OVERSCAN = 5; // 위아래 여유분
function visibleRange(scrollTop, viewportH, total) {
const start = Math.max(0, Math.floor(scrollTop / ITEM_H) - OVERSCAN);
const count = Math.ceil(viewportH / ITEM_H) + OVERSCAN * 2;
const end = Math.min(total, start + count);
return { start, end };
}
// 전체 높이를 유지해 스크롤바 길이를 맞춘다
// <div style="height: {total * ITEM_H}px; position: relative">
// <div style="transform: translateY({start * ITEM_H}px)">
// ...보이는 항목만...
// </div>
// </div>
가변 높이를 다루려면 항목별 실제 높이를 측정해 누적 오프셋을 관리해야 한다. 여기서 복잡도가 급증하므로, 직접 구현보다 검증된 라이브러리를 쓰는 편이 대개 낫다.
선택 기준
| 항목 수 | 권장 | 이유 |
|---|---|---|
| ~200 | 아무것도 안 해도 됨 | 최적화 비용이 이득보다 크다 |
| 200~2,000 | content-visibility | CSS 한 줄로 충분한 경우가 많다 |
| 2,000~10,000 | content-visibility + 페이지네이션/무한 스크롤 | 노드 수 자체를 줄인다 |
| 10,000 이상 | 가상 스크롤 | DOM 노드 수가 근본 문제 |
현실적으로는 페이지네이션이나 점진 로딩이 가장 자주 정답이다. 사용자가 수만 개 항목을 스크롤해서 보는 경우는 드물다. 대개는 검색과 필터가 필요한 것이지, 전부 그려 주는 게 필요한 게 아니다.
함께 적용할 작은 개선들
- 이미지 지연 로딩 —
loading="lazy"와decoding="async". 목록에서 효과가 크다. - 크기 지정 — 이미지에 width·height를 주면 레이아웃 이동(CLS)이 줄고 재계산이 준다.
- 선택자 단순화 — 깊은 자손 선택자와 복잡한 :has()는 노드가 많을수록 비싸다.
- CSS containment — 항목 단위로
contain: layout style을 주면 영향 범위가 격리된다. - 이벤트 위임 — 항목마다 리스너를 붙이지 말고 컨테이너에서 한 번만.
- 애니메이션 제한 — 목록 항목의 전환 효과는 스크롤 중 프레임을 잡아먹는다.
/* 항목 단위 격리 + 지연 렌더 */
.list-item {
contain: layout style; /* 레이아웃·스타일 영향 격리 */
content-visibility: auto;
contain-intrinsic-size: auto 120px;
}
/* 이미지 */
.list-item img {
width: 96px; height: 64px; /* 명시적 크기 */
object-fit: cover;
}
INP 관점에서 한 가지
목록이 길면 스크롤보다 상호작용 응답이 더 문제가 되는 경우가 있다. 항목을 클릭했을 때 상태가 바뀌며 전체 목록이 리렌더되면, 입력 지연이 길어진다. 상태를 항목 단위로 격리하고, 큰 리렌더는 startTransition 같은 수단으로 우선순위를 낮추는 것이 효과적이다.
자주 묻는 질문
content-visibility와 가상 스크롤의 차이는 무엇인가요?
content-visibility는 화면 밖 요소의 렌더링 작업을 건너뛰지만 DOM에는 그대로 남깁니다. 가상 스크롤은 화면 밖 요소를 DOM에서 제거합니다. 전자는 적용이 쉽고 검색·접근성이 유지되며, 후자는 효과가 크지만 구현 복잡도가 높습니다.
contain-intrinsic-size는 왜 필요한가요?
렌더되지 않은 요소의 크기를 브라우저가 알 수 없어 스크롤바 길이가 부정확해지기 때문입니다. 예상 높이를 지정하면 스크롤 동작이 안정되며, auto 키워드를 함께 쓰면 한 번 렌더된 뒤 실제 크기를 기억합니다.
항목이 몇 개부터 최적화가 필요한가요?
수백 개 수준에서는 대개 필요 없습니다. 수천 개부터 content-visibility가 효과를 보이고, 수만 개를 넘어가면 DOM 노드 수 자체가 문제가 되어 가상 스크롤을 검토하게 됩니다. 다만 먼저 성능 패널로 어떤 비용이 지배적인지 확인하세요.
가상 스크롤을 쓰면 브라우저 검색이 안 되나요?
화면 밖 항목이 DOM에 없으므로 Ctrl+F로 찾을 수 없습니다. 이것이 가상 스크롤의 대표적인 단점이며, 자체 검색 기능을 제공하거나 검색 시 해당 항목으로 스크롤하는 동작을 별도로 구현해 보완해야 합니다.
접근성은 어떻게 챙기나요?
가상 스크롤에서는 전체 항목 수와 현재 위치를 aria 속성으로 알려야 스크린 리더 사용자가 맥락을 잃지 않습니다. content-visibility는 접근성 트리를 대체로 유지하므로 이 부담이 작습니다.
목록이 느린데 원인을 어떻게 찾나요?
개발자 도구 성능 패널에서 스타일 재계산, 레이아웃, 페인트의 비중을 확인하세요. 스타일·레이아웃이 지배적이면 content-visibility와 containment가, 노드 생성과 메모리가 문제면 가상 스크롤이나 페이지네이션이 적합합니다.

댓글 0