- Published on
CSS contain·content-visibility로 렌더링 튜닝
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버 튜닝이든 DB 인덱스 튜닝이든, 핵심은 “비용이 큰 일을 덜 하게 만드는 것”입니다. 프론트엔드에서도 마찬가지로, 렌더링 파이프라인(스타일 계산, 레이아웃, 페인트, 컴포지팅)에서 불필요한 작업 범위를 줄이는 것이 가장 확실한 성능 개선입니다.
이 글에서는 CSS의 contain과 content-visibility를 사용해 렌더링 범위를 격리하고, 화면 밖 콘텐츠는 아예 렌더링을 미루는 방식으로 스크롤 성능과 인터랙션 응답성을 끌어올리는 방법을 다룹니다. 특히 카드 리스트, 댓글, 문서 뷰어처럼 “DOM은 크고 반복 구조가 많은 화면”에서 효과가 큽니다.
또한 성능 이슈는 원인 파악과 재현이 절반이므로, DevTools에서 무엇을 봐야 하는지도 함께 정리합니다.
브라우저 렌더링 비용이 커지는 전형적인 패턴
대형 페이지에서 성능이 나빠지는 흔한 이유는 다음과 같습니다.
- 큰 DOM 트리: 스타일 계산과 레이아웃의 범위가 넓어짐
- 리플로우 전파: 한 요소의 크기 변경이 부모/형제/자식에 연쇄 영향을 줌
- 페인트 영역 확대: 작은 변경이 큰 영역을 다시 칠하게 만듦
- 스크롤 중 작업 과다: 화면 밖 요소까지 계속 계산
contain과 content-visibility는 이 중에서 특히 리플로우 전파 차단과 화면 밖 요소 작업 최소화에 특화된 도구입니다.
contain 한 줄로 “레이아웃 전파”를 끊기
contain은 요소를 경계로 브라우저가 “이 박스 밖에는 영향이 없다”고 가정할 수 있게 만들어, 스타일/레이아웃/페인트 계산 범위를 줄입니다.
contain의 종류와 의미
contain: layout
레이아웃(크기/위치) 변경이 바깥으로 전파되지 않도록 격리contain: paint
페인트가 요소 경계를 넘지 않도록 제한(클리핑처럼 동작)contain: style
스타일 계산의 영향 범위를 제한(상속/카운터 등 일부 제약)contain: size
요소의 크기를 외부에 의존하지 않는 것으로 가정(주의 필요)contain: content
일반적으로layout paint style조합contain: strict
가장 강한 격리(size layout paint style)
실무에서 가장 안전하게 시작하기 좋은 조합은 보통 contain: content입니다.
언제 효과가 큰가
- 반복 카드/행 컴포넌트(피드, 검색 결과, 테이블 유사 리스트)
- 아코디언/탭처럼 내부 레이아웃이 자주 변하지만 바깥 레이아웃은 고정인 영역
- 위젯이 많은 대시보드에서 각 위젯을 독립 “섬”으로 만들 때
기본 예제: 카드 컴포넌트 격리
.card {
contain: content;
}
이 한 줄로, 카드 내부에서 이미지 로딩으로 높이가 바뀌거나 텍스트가 확장되는 등의 레이아웃 변화가 페이지 전체로 번지는 비용을 줄일 수 있습니다.
주의할 점(실전에서 자주 밟는 함정)
position: sticky가 기대대로 동작하지 않을 수 있음contain이 레이아웃 컨테이너 경계를 강하게 만들면 sticky 기준이 달라져 의도와 어긋날 수 있습니다.contain: paint는 오버플로우/드롭다운이 잘릴 수 있음
툴팁, 드롭다운, 팝오버처럼 박스 밖으로 나가야 하는 UI는 해당 요소(또는 상위)에paint격리를 걸면 잘립니다. 이런 경우 오버레이는body하위 포털로 빼거나, 격리 대상에서 제외하세요.contain: size는 자동 크기 계산이 필요한 레이아웃에 위험
외부 의존 크기를 끊기 때문에, 자연스럽게 늘어나야 하는 컨테이너에는 부작용이 생길 수 있습니다.
content-visibility로 화면 밖 렌더링을 “건너뛰기”
content-visibility는 화면 밖(또는 뷰포트에서 멀리 떨어진) 요소의 렌더링 작업을 실제로 생략할 수 있게 해줍니다.
content-visibility: auto
화면에 가까워지기 전까지 렌더링(레이아웃/페인트 등)을 미룸
이 속성은 특히 “초기 로드가 느린 긴 페이지”에서 강력합니다. 단순히 지연 로딩이 아니라, 브라우저가 렌더링 파이프라인 자체를 스킵할 수 있도록 힌트를 주는 것이 핵심입니다.
가장 중요한 짝꿍: contain-intrinsic-size
content-visibility: auto를 쓰면 화면 밖 요소의 실제 크기를 아직 모르기 때문에, 스크롤바 길이가 튀거나 레이아웃 점프가 발생할 수 있습니다. 이를 막기 위해 대략적인 고유 크기를 지정합니다.
.feed-item {
content-visibility: auto;
contain-intrinsic-size: 320px; /* 화면 밖일 때 임시 높이 */
}
- 임시 크기는 “평균적인 아이템 높이” 정도로 잡는 게 실전에서 무난합니다.
- 아이템 높이 편차가 크면 점프가 완전히 사라지진 않지만, 초기 렌더링 비용 절감 효과가 더 큰 경우가 많습니다.
리스트/피드에 적용하는 예제
<ul class="feed">
<li class="feed-item">...</li>
<li class="feed-item">...</li>
<li class="feed-item">...</li>
</ul>
.feed-item {
content-visibility: auto;
contain-intrinsic-size: 280px;
}
이렇게 하면 사용자가 첫 화면에서 보는 일부 아이템만 우선 렌더링되고, 아래쪽 수백 개 아이템은 스크롤로 가까워질 때까지 비용이 거의 들지 않습니다.
contain과 content-visibility를 같이 쓰는 실전 패턴
둘은 성격이 다릅니다.
contain: “변경의 파급”을 줄이는 격리content-visibility: “아예 렌더링을 미루는” 지연
따라서 긴 리스트에서는 보통 아래 조합이 잘 맞습니다.
.feed-item {
contain: content;
content-visibility: auto;
contain-intrinsic-size: 280px;
}
이 조합의 기대 효과:
- 첫 로드 시 화면 밖 항목 렌더링 스킵
- 각 항목 내부 변경이 페이지 전체 레이아웃으로 번지는 것 완화
- 스크롤 중 레이아웃/페인트 부담 감소
“어디에 적용해야 하나” 빠른 체크리스트
다음 조건에 해당하면 우선순위가 높습니다.
- 스크롤 페이지에서 하위 섹션이 길다(문서, 댓글, 피드)
- 각 섹션이 서로 독립적이다(한 섹션 변경이 다른 섹션 레이아웃에 영향이 적음)
- 사용자 인터랙션이 많고, 특정 컴포넌트에서 리플로우가 자주 발생한다
반대로 다음은 신중해야 합니다.
- 드롭다운/툴팁/팝오버가 박스 밖으로 나가야 하는 컴포넌트
- sticky 헤더/사이드바가 격리 경계에 걸리는 구조
- 높이가 매우 동적이고 예측 불가능한 컴포넌트(임시 크기 추정이 어려움)
DevTools로 효과 측정하기(적용 전/후 비교)
성능 튜닝은 “좋아진 것 같은 느낌”이 아니라 측정이 필요합니다.
1) Performance 패널에서 스크롤 프로파일링
- Chrome DevTools
Performance에서 레코딩 후 스크롤 - 아래를 확인
Recalculate Style/Layout/Paint이벤트 비중- 긴 작업(Long task) 발생 여부
- 프레임 드랍(60fps 기준)
content-visibility: auto가 잘 먹으면, 초기 로드 구간에서 Layout/Paint 비용이 크게 줄고, 스크롤하면서 필요한 부분만 점진적으로 비용이 발생하는 형태로 바뀝니다.
2) Rendering 패널에서 Paint Flashing
- DevTools
Rendering에서Paint flashing켜기 - 스크롤/호버/애니메이션 시 초록색 페인트 영역이 과도하게 번지는지 확인
contain: paint 또는 contain: content가 적절하면 “작은 변경이 큰 영역을 다시 칠하는” 현상이 줄어드는 것을 관찰할 수 있습니다.
3) Layers/Compositing 확인
격리를 과하게 걸면 레이어가 늘어나거나(상황에 따라) 오히려 메모리/컴포지팅 비용이 증가할 수 있습니다. 변화가 크면 Layers 관련 패널에서 레이어 수와 큰 텍스처 생성을 점검하세요.
프레임워크(React/Next.js)에서의 적용 팁
컴포넌트 단위로 점진 적용
대부분은 CSS 한두 줄로 끝나지만, “어느 컴포넌트에 걸 것인가”가 성패를 좌우합니다. 추천 순서:
- 가장 긴 리스트의 아이템(root)에
content-visibility: auto - 아이템 내부가 복잡하면
contain: content추가 - 오버레이/팝오버가 잘리면 해당 부분만 포털로 분리
동적 높이 컴포넌트의 contain-intrinsic-size 전략
- 댓글처럼 길이가 제각각이면 평균 높이를 1차로 넣고
- 실제 측정값(예: 렌더 후
offsetHeight)을 캐시해 다음 렌더에 반영하는 방식도 고려할 수 있습니다.
아래는 “렌더 후 높이를 CSS 변수로 저장”하는 간단한 예시입니다.
function rememberHeight(el) {
if (!el) return;
const h = el.offsetHeight;
el.style.setProperty('--cis', `${h}px`);
}
.feed-item {
content-visibility: auto;
contain: content;
contain-intrinsic-size: var(--cis, 280px);
}
이 접근은 완벽하진 않지만, 점프를 줄이고 사용자 경험을 안정시키는 데 도움이 됩니다.
다른 최적화와의 관계: “중복이 아니라 레이어링”
- 가상 스크롤(virtualization)은 DOM 자체를 줄이는 강력한 방법이지만 구현 복잡도가 큼
content-visibility는 DOM을 유지하면서 렌더링 비용을 줄여, 적용 난이도가 낮음
즉,
- 빠르게 이득을 보고 싶으면
content-visibility와contain - 극단적으로 긴 리스트에서 메모리까지 줄이려면 virtualization
처럼 “레이어링”으로 접근하는 게 실전적입니다.
비슷한 맥락에서 서버/DB 튜닝도 비용이 큰 작업을 줄이는 방향으로 접근합니다. 예를 들어 PostgreSQL VACUUM 안됨? autovacuum 튜닝 실전처럼, 내부 메커니즘을 이해하고 병목을 줄이는 방식이 프론트 렌더링 튜닝과 사고방식이 닮아 있습니다.
브라우저 지원과 운영 환경 고려
contain은 비교적 오래전부터 지원이 안정적입니다.content-visibility는 Chromium 계열에서 특히 효과가 좋고, 다른 엔진은 지원 수준이 다를 수 있습니다.
따라서 운영 환경에서 크로스 브라우저 요구사항이 강하면,
- 기본은
contain위주로 안전하게 적용 content-visibility는 점진적 개선(progressive enhancement)로 적용
하는 전략이 합리적입니다.
적용 가이드: 안전한 시작 템플릿
긴 리스트/문서 페이지에서 “일단 해볼 만한” 기본 템플릿입니다.
.section {
contain: content;
}
.section .row {
content-visibility: auto;
contain-intrinsic-size: 240px;
}
.section은 내부 변경이 바깥으로 새지 않게 격리.row는 화면 밖 렌더링을 지연
여기서 문제가 생기면 보통은 오버레이 잘림, sticky 동작, 임시 높이 부정확으로 인한 점프입니다. 이 3가지만 집중 점검하면 대부분 해결됩니다.
마무리: “큰 화면”을 다루는 가장 싼 최적화
contain과 content-visibility는 대규모 리팩터링 없이도,
- 레이아웃/페인트 범위를 줄이고
- 화면 밖 렌더링을 늦추며
- 스크롤/인터랙션 체감을 개선
할 수 있는 비용 대비 효과가 큰 도구입니다.
다만 만능은 아니므로, DevTools로 전후를 비교해 실제로 Layout/Paint 시간이 줄었는지 확인하고, 오버레이/스티키/점프 같은 부작용을 케이스별로 제어하는 것이 실전에서의 핵심입니다.
성능 문제는 프론트든 백엔드든 “원인 분해, 비용 격리, 점진 적용, 측정”의 반복입니다. 이 글의 설정을 작은 범위부터 적용해보고, 가장 병목이 큰 화면부터 확장해 보세요.