기존 서비스 중 소홀히 대접을 받던 랭킹 페이지를 개편하다 문제를 만났습니다. 순위표는 채널에 따라 수백 명, 많게는 약 1,000명가량의 행을 한 번에 그립니다. 화면에는 십수 개만 보이지만, 브라우저는 스크롤을 시작하기도 전에 보이지 않는 행까지 전부의 위치·크기와 픽셀을 계산합니다. 첫 렌더에서 이미 비용을 다 치르는 셈입니다.
가장 먼저 떠오른 답은 무한 스크롤이었습니다. 약 1,000행을 한 번에 내려주지 말고 필요한 만큼만 잘라서 받는 방식이죠. 그런데 두 가지가 걸렸습니다. 하나는, 무한 스크롤은 데이터를 나눠 받는 기법이지 그리는 비용을 직접 줄이는 기법이 아니라는 점입니다. 스크롤해서 행이 쌓이면 결국 다시 전부 그리게 되니까요. 다른 하나는 현실적인 문제로, 이 방식은 백엔드에 페이지네이션 API가 필요한데 그 우선순위가 낮아 언제 반영될지 미지수였습니다. 그래서 우선 프론트엔드 단에서, 그리는 비용 자체를 줄일 방법부터 찾아보기로 했습니다.
그렇게 그리는 양을 줄이는 프론트 기법을 조사했습니다. 프론트엔드 스터디에서 발견한 List Virtualization이 유력해 보였고, 마침 프로젝트에 이미 react-window가 설치되어 있었습니다. 그런데 가상화는 이 페이지에 생각보다 좋은 선택이 아니었습니다.
- 화면 밖 행을 DOM에서 아예 제거하기 때문에 Ctrl+F 검색이 되지 않고
- 접근성 트리와 SEO 대상에서 사라지며
- 페이지 스크롤을 내부 스크롤 컨테이너로 대체하면서 뒤로가기 시 스크롤 복원이 깨집니다
물론 하나씩 해결할 수는 있습니다. 하지만 문제 하나를 풀자고 세 개의 문제를 새로 떠안는 셈이라 고민이 됐습니다. 그러던 중 훨씬 가벼운 선택지를 발견했습니다.
이 글은 CSS 속성 두 줄(content-visibility, contain-intrinsic-size)만으로, 백엔드 요청 없이, 검색과 접근성을 지키면서 렌더링·페인팅 비용을 72% 줄인 기록입니다.
1. 렌더 비용은 어디서 발생하는가
최적화에 앞서 알아야 할 전제가 하나 있었습니다. 리스트의 렌더 비용은 두 개의 서로 다른 층에서 발생합니다.
[React 층] [브라우저 렌더 층]
컴포넌트 함수 실행 Style (Recalculation)
→ Virtual DOM diff → Layout (위치·크기)
→ Reconciliation → Paint (픽셀화)
→ DOM commit ──────► → Composite
두 층은 순차적으로 연결되어 있습니다. React가 DOM을 commit하면 그것이 브라우저의 Style/Layout을 유발합니다.
중요한 건 기법마다 개입하는 지점이 다르다는 점입니다.
| 기법 | 개입 지점 | 줄이는 비용 |
| React.memo | React 층 | 컴포넌트 실행 + 재조정 |
| content-visibility | 브라우저 층 | Style + Layout + Paint |
| 리스트 가상화 | 양쪽 모두 | React + DOM 노드 + Layout/Paint |
여기서 판단의 근거가 나옵니다. 가상화는 양쪽을 다 줄이지만, 그 대가로 DOM 노드 자체를 없앱니다. 앞서 언급한 검색·접근성·스크롤 복원 문제는 전부 이 대가에서 파생된 것입니다.
우리 페이지의 병목은 브라우저 렌더 층의 Layout/Paint였지, DOM 노드 수 자체가 아니었습니다. 그렇다면 브라우저 렌더 층만 겨냥하는 도구로 충분하다는 뜻입니다.
2. content-visibility: auto는 무엇을 하는가
content-visibility: auto가 적용된 요소가 뷰포트 밖에 있으면, 브라우저는 그 내부 자식들의 Style·Layout·Paint를 전부 스킵합니다. DOM 노드는 그대로 둔 채, 렌더링 작업만 건너뜁니다.
여기서 오해하기 쉬운 지점이 있었습니다. 요소 자체(wrapper)의 위치는 여전히 계산됩니다. 그래야 브라우저가 "이 행이 뷰포트에 들어왔는지"를 판정할 수 있기 때문입니다. 스킵되는 것은 그 안쪽 subtree입니다.
<div class="row" style="content-visibility: auto">
<span>1</span> <!-- ┐ -->
<div class="rank-change">▲</div> <!-- │ 이 안쪽 -->
<img src="..." /> <!-- │ 전부가 -->
<span>홍길동</span> <!-- │ subtree -->
<span>4.532</span> <!-- ┘ -->
</div>
언제 다시 그려지는가
브라우저는 요소가 뷰포트에 정확히 걸치는 순간에 렌더를 시작하지 않습니다. 그러면 스크롤할 때 빈 칸이 보이기 때문입니다. 대신 뷰포트 주변에 여유 마진을 두고 미리 그려둡니다. 덕분에 사용자에게는 스크롤이 자연스럽게 이어져 보입니다.
뷰포트 근접 외에도 다음 조건에서 해당 요소는 "사용자에게 관련 있는(relevant)" 것으로 간주되어 즉시 렌더됩니다.
- Ctrl+F 페이지 내 검색에 걸렸을 때
- 내부 요소에 포커스가 있을 때
- 내부 텍스트가 선택되었을 때
- top layer(<dialog>, Popover API)에 있을 때
- View Transition에 포함될 때
hidden 값은 이 자동 복귀가 없어 사실상 display: none에 가깝게 동작합니다. 긴 리스트에는 반드시 auto를 써야 하는 이유가 여기 있습니다. auto이기 때문에 Ctrl+F 검색이 그대로 살아 있고, 이것이 가상화 대비 가장 큰 이점입니다.
접근성은 어떻게 되는가
Chromium 90부터 content-visibility: auto의 화면 밖 콘텐츠는 DOM과 접근성 트리에 그대로 남습니다. 가상화와 결정적으로 갈리는 지점입니다. 주의할 점이 하나 있습니다. 스킵된 subtree는 스타일이 계산되지 않기 때문에, 그 안에서 display: none이나 visibility: hidden으로 숨긴 요소가 접근성 트리에 노출될 수 있습니다. 의도적으로 숨겨야 하는 요소에는 aria-hidden="true"를 함께 붙이는 편이 안전합니다.
3. contain-intrinsic-size: auto
content-visibility만 단독으로 쓰면 문제가 생깁니다. 브라우저가 각 행의 높이를 알 수 없으니 전체 문서 높이를 잘못 잡고, 스크롤바도 그만큼 어긋나 버립니다.
contain-intrinsic-size는 이때 브라우저가 레이아웃 계산에 사용할 대체 크기를 지정합니다. 아직 그려지지 않은 요소에 미리 크기를 부여해, 실제로 렌더될 때 화면이 밀리는 레이아웃 시프트를 막아줍니다.
contain-intrinsic-size: auto 60px; /* 60px: 실제 행 높이 */
두 개의 값은 각각 이렇게 동작합니다.
- auto: 한 번이라도 실제로 렌더된 적이 있으면, 그때 측정된 크기를 기억해 두었다가 이후 화면 밖으로 나갔을 때 그 값을 사용합니다.
- 60px (길이값): 아직 한 번도 렌더된 적이 없을 때 쓰이는 초기 추정치입니다.
이 값이 실제 행 높이와 크게 어긋나면 스크롤 중 스크롤바가 튀거나 위치가 흔들립니다. 행 높이가 고정된 테이블이라면 정확한 값을 넣는 것으로 충분하지만, 높이가 가변인 리스트라면 이 값 조정에 시간을 좀 써야 합니다.
4. 실제 측정
같은 페이지에서 URL 쿼리 플래그로 content-visibility 적용 여부만 토글 하고, Chrome DevTools Performance 패널로 초기 로드를 기록했습니다.
- 대상: 랭킹 페이지, 1,000행
- 도구: Chrome DevTools Performance (Summary 카테고리 기준)
- 조건: 동일 URL, 동일 데이터, CSS 플래그만 변경
결과
| 항목 | 적용 전 | 적용 후 | 변화 |
| Rendering (Style + Layout) | 821 ms | 266 ms | −67.6% |
| Painting | 529 ms | 113 ms | −78.6% |
| Rendering + Painting | 1,350 ms | 379 ms | −71.9% |
| Scripting | 700 ms | 671 ms | −4.1% |
| System | 158 ms | 483 ms | +205.7% |
| 작업 시간 합계 | 2,217 ms | 1,542 ms | −30.4% |
| LCP | 1.73 s | 1.64 s | −5.2% |
| CLS | 0.04 | 0.04 | 변화 없음 |
표에서 읽은 것들
Rendering이 821ms → 266ms, Painting이 529ms → 113ms로 줄었습니다. 합쳐서 971ms, 약 72%가 빠졌습니다.
1장에서 얘기한 대로 Layout이랑 Paint가 문제였던 게 맞았습니다.
자세히 보면, Scripting이 700ms → 671ms로 거의 그대로였습니다. content-visibility가 브라우저 렌더 쪽만 건드리고 React 실행은 안 건드린다는 것으로 이해했습니다. 앞에서 둘이 개입하는 지점이 다르다고 했던 게 여기서 보입니다. 대신 이건 한계이기도 합니다. 컴포넌트 실행 자체가 무거운 페이지라면 이걸로는 줄이지 못하기 때문입니다.
당연히 공짜는 아니었습니다. System 시간이 158ms에서 483ms로 오히려 늘었습니다.
content-visibility는 화면 밖 요소가 뷰포트에 가까워졌는지를 브라우저가 계속 판정해야 하는데, 그 비용이 여기 잡힌 것으로 보입니다.
다만 이건 동작 원리에 근거한 추정이고, 실제로 어떤 이벤트가 늘었는지는 프로파일을 더 확인해봐야 확실합니다(이 부분은 다음 숙제로 남겨둡니다.). 어쨌든 이것 때문에 전체 시간으로 보면 72%가 아니라 30% 줄어든 것으로 볼 수 있습니다. Rendering·Painting에서 971ms 아끼고 System에서 325ms를 도로 쓴 셈입니다.
LCP는 1.73초에서 1.64초, 5%밖에 안 줄었습니다. 이 페이지 LCP는 리스트 렌더가 아니라 네트워크 요청에 걸려 있어서 그렇습니다. 이번에 줄인 건 로딩 속도가 아니라 메인스레드 작업량이었고요. 로딩을 빠르게 하려면 네트워크를 따로 봐야 합니다.


