- Published on
Chrome CLS 깜빡임 원인 - contain·will-change
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버나 네트워크 문제가 아닌데도 화면이 순간적으로 흔들리거나, 스크롤 중 특정 영역이 번쩍이며 재배치되는 현상을 Chrome에서 종종 만납니다. Lighthouse의 CLS 점수도 나빠지고, 사용자 체감은 더 나쁩니다. 특히 성능 최적화를 위해 넣은 contain과 will-change가 의도치 않게 렌더링 파이프라인의 경계를 바꿔 “깜빡임” 또는 레이아웃 점프를 만들 수 있습니다.
이 글은 다음을 목표로 합니다.
- Chrome에서 CLS(누적 레이아웃 이동)와 “깜빡임”이 발생하는 전형적인 패턴을
contain과will-change관점에서 설명 - DevTools로 원인을 확정하는 체크리스트
- 안전한 대체 접근(레이아웃 고정, 애니메이션 방식 변경, 사용 범위 제한)
CLS와 깜빡임은 같은 문제일까
- CLS는 “예상치 못한 레이아웃 이동”을 점수로 측정합니다. 즉, 요소의 위치/크기가 바뀌어 주변 콘텐츠가 밀리면 CLS가 증가합니다.
- **깜빡임(flicker)**은 레이아웃 이동이 없어도 발생할 수 있습니다. 예를 들어 합성 레이어 전환, 페인트 순서 변화, 서브픽셀 라운딩 변화로 인해 같은 위치에서 순간적으로 번쩍이는 현상이 생깁니다.
하지만 실무에서는 둘이 함께 나타나는 경우가 많습니다. 이유는 contain과 will-change가 레이아웃/페인트/합성 경계를 바꿔, “재계산 타이밍”과 “표현 방식”을 동시에 흔들 수 있기 때문입니다.
contain이 만드는 경계: 장점과 부작용
CSS contain은 요소의 영향 범위를 제한해 브라우저 최적화를 돕습니다.
contain: layout은 레이아웃 계산을 격리합니다.contain: paint는 페인트 영역을 격리합니다.contain: strict는 여러 격리를 강하게 적용합니다.contain: content는 보통layout과paint를 포함한 조합으로 이해하면 됩니다.
부작용 1: 격리로 인한 레이아웃 “재조정”
컨테이너가 레이아웃 격리되면, 자식의 크기 변화가 외부에 전파되지 않는 대신 컨테이너 내부에서만 정리됩니다. 이때 개발자가 의도한 “자연스러운 크기 확장” 대신, 다음과 같은 일이 벌어질 수 있습니다.
- 내부 콘텐츠가 로딩되며 커지는데 컨테이너가 이를 즉시 반영하지 못하거나
- 특정 프레임에서 스크롤바/오버플로 정책이 바뀌며
- 결과적으로 주변 레이아웃이 한 번에 밀리는 점프가 발생
특히 이미지/웹폰트/동적 데이터 로딩이 결합되면 CLS로 직결됩니다.
부작용 2: contain: paint와 클리핑/페인트 순서 변화
contain: paint는 페인트 영역을 제한합니다. 이 자체가 나쁜 건 아니지만, 다음 상황에서 “번쩍임”이 생기기 쉽습니다.
- 그림자, 블러, 오버레이가 컨테이너 바깥으로 나가야 하는데 클리핑되었다가
- 프레임에 따라 레이어/합성 방식이 바뀌면서
- 특정 순간에만 잘리거나 다시 나타나는 현상
부작용 3: 스크롤 컨테이너와 결합될 때
overflow: auto 같은 스크롤 컨테이너에 contain을 강하게 걸면, Chrome이 스크롤 최적화(타일링, 래스터 캐시)를 다르게 적용할 수 있습니다. 이때 다음이 트리거가 됩니다.
- 스크롤 중 동적 높이 변경
position: sticky요소의 페인트/합성 전환- 서브픽셀 라운딩 차이로 인한 미세한 흔들림
will-change는 “예약”일 뿐인데 왜 문제를 만들까
will-change는 브라우저에게 “곧 변할 것”을 미리 알려 최적화를 유도합니다. 대표적으로 transform, opacity를 지정하면 합성 레이어 승격이 일어나기 쉽습니다.
하지만 과도하거나 잘못된 사용은 다음 문제를 유발합니다.
부작용 1: 레이어 승격으로 인한 텍스트 렌더링 변화
합성 레이어로 올라가면 텍스트 안티앨리어싱이나 서브픽셀 렌더링이 바뀌어, 같은 글자가 프레임마다 미묘하게 달라 보일 수 있습니다. 사용자는 이를 “깜빡임”으로 인지합니다.
- 특히 작은 폰트, 얇은 웨이트,
transform이 걸린 부모 아래 텍스트에서 잘 보입니다.
부작용 2: 메모리/래스터 부담 증가로 인한 순간적인 재래스터
will-change는 “미리 준비”를 위해 리소스를 더 씁니다. 많은 요소에 남발하면:
- GPU 메모리 압박
- 타일 래스터 캐시가 밀려나며
- 스크롤/애니메이션 중 특정 프레임에서 재래스터가 발생
이때 번쩍임 또는 순간적인 품질 변화(블러처럼 보임)가 생길 수 있습니다.
부작용 3: 레이아웃 속성에 will-change를 걸었을 때
will-change: height 같은 레이아웃 속성은 비용이 크고, 기대한 최적화가 나오지 않는 경우가 많습니다. 오히려 레이아웃 재계산 타이밍이 불안정해져 CLS를 악화시키기도 합니다.
재현 가능한 최소 예제
아래 예제는 “동적 콘텐츠 로딩”과 “contain/will-change”가 결합될 때 CLS 또는 깜빡임이 생기는 전형적인 패턴을 단순화한 것입니다.
예제 1: contain으로 인해 높이 변화가 한 번에 튀는 케이스
<div class="card">
<h3>상품</h3>
<p id="desc">로딩 중...</p>
</div>
<script>
// 300ms 뒤에 긴 텍스트가 들어오며 높이가 증가
setTimeout(() => {
document.getElementById('desc').textContent =
'긴 설명이 들어오면서 카드 높이가 커집니다. '.repeat(20);
}, 300);
</script>
.card {
contain: content;
border: 1px solid #ddd;
padding: 12px;
}
이 자체로 항상 CLS가 발생하는 것은 아니지만, 페이지 상단이나 뷰포트 안에서 일어나면 CLS로 측정될 수 있습니다. 특히 주변에 다른 콘텐츠가 붙어 있으면 더 두드러집니다.
개선 포인트는 “격리”보다 “공간 예약”입니다.
.card {
border: 1px solid #ddd;
padding: 12px;
min-height: 180px; /* 예상 높이를 예약 */
}
예제 2: will-change: transform로 텍스트가 흔들리는 케이스
<button class="cta">결제하기</button>
.cta {
will-change: transform;
transform: translateZ(0);
font-size: 13px;
font-weight: 500;
}
.cta:hover {
transform: translateY(-1px);
}
이 패턴은 레이어 승격을 강제하는데, 환경에 따라 텍스트 렌더링이 바뀌며 hover 전후로 글자가 “번쩍”하는 것처럼 보일 수 있습니다.
개선 방향은 다음 중 하나입니다.
- 텍스트가 있는 요소 자체를 승격하지 말고, 배경/장식만 별도 레이어로 분리
will-change를 상시 적용하지 말고, 상호작용 직전에만 짧게 적용
const btn = document.querySelector('.cta');
btn.addEventListener('pointerenter', () => {
btn.style.willChange = 'transform';
});
btn.addEventListener('pointerleave', () => {
btn.style.willChange = 'auto';
});
DevTools로 원인 확정하는 방법
감으로 contain이나 will-change를 지우는 건 위험합니다. 먼저 “무엇이 움직였는지, 왜 다시 그려졌는지”를 확인해야 합니다.
1) Performance 패널에서 Layout Shift 이벤트 확인
- DevTools
Performance기록 - 트랙에서 Layout Shift 이벤트 확인
- 어떤 노드가 shift에 기여했는지, shift의 원인이 이미지/폰트/DOM 변경인지 확인
2) Rendering 패널로 페인트/레이아웃 시각화
DevTools의 More tools 안의 Rendering에서 다음을 켭니다.
- Paint flashing: 페인트가 발생한 영역이 깜빡이며 표시
- Layout Shift Regions: 이동한 영역 표시
여기서 특정 컨테이너만 반복적으로 페인트되면 contain: paint 또는 레이어/합성 변경 가능성이 큽니다.
3) Layers 패널로 레이어 승격 확인
will-change나 transform이 걸린 요소가 과도하게 레이어로 분리되는지 확인합니다.
- 레이어 수가 많아지면 스크롤 중 래스터 부담이 증가
- 특정 레이어가 프레임마다 재래스터되면 깜빡임이 발생할 수 있음
안전한 해결 전략: “격리”보다 “예측 가능성”
1) CLS는 공간 예약으로 먼저 잡기
동적 콘텐츠가 들어올 영역은 반드시 크기를 예약합니다.
- 이미지:
width와height속성 지정 또는aspect-ratio사용 - 스켈레톤 UI: 실제 콘텐츠와 동일한 레이아웃 박스를 유지
- 광고/추천영역: 최소 높이 확보
.thumb {
aspect-ratio: 16 / 9;
width: 100%;
background: #f2f2f2;
}
2) contain은 “필요한 축만” 최소 적용
contain: strict 같은 강한 격리는 최후의 수단에 가깝습니다.
- 단순 리스트 아이템 최적화라면
content-visibility: auto가 더 적합한 경우가 많음 contain을 쓰더라도contain: layout만 필요한지,paint까지 필요한지 분리해서 판단
.item {
contain: layout; /* paint까지 격리하지 않음 */
}
3) will-change는 짧게, 적게, 정확히
- 상시 적용하지 말고 상호작용 직전/직후로 제한
- 레이아웃 속성보다
transform/opacity중심 - 텍스트가 포함된 큰 블록에 무차별 적용 금지
4) 애니메이션은 레이아웃이 아닌 합성 속성으로
top/left/height 애니메이션은 레이아웃을 흔듭니다. 가능한 경우 transform으로 바꿉니다.
.panel {
transform: translateY(0);
transition: transform 180ms ease;
}
.panel.is-open {
transform: translateY(-8px);
}
5) 폰트 로딩으로 인한 흔들림도 같이 점검
contain/will-change 이슈처럼 보이지만 실제로는 웹폰트 로딩으로 인한 FOIT/FOUT, 메트릭 변화가 CLS를 만드는 경우가 많습니다.
font-display: swap적용- 가능하면 메트릭 호환 폰트 선택
- 프리로드로 로딩 타이밍 안정화
자주 터지는 조합별 체크리스트
조합 A: contain: paint + box-shadow/blur
- 그림자 잘림 또는 프레임별 클리핑 변화가 있나
- 해결:
paint격리 제거 또는 그림자 영역을 별도 엘리먼트로 분리
조합 B: will-change: transform + 작은 텍스트
- hover/scroll 시 글자가 두께가 달라 보이나
- 해결: 텍스트를 승격하지 말고 장식 레이어만 승격, 또는
will-change를 상호작용 시점에만
조합 C: 스크롤 컨테이너 + 동적 높이 + 격리
- 스크롤 중 특정 아이템이 로딩되며 높이가 변하나
- 해결: 스켈레톤으로 높이 고정, 이미지/콘텐츠 영역에
min-height또는aspect-ratio
운영 관점 팁: “원인 규명”을 남기는 방식
렌더링 이슈는 재현이 어렵고, 수정이 다른 성능/레이아웃 문제를 부를 수 있습니다. 다음처럼 기록을 남기면 회귀를 줄일 수 있습니다.
- 어떤 페이지/컴포넌트에서 발생했는지
- DevTools에서 확인한 Layout Shift 이벤트 스크린샷/타임스탬프
- 적용한 CSS 힌트(
contain,will-change)의 범위 - 수정 전후 CLS 수치(Lighthouse, CrUX, RUM)
이런 “체크리스트 기반 복구” 접근은 Git 작업에서도 비슷하게 유효합니다. 예를 들어 rebase 후 강제푸시로 꼬였을 때도 단계별로 증거를 남기며 복구하는 게 안전합니다. 관련해서는 Git rebase 후 force push 충돌 복구 체크리스트도 참고할 만합니다.
결론
contain과will-change는 성능 최적화에 유용하지만, Chrome에서 레이아웃/페인트/합성 경계를 바꾸며 CLS와 깜빡임을 유발할 수 있습니다.- 해결의 우선순위는 “격리/승격”이 아니라 공간 예약과 예측 가능한 레이아웃입니다.
- DevTools에서 Layout Shift, Paint flashing, Layers를 통해 “진짜 원인”을 확정한 뒤 최소 범위로 적용하세요.
최적화 힌트는 빠르게 보이게 만들지만, 잘못 쓰면 더 느리고 더 흔들리게 만듭니다. contain과 will-change는 ‘필요한 곳에만, 필요한 기간만’이 정답입니다.