Published on

CSS contain로 레이아웃 스래싱 줄이는 실전

Authors

서버/네트워크 병목이 아닌데도 스크롤이 버벅이고 입력 반응이 늦는 UI는 종종 레이아웃 스래싱(layout thrashing) 때문에 발생합니다. 자바스크립트가 DOM을 수정한 직후 레이아웃 정보를 읽으면(예: getBoundingClientRect()), 브라우저는 최신 값을 만들기 위해 강제 동기 레이아웃(forced reflow) 을 수행합니다. 이 패턴이 루프에서 반복되면 프레임 예산(16.6ms)을 쉽게 초과합니다.

이 글에서는 CSS contain을 이용해 레이아웃/페인트/스타일 계산의 전파 범위를 격리하여 스래싱의 피해를 줄이는 방법을, “어디에 어떻게 붙여야 안전한지” 중심으로 다룹니다. Long Task/INP 진단 흐름은 이 글도 함께 보면 좋습니다: Chrome INP 급락 원인 - Long Task 5분 진단

레이아웃 스래싱이 생기는 전형적인 코드

레이아웃 스래싱은 대개 아래처럼 쓰기(write)읽기(read) 가 교차할 때 생깁니다.

// 나쁜 예: write -> read -> write -> read ...
const items = document.querySelectorAll('.item');

items.forEach((el, i) => {
  el.style.height = (100 + i) + 'px'; // write
  const h = el.getBoundingClientRect().height; // read (forced reflow 가능)
  el.style.transform = `translateY(${h}px)`; // write
});

브라우저는 스타일 변경을 내부적으로 배치해두었다가 한 번에 처리하려고 하지만, 중간에 레이아웃 값을 읽어버리면 “지금 당장 최신 레이아웃이 필요”해져서 강제로 계산합니다.

1차 처방: 읽기/쓰기를 분리(batch)

가장 먼저 해야 할 것은 패턴 자체를 고치는 것입니다.

const items = [...document.querySelectorAll('.item')];

// 1) write만 모아서
items.forEach((el, i) => {
  el.style.height = (100 + i) + 'px';
});

// 2) read를 모아서
const heights = items.map(el => el.getBoundingClientRect().height);

// 3) 다시 write만
items.forEach((el, idx) => {
  el.style.transform = `translateY(${heights[idx]}px)`;
});

하지만 현실에서는 “서드파티 위젯”, “복잡한 컴포넌트 트리”, “스크롤 컨테이너 내부에서의 잦은 변화”처럼 코드만으로 완벽히 통제하기 어려운 경우가 많습니다. 이때 contain피해를 국소화하는 유용한 도구가 됩니다.

CSS contain: 무엇을 격리하는가

contain은 특정 요소가 외부 레이아웃/페인트/스타일 계산에 미치는 영향을 제한하도록 브라우저에 힌트를 줍니다.

  • contain: layout; : 해당 박스의 레이아웃 계산이 바깥으로 전파되는 것을 줄입니다.
  • contain: paint; : 페인트 영역을 박스 경계로 제한해, 외부에 영향을 덜 주게 합니다(클리핑 성격).
  • contain: style; : 스타일 계산의 영향 범위를 줄입니다(상속/카운터 등 일부 제약).
  • contain: size; : 요소의 크기가 내부 콘텐츠에 의해 바깥 레이아웃에 영향을 주지 않게 합니다(가장 제약이 큼).
  • contain: content; : 보통 layout paint style 조합(브라우저 정의).
  • contain: strict; : 보통 size layout paint style 조합(더 강함).

핵심은 “스래싱을 없애는 마법”이 아니라, 리플로우/리페인트의 전파 반경을 줄여 전체 페이지가 함께 흔들리는 것을 막는 것입니다.

실전 적용 1: 카드 리스트/피드에서 리플로우 전파 차단