비용은 사라진 게 아니라 미뤄진 것
첫 화면이 빨라진 대신 스크롤을 내려 각 행이 뷰포트에 가까워지는 순간 그 행의 subtree가 그제야 렌더링 됩니다.
전체 총량이 확 줄었다기보다 사용자가 실제로 보는 만큼만 나눠서 내는 구조로 바뀌었습니다. 그래도 랭킹 페이지처럼 대부분 상위 몇십 행만 보고 나가는 화면이라면 그 나머지 비용은 아예 안 치르게 됩니다.
마치며
정리하면 이렇습니다.
- 병목이 어느 층에 있는지 먼저 확인했습니다. 우리 경우는 브라우저 렌더 층이었습니다.
- 그래서 양쪽을 다 건드리는 가상화 대신, 브라우저 렌더 층만 겨냥하는 CSS 두 줄을 선택했습니다.
- 결과적으로 백엔드 API 요청 없이, Ctrl+F 검색과 접근성 트리를 그대로 유지한 채 렌더링·페인팅 비용을 72% 줄였습니다.
가상화가 틀렸다는 이야기는 아닙니다. 행이 수만 단위로 늘거나 React 실행 자체가 병목이 되면 그때는 가상화나 무한 스크롤이 맞는 답입니다. 우리 랭킹 페이지도 행이 계속 늘어날 테니 언젠가는 그 방향으로 가게 될 거라고 생각합니다.
이번에 얻은 건 특정 기술 하나라기보다 상황에 맞는 선택지를 고르는 연습에 가까웠습니다.
긴 리스트라고 반사적으로 가상화부터 꺼내는 대신, 지금 내 문제의 병목이 어디에 있는지 먼저 재보고 그 지점에 딱 맞는 가장 가벼운 도구를 고르는 것. 같은 긴 리스트여도 1,000행과 수만 행에서 정답이 갈린다는 걸 직접 재보면서 알게 됐고 이 경험으로 문제를 보는 시야가 하나 넓어졌습니다. 늘 쓰던 방법을 꺼내기 전에 이런 선택지도 있다는 걸 경험할 수 있어서 즐거운 시간이었습니다.
참고 자료:
https://developer.mozilla.org/ko/docs/Web/CSS/Reference/Properties/content-visibility
https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Properties/contain-intrinsic-size
'Front-End' 카테고리의 다른 글
| 웹 아키텍처 진화의 본질과 역사적 흐름 (2) | 2026.06.08 |
|---|---|
| Frontend Fundamental 모의고사 2회차 회고 (0) | 2026.03.28 |
| 메인스레드 쉬는 시간 압수하기 (0) | 2026.03.08 |
| First Load JS 축소 및 번들최적화 - 시즌 2 (2) | 2026.01.22 |
| First Load JS 축소 및 번들 최적화 - 시즌 1 (2) | 2025.11.18 |
