Published on

Chrome CLS 깜빡임 원인 - contain·will-change

Authors

서버나 네트워크 문제가 아닌데도 화면이 순간적으로 흔들리거나, 스크롤 중 특정 영역이 번쩍이며 재배치되는 현상을 Chrome에서 종종 만납니다. Lighthouse의 CLS 점수도 나빠지고, 사용자 체감은 더 나쁩니다. 특히 성능 최적화를 위해 넣은 containwill-change가 의도치 않게 렌더링 파이프라인의 경계를 바꿔 “깜빡임” 또는 레이아웃 점프를 만들 수 있습니다.

이 글은 다음을 목표로 합니다.

  • Chrome에서 CLS(누적 레이아웃 이동)와 “깜빡임”이 발생하는 전형적인 패턴을 containwill-change 관점에서 설명
  • DevTools로 원인을 확정하는 체크리스트
  • 안전한 대체 접근(레이아웃 고정, 애니메이션 방식 변경, 사용 범위 제한)

CLS와 깜빡임은 같은 문제일까

  • CLS는 “예상치 못한 레이아웃 이동”을 점수로 측정합니다. 즉, 요소의 위치/크기가 바뀌어 주변 콘텐츠가 밀리면 CLS가 증가합니다.
  • **깜빡임(flicker)**은 레이아웃 이동이 없어도 발생할 수 있습니다. 예를 들어 합성 레이어 전환, 페인트 순서 변화, 서브픽셀 라운딩 변화로 인해 같은 위치에서 순간적으로 번쩍이는 현상이 생깁니다.

하지만 실무에서는 둘이 함께 나타나는 경우가 많습니다. 이유는 containwill-change레이아웃/페인트/합성 경계를 바꿔, “재계산 타이밍”과 “표현 방식”을 동시에 흔들 수 있기 때문입니다.

contain이 만드는 경계: 장점과 부작용

CSS contain은 요소의 영향 범위를 제한해 브라우저 최적화를 돕습니다.

  • contain: layout은 레이아웃 계산을 격리합니다.
  • contain: paint는 페인트 영역을 격리합니다.
  • contain: strict는 여러 격리를 강하게 적용합니다.
  • contain: content는 보통 layoutpaint를 포함한 조합으로 이해하면 됩니다.

부작용 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-changetransform이 걸린 요소가 과도하게 레이어로 분리되는지 확인합니다.

  • 레이어 수가 많아지면 스크롤 중 래스터 부담이 증가
  • 특정 레이어가 프레임마다 재래스터되면 깜빡임이 발생할 수 있음

안전한 해결 전략: “격리”보다 “예측 가능성”

1) CLS는 공간 예약으로 먼저 잡기

동적 콘텐츠가 들어올 영역은 반드시 크기를 예약합니다.

  • 이미지: widthheight 속성 지정 또는 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 충돌 복구 체크리스트도 참고할 만합니다.

결론

  • containwill-change는 성능 최적화에 유용하지만, Chrome에서 레이아웃/페인트/합성 경계를 바꾸며 CLS와 깜빡임을 유발할 수 있습니다.
  • 해결의 우선순위는 “격리/승격”이 아니라 공간 예약과 예측 가능한 레이아웃입니다.
  • DevTools에서 Layout Shift, Paint flashing, Layers를 통해 “진짜 원인”을 확정한 뒤 최소 범위로 적용하세요.

최적화 힌트는 빠르게 보이게 만들지만, 잘못 쓰면 더 느리고 더 흔들리게 만듭니다. containwill-change는 ‘필요한 곳에만, 필요한 기간만’이 정답입니다.