무한 스크롤 피드에서 각 카드 내부(이미지 로딩, 좋아요 토글, 댓글 펼치기 등)가 자주 변하면, 상위 컨테이너까지 레이아웃 재계산이 전파될 수 있습니다. 카드 단위로 격리하면 “카드 내부 변화가 카드 밖에 덜 영향을 주도록” 만들 수 있습니다.

.feed {
  display: grid;
  grid-template-columns: 1fr;
  gap: 12px;
}

.card {
  contain: layout paint; /* 실전에서 가장 무난한 출발점 */
  background: white;
  border: 1px solid #eee;
  border-radius: 12px;
  padding: 12px;
}
  • layout은 카드 내부 레이아웃 변경이 바깥 계산을 덜 흔들게 돕습니다.
  • paint는 카드 외부로 번지는 페인트 영향을 줄여줍니다.

주의: paint는 “밖으로 그리기”가 필요한 UI와 충돌

contain: paint는 박스 밖으로 그리는 효과(예: 큰 box-shadow, 글로우, 툴팁/드롭다운이 카드 밖으로 나가는 경우)와 충돌할 수 있습니다. 이런 UI가 카드 내부에 있다면 카드에는 contain: layout;만 적용하고, 오버레이는 포털(예: position: fixed 레이어)로 빼는 편이 안전합니다.

실전 적용 2: 스크롤 컨테이너 내부 컴포넌트 격리

스크롤 영역 안에서 스크립트가 scrollTop을 읽고, 동시에 아이템 높이/스타일을 바꾸는 경우가 많습니다. 컨테이너 내부의 “섹션”을 격리해두면, 특정 섹션의 변화가 다른 섹션의 레이아웃에 덜 영향을 줍니다.

.scroll-area {
  height: 70vh;
  overflow: auto;
}

.section {
  contain: layout; 
}

여기에 content-visibility까지 함께 쓰면(렌더링 생략) 성능이 더 좋아질 수 있지만, 이 글의 초점은 contain이므로 “격리” 관점만 잡고 넘어가겠습니다.

실전 적용 3: 위젯/서드파티 DOM을 샌드박싱처럼 다루기

서드파티 위젯(차트, 광고, 댓글, 에디터)은 내부에서 DOM 측정과 스타일 변경을 반복하는 경우가 많습니다. 이때 위젯 루트에 contain을 주면, 위젯 내부의 레이아웃 churn이 페이지 전체로 번지는 것을 완화할 수 있습니다.

<div class="page">
  <main>
    <section class="article">...</section>
    <aside class="widget-root" id="thirdPartyWidget"></aside>
  </main>
</div>
.widget-root {
  contain: content; /* layout + paint + style 조합 */
  min-height: 240px; /* 로딩 중 점프 방지용 */
}

여기서 min-height를 같이 주는 이유는, 위젯이 로딩되며 높이가 커질 때 레이아웃 점프가 발생하면 사용자 체감이 나빠지기 때문입니다. contain은 전파를 줄여도 “높이가 바뀌는 사실” 자체를 없애진 않습니다.

contain: sizecontain: strict는 언제 쓰나

size는 강력하지만 제약이 큽니다. 요소의 크기가 내부 콘텐츠에 의해 결정되는 일반적인 레이아웃에서는 예상치 못한 결과를 만들 수 있습니다. 대신 아래처럼 “크기를 이미 알고 있고, 내부는 독립적으로 움직여도 되는” 박스에 적합합니다.

  • 고정 크기(또는 명시적 width/height)의 미니맵, 캔버스, 차트 영역
  • 스켈레톤/플레이스홀더처럼 외부 레이아웃을 안정화하고 싶은 영역
.chart-box {
  width: 360px;
  height: 240px;
  contain: strict; /* size + layout + paint + style */
}

이런 박스는 내부에서 캔버스 리사이즈, 텍스트 업데이트가 빈번해도 바깥 레이아웃에 영향이 적고, 브라우저 최적화 여지가 커집니다.

DevTools로 효과 확인하는 방법

