- Published on
CLS 튀는 이유? content-visibility로 렌더링 최적화
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버 성능도 괜찮고 번들도 가벼운데, 스크롤하거나 이미지가 로드되는 순간 화면이 툭 하고 밀리는 경험이 있습니다. Lighthouse에서 CLS가 높게 나오면 더 답답해집니다. CLS는 단순히 이미지에 width/height 안 줘서 생기는 문제로만 보면 해결이 늦습니다. 실제로는 레이아웃이 확정되기 전에 DOM과 스타일이 계속 바뀌는 구조가 핵심이고, 특히 긴 목록/피드/문서 페이지에서는 오프스크린 콘텐츠까지 초기 렌더링에 포함되면서 비용과 변동성이 커집니다.
여기서 content-visibility는 단순 성능 옵션을 넘어, 렌더링 파이프라인을 제어해 초기 레이아웃의 안정성과 렌더링 비용을 동시에 잡는 데 도움이 됩니다. 다만 잘못 쓰면 스크롤 시 팝인(pop-in)처럼 보이거나, 예상치 못한 레이아웃 변화가 생길 수 있어 contain-intrinsic-size와 함께 설계해야 합니다.
CLS가 튀는 진짜 이유: 레이아웃이 뒤늦게 바뀌는 순간들
CLS(Cumulative Layout Shift)는 사용자 입력 없이 발생하는 레이아웃 이동의 누적 점수입니다. 브라우저 관점에서 보면 레이아웃 트리 계산 결과가 바뀌는 사건이 반복될 때 점수가 쌓입니다.
대표적인 원인은 다음과 같습니다.
1) 크기를 모르는 리소스가 나중에 공간을 차지
- 이미지, iframe, 광고 슬롯, 임베드, 폰트 로드 이후 글자 폭 변화
lazy-load이미지가 들어오면서 카드 높이가 늘어남
핵심은 처음 레이아웃에서 공간을 예약하지 않았다는 점입니다.
2) 비동기 데이터로 DOM이 뒤에서 삽입/확장
- 목록 아이템에 배지/태그/서브텍스트가 API 응답 이후 추가
- skeleton이 실제 콘텐츠로 바뀌며 높이가 달라짐
skeleton을 쓴다고 자동으로 CLS가 줄지 않습니다. skeleton과 실제 콘텐츠의 최종 높이/폭이 일치해야 합니다.
3) 긴 페이지에서 오프스크린까지 한꺼번에 스타일/레이아웃 계산
초기 렌더링에서 화면 밖에 있는 수백 개 섹션까지 레이아웃을 계산하면:
- 초기 렌더링이 느려지고(TBT, INP 악화)
- 뒤늦게 로드되는 이미지/폰트/서브컴포넌트가 많아져
레이아웃 변경 이벤트가 더 자주 발생합니다.
여기서 content-visibility가 특히 효과적입니다.
content-visibility란: 오프스크린 렌더링을 “건너뛰기”
content-visibility는 요소의 렌더링 작업(스타일 계산, 레이아웃, 페인트)을 브라우저가 스킵할 수 있게 하는 CSS 속성입니다.
content-visibility: auto;로 설정하면- 뷰포트 밖에 있는 요소는 렌더링 비용을 줄이고
- 스크롤로 가까워지면 그때 렌더링합니다.
중요한 포인트는 display: none;처럼 DOM에서 제거하는 게 아니라, 렌더링 파이프라인을 지연한다는 점입니다.
CLS 관점에서의 함정: 공간 예약 없으면 오히려 “팝인”이 생긴다
content-visibility: auto;를 쓰면 오프스크린 요소는 레이아웃 계산이 지연됩니다. 이때 브라우저는 해당 요소의 크기를 모르므로, 기본적으로는 0에 가까운 크기처럼 취급될 수 있습니다.
그래서 같이 써야 하는 것이 contain-intrinsic-size입니다.
contain-intrinsic-size는 “렌더링이 스킵된 상태에서 가짜(추정) 크기”를 제공해- 스크롤로 렌더링이 활성화되기 전에도 레이아웃이 안정적으로 유지되게 합니다.
즉,
content-visibility는 비용을 줄이고contain-intrinsic-size는 CLS를 줄입니다.
실전 패턴 1: 긴 문서/피드의 섹션 단위 최적화
아래는 블로그 본문처럼 섹션이 길거나, 피드 카드가 수십 개 있는 페이지에서 흔히 쓰는 패턴입니다.
/* 긴 페이지의 섹션/카드 단위로 적용 */
.article-section,
.feed-card {
content-visibility: auto;
contain-intrinsic-size: 1px 800px;
}
설명:
1px 800px는width height순서로, 렌더링 전 추정 높이를 800px로 둡니다.- 섹션 평균 높이에 맞춰 조정하세요. 너무 작으면 나중에 커지며 CLS가 생기고, 너무 크면 빈 공간이 커져 UX가 어색할 수 있습니다.
어떤 단위로 자르는 게 좋은가
- 문서:
h2단위 섹션 래퍼 - 피드: 카드 1개 단위
- 마켓/리스트: 행(row) 단위 또는 5~10개 묶음
너무 잘게 쪼개면 스크롤 시 잦은 활성화로 오버헤드가 늘 수 있습니다.
실전 패턴 2: Next.js/React에서 “섹션 래퍼” 컴포넌트로 표준화
CSS만으로도 충분하지만, 팀 단위로는 섹션 래퍼를 만들어 일관되게 적용하는 게 좋습니다.
import React, { PropsWithChildren } from 'react';
type Props = PropsWithChildren<{
className?: string;
intrinsicHeight?: number;
}>;
export function DeferredSection({
children,
className,
intrinsicHeight = 800,
}: Props) {
return (
<section
className={className}
style={{
contentVisibility: 'auto',
containIntrinsicSize: `1px ${intrinsicHeight}px`,
}}
>
{children}
</section>
);
}
포인트:
- React 인라인 스타일에서는
contentVisibility,containIntrinsicSize처럼 camelCase를 사용합니다. - 섹션마다 평균 높이가 다르면
intrinsicHeight를 조정할 수 있게 둡니다.
CLS를 더 줄이는 체크리스트: content-visibility는 “마지막 퍼즐”이다
content-visibility만으로 CLS가 0이 되진 않습니다. 아래를 먼저/같이 잡아야 합니다.
1) 이미지/비디오/iframe은 반드시 공간 예약
width/height속성 또는 CSSaspect-ratio로 레이아웃을 고정하세요.
.media {
width: 100%;
aspect-ratio: 16 / 9;
background: #f2f3f5;
overflow: hidden;
}
.media img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
2) 폰트 로딩으로 인한 시프트 완화
font-display: swap;은 FOUT를 줄이지만 폭 변화가 생기면 CLS가 날 수 있습니다.- 가능하면 metric 호환 폰트를 고려하거나, 중요한 헤딩에만 다른 폰트를 쓰는 등 범위를 줄이세요.
3) skeleton은 “최종 레이아웃과 동일한 박스”로
skeleton이 예쁜 것보다 중요한 건 최종 높이와 동일하게 만드는 것입니다.
4) 광고/추천 위젯은 슬롯 높이를 고정
광고는 CLS의 주범입니다. 최소 높이를 고정하거나, 비어 있을 때도 동일한 영역을 유지하세요.
측정과 디버깅: 어디서 CLS가 발생하는지 찾는 법
Chrome DevTools로 원인 요소 추적
- DevTools
Performance탭에서 레코딩 Experience또는Layout Shift이벤트 확인- 어떤 노드가 이동했는지 상세에서 추적
또는 Rendering 패널에서 Layout Shift Regions를 켜면, 시프트가 발생한 영역이 시각적으로 표시됩니다.
현업 팁: 배포 후에도 RUM으로 확인
Lighthouse는 실험실 측정이라, 실제 사용자 환경(광고, 느린 네트워크, 기기 성능)에선 다르게 나옵니다. web-vitals로 CLS를 수집해 릴리즈 전후를 비교하는 게 좋습니다.
content-visibility 적용 시 주의사항
1) 접근성/탐색(Find in page)과의 상호작용
오프스크린 콘텐츠가 렌더링 스킵 상태면, 브라우저의 페이지 내 검색이나 스크린리더 동작이 기대와 다를 수 있습니다. 중요한 콘텐츠(약관, 핵심 문장)는 전체를 스킵하지 말고 섹션 단위로 신중히 적용하세요.
2) 스크롤 앵커/해시 이동
URL 해시로 특정 섹션으로 점프할 때, 해당 섹션이 스킵 상태면 점프 후 렌더링 타이밍에 따라 위치가 흔들릴 수 있습니다. 이 경우:
- 해시 타겟 섹션에는
content-visibility를 제외하거나 - 점프 직후 해당 섹션을 강제로 활성화하는 방식(예:
IntersectionObserver로 미리 워밍업)을 고려하세요.
3) 너무 큰 intrinsic size는 빈 공간을 만든다
contain-intrinsic-size를 과도하게 크게 잡으면 스크롤 중 빈 공간이 보일 수 있습니다. 섹션 높이 분포를 보고 P50 또는 P75 정도로 잡는 전략이 무난합니다.
정리: CLS를 줄이면서 렌더링 비용도 낮추는 순서
- 리소스 공간 예약: 이미지/iframe/광고 슬롯의 크기 고정
- 비동기 UI의 레이아웃 안정화: skeleton과 최종 박스 일치
- 긴 페이지의 오프스크린 비용 절감:
content-visibility: auto; - 팝인 방지 및 CLS 완화:
contain-intrinsic-size로 추정 크기 제공
긴 문서/피드 페이지에서 CLS가 튀는 이유는 대부분 초기 레이아웃이 확정되지 않은 상태에서 많은 요소가 뒤늦게 크기를 갖기 때문입니다. content-visibility는 그중에서도 오프스크린 렌더링을 늦춰 전체 변동성을 줄이는 강력한 도구이고, contain-intrinsic-size와 함께 써야 안정적으로 효과를 봅니다.
추가로, 배포 파이프라인에서 성능 회귀를 막으려면 캐시/빌드 환경 차이로 지표가 흔들리지 않게 관리하는 것도 중요합니다. 관련해서는 GitHub Actions 캐시 충돌 시 빌드 완전 초기화 전략도 함께 참고하면 좋습니다.