contain은 “붙였더니 빨라진 느낌”으로 끝내면 위험합니다. 다음 순서로 확인하는 게 좋습니다.

  1. Chrome DevTools Performance에서 기록
  2. 스크롤/클릭/입력 등 문제 동작 재현
  3. Main thread에서 Recalculate Style, Layout, Paint가 어디서 많이 발생하는지 확인
  4. contain 적용 전후로
    • Layout 시간 감소 여부
    • Layout 발생 범위(연쇄적으로 큰 트리까지 타는지) 완화 여부
    • Paint 영역 감소 여부

특히 INP 관점에서는 “레이아웃 자체 시간”뿐 아니라, 레이아웃이 길게 이어져 Long Task로 커지는지를 보는 게 중요합니다. 진단 루틴은 위 내부 링크 글의 흐름을 그대로 적용할 수 있습니다.

contain 적용 체크리스트(실수 방지)

1) 오버레이/드롭다운/툴팁이 박스 밖으로 나가야 하는가

  • 그렇다면 contain: paint는 신중히 적용
  • 대안: 오버레이를 루트 레이어로 포털링하고, 본문 박스에는 contain: layout만 적용

2) 콘텐츠 크기에 따라 부모 레이아웃이 자연스럽게 늘어나야 하는가

  • 그렇다면 contain: size 또는 contain: strict는 피하기
  • 카드/섹션 단위라면 보통 contain: layout 또는 contain: content부터

3) 너무 촘촘하게 적용하지 않았는가

  • 모든 요소에 contain을 남발하면 오히려 스타일/레이아웃 최적화 경로가 꼬이거나 디버깅이 어려워질 수 있습니다.
  • “자주 변하는 컴포넌트의 루트”에만 전략적으로 적용하세요.

Next.js/React에서의 실전 패턴

React에서 스래싱이 자주 보이는 지점은 “상태 변화로 리스트가 재렌더링되고, 동시에 측정 로직이 돌아가는” 구간입니다. 측정이 꼭 필요하다면 useLayoutEffect에서 읽기만 하고, 쓰기는 다음 프레임으로 넘기는 식으로 분리하는 것이 기본이고, 여기에 contain으로 컴포넌트 경계를 격리합니다.

import { useLayoutEffect, useRef, useState } from 'react';

type Props = { open: boolean };

export function ExpandableCard({ open }: Props) {
  const ref = useRef<HTMLDivElement | null>(null);
  const [h, setH] = useState<number>(0);

  useLayoutEffect(() => {
    const el = ref.current;
    if (!el) return;

    // read
    const next = el.getBoundingClientRect().height;

    // write는 state로 반영되지만, 가능하면 읽기/쓰기 교차를 최소화
    setH(next);
  }, [open]);

  return (
    <div className="card" ref={ref} style={{ ['--measured-h' as any]: `${h}px` }}>
      <button>toggle</button>
      <div className={open ? 'body open' : 'body'}>
        ...
      </div>
    </div>
  );
}
.card {
  contain: layout paint;
}

여기서 중요한 포인트는 contain이 React 렌더링 비용을 직접 줄이는 게 아니라, 렌더 결과가 레이아웃 엔진에 미치는 파급을 줄여 전체 프레임이 무너지는 상황을 완화한다는 점입니다.

결론: contain은 “격리 경계”를 설계하는 도구

  • 레이아웃 스래싱의 1차 해결은 언제나 “읽기/쓰기 분리”와 “측정 최소화”입니다.
  • 그 다음 단계에서 contain은 자주 변하는 UI를 컴포넌트 경계로 묶어 리플로우/리페인트 전파를 차단하는 실전 최적화 수단입니다.
  • 추천 시작점은 대개 contain: layout 또는 contain: layout paint이며, 오버레이/그림자 같은 시각 효과가 박스 밖으로 나가야 한다면 paint는 조심해야 합니다.

성능 이슈는 원인 추적이 절반입니다. 프론트에서 Long Task/INP를 먼저 잡고, 그 다음 contain 같은 격리 전략을 적용하면 “부분 최적화가 전체 체감 개선”으로 이어지는 경우가 많습니다.