안녕하세요! 프론트엔드 개발 강사입니다. 이번에 공부하실 내용은 웹 페이지의 렌더링 성능을 극적으로 끌어올릴 수 있는 고급 CSS 기술인 CSS 컨테인먼트(Containment)네요!
문서가 길고 생소한 개념이 많아 영문으로 읽기 벅차셨을 텐데, 제가 이해하기 쉬운 구어체로 하나하나 번역해 드리고 실무에서 어떻게 쓰이는지 꿀팁도 팍팍 얹어드리겠습니다. 브라우저가 화면을 그리는 렌더링 파이프라인(Layout -> Paint -> Composite)을 머릿속에 떠올리면서 읽어보시면 훨씬 이해가 쉬울 거예요. 자, 시작해볼까요? 😊
CSS 컨테인먼트는 브라우저가 페이지의 특정 하위 트리(subtree)를 페이지의 나머지 부분과 격리할 수 있게 해 주어 웹 페이지의 성능을 향상시킵니다. 만약 브라우저가 페이지의 특정 부분이 나머지 콘텐츠와 완전히 독립적이라는 것을 알게 된다면, 렌더링 과정을 최적화하고 성능을 크게 개선할 수 있거든요.
contain 속성과 content-visibility 속성을 사용하면, 개발자는 브라우저(사용자 에이전트)에게 특정 요소의 콘텐츠를 아예 렌더링할지 말지, 혹은 화면 밖에 있을 때 렌더링을 할지 말지 알려줄 수 있습니다. 그러면 브라우저는 적절한 시점에 요소에 컨테인먼트(격리)를 적용하여, 화면에 진짜 필요해질 때까지 레이아웃 계산과 페인팅(렌더링)을 미루게 됩니다.
이 가이드에서는 CSS 컨테인먼트의 기본적인 목적과, 더 나은 사용자 경험을 제공하기 위해 contain 및 content-visibility를 어떻게 활용해야 하는지 설명합니다.
👨🏫 강사의 실무 팁! "왜 격리(Containment)가 필요할까요?"
보통 브라우저는 DOM 요소 하나만 크기가 바뀌어도 전체 페이지의 레이아웃을 처음부터 끝까지 다시 계산(Reflow)하고 다시 그립니다(Repaint). 이게 엄청나게 무거운 작업이거든요. 이때 컨테인먼트를 사용해 "이 박스 안에서 일어나는 일은 바깥에 절대 영향을 주지 않아!"라고 브라우저에게 확신을 주면, 브라우저는 안심하고 그 박스 안쪽만 계산하게 됩니다. 성능 최적화의 치트키 같은 존재죠!
웹 페이지는 대개 논리적으로 서로 독립적인 여러 개의 섹션으로 구성됩니다. CSS 컨테인먼트를 사용하면, 렌더링 관점에서도 이 섹션들을 완전히 독립적으로 처리할 수 있습니다.
예를 들어, 블로그는 보통 아래 마크업처럼 제목과 내용을 담은 여러 개의 게시글(article)을 포함하고 있습니다.
<h1>My blog</h1>
<article>
<h2>Heading of a nice article</h2>
<p>Content here.</p>
</article>
<article>
<h2>Another heading of another article</h2>
<p>More content here.</p>
</article>
CSS를 사용해서, 우리는 각 article 요소에 contain 속성의 값으로 content를 적용할 수 있습니다. content 값은 contain: layout paint style을 한 번에 적용하는 단축 속성(shorthand)입니다.
article {
contain: content;
}
논리적으로 보면 페이지 안의 각 게시글(article)은 서로 독립적입니다. 이 사실은 페이지를 만드는 웹 개발자에게는 너무나 당연하고 익숙한 정보죠. 하지만 브라우저는 여러분이 짠 콘텐츠의 '의도'를 알지 못하기 때문에, 하나의 게시글이나 콘텐츠 섹션이 완전히 독립적일 것이라고 함부로 가정할 수 없습니다.
contain 속성은 이러한 여러분의 의도를 브라우저에게 설명하고, 브라우저가 성능 최적화를 할 수 있도록 명시적으로 허락해 주는 역할을 합니다. 이 속성은 브라우저에게 "이 요소의 내부 레이아웃은 페이지 나머지 부분과 완전히 분리되어 있고, 이 요소와 관련된 모든 것들은 요소의 경계선 안쪽에서만 그려진다(아무것도 바깥으로 넘치지 않는다)"라고 알려줍니다.
각 <article>에 contain: content를 설정함으로써 우리는 이 사실을 명시했습니다. 즉, 브라우저에게 각 게시글이 독립적이라고 말해준 것이죠. 브라우저는 이제 이 정보를 활용해서 각 <article> 콘텐츠를 어떻게 렌더링할지 현명한 결정을 내릴 수 있습니다. 예를 들어, 현재 화면에 보이지 않는 영역(offscreen)에 있는 게시글들은 아예 렌더링하지 않을 수도 있습니다.
페이지 맨 끝에 새로운 게시글이 추가될 때, 브라우저는 그 앞에 있던 콘텐츠들의 레이아웃을 다시 계산하거나 다시 그릴(repaint) 필요가 없습니다. 또한 이 컨테이닝 요소의 하위 트리를 벗어난 바깥 영역은 전혀 건드릴 필요가 없죠.
하지만 만약 박스 모델 속성들이 서로 의존적으로 연결되어 있다면 브라우저는 레이아웃과 페인트를 다시 계산해야 합니다. 예를 들어, <article>의 크기가 내부 콘텐츠에 따라 결정되도록 스타일이 지정된 경우(예: height: auto를 사용한 경우), 브라우저는 그 크기가 변하는 상황을 고려해서 다시 계산을 수행해야 합니다.
contain 속성값들 (contain values)컨테인먼트에는 4가지 유형이 있습니다: 레이아웃(layout), 페인트(paint), 크기(size), 그리고 스타일(style)입니다. contain 속성을 사용해서 원하는 하나 이상의 유형들을 조합하여 요소에 적용할 수 있습니다.
article {
contain: layout;
}
일반적으로 레이아웃은 전체 문서를 범위로(scoped) 계산됩니다. 즉, 요소 하나를 이동시키면 브라우저는 전체 문서의 다른 요소들도 모두 이동했을 수 있다고 가정하고 처리해야 합니다. 하지만 contain: layout을 사용하면 브라우저에게 "이 요소만 확인하면 돼!"라고 말해줄 수 있습니다. 요소 내부의 모든 것은 해당 요소 안에만 영향을 미치고 페이지의 나머지 부분에는 영향을 주지 않으며, 이 요소 박스가 독립적인 서식 컨텍스트(formatting context)를 생성하게 됩니다.
추가적으로 적용되는 특징은 다음과 같습니다:
float 레이아웃이 해당 요소 안에서만 독립적으로 수행됩니다.absolute 및 fixed로 위치가 지정된 자손 요소들에 대해 컨테이닝 블록(containing block) 역할을 합니다.z-index를 사용할 수 있게 됩니다.참고: >
container-type속성과container-name속성(컨테이너 쿼리에 쓰이는 속성들)을 사용할 때,contain의style과layout값은 자동으로 적용됩니다.
article {
contain: paint;
}
페인트 컨테인먼트는 기본적으로 요소의 박스를 주요 박스(principal box)의 패딩 경계선(padding edge)에 맞춰 잘라냅니다(clip). 즉, 시각적으로 넘치는(overflow) 부분이 절대 생길 수 없습니다. 위에서 설명한 layout 컨테인먼트의 추가 특징들이 paint 컨테인먼트에서도 동일하게 적용됩니다.
또 다른 장점은, 페인트 컨테인먼트가 적용된 요소가 화면 밖(offscreen)에 있다면, 브라우저가 그 자식 요소들까지 굳이 그릴(paint) 필요가 없다는 점입니다. 어차피 부모 박스 안에 완벽하게 갇혀있기 때문에 자식들도 모두 화면 밖이라는 게 보장되니까요.
article {
contain: size;
}
크기 컨테인먼트는 이것 하나만 단독으로 썼을 때는 성능 최적화 측면에서 큰 효과를 보지 못합니다. 하지만 크기 컨테인먼트를 지정하면, 내부에 있는 자식 요소들의 크기가 부모 요소 자신의 크기에 절대 영향을 주지 못한다는 특징이 있습니다. 마치 자식 요소가 아예 없는 것처럼 자신의 크기가 계산됩니다.
요소에 contain: size를 설정한다면, 반드시 contain-intrinsic-size 속성이나, 그 세부 속성인 contain-intrinsic-width 와 contain-intrinsic-height를 사용해 요소의 고유 크기를 직접 지정해 주어야 합니다. 만약 크기를 명시하지 않으면, 대부분의 경우 요소의 크기가 0으로 찌그러질 위험이 있습니다.
👨🏫 강사의 실무 팁! "크기를 예측하는 contain-intrinsic-size"
크기 컨테인먼트를 쓰면 자식이 있든 없든 뼈대(스켈레톤)의 크기를 유지해야 합니다. 렌더링을 지연시켰는데 요소의 크기가 0이었다가 내용이 로드될 때 확 커져버리면 덜컥거리면서 화면이 밀려 내려가는 현상(Layout Shift)이 발생하거든요! 그래서contain-intrinsic-size로 "대충 이 요소는 200px 정도 될 거야"라고 미리 브라우저에 자리표시자 역할을 해두는 것입니다.
article {
contain: size;
contain-intrinsic-size: 100vw auto none;
}
article {
contain: style;
}
이름만 들으면 오해하기 쉽지만, 스타일 컨테인먼트는 Shadow DOM이나 @scope를 사용할 때처럼 CSS 스타일 자체가 격리되는(scoped) 기능이 아닙니다.
style 값의 주된 사용 목적은, 요소 내부에서 CSS 카운터(CSS counter)가 변경되었을 때 그 영향이 DOM 트리 전체로 퍼져나가는 상황을 막기 위함입니다.
contain: style을 사용하면, counter-increment와 counter-set 속성이 문서 전체가 아닌 해당 하위 트리에서만 유효한 새로운 카운터를 생성하도록 보장해 줍니다.
여러 개의 컨테인먼트 유형을 적용하려면 contain: layout paint 처럼 공백으로 구분해서 값을 여러 개 넣거나, 아래에 설명할 두 가지 특별한 단축 값을 사용할 수 있습니다.
contain에는 위에서 살펴본 3가지 혹은 4가지 유형을 한 번에 묶어주는 두 가지 특수한 단축 값(shorthand)이 있습니다.
contentstrict첫 번째 content는 맨 위 예제에서 살펴봤었죠. contain: content를 사용하면 layout, paint, style 컨테인먼트가 한꺼번에 켜집니다. size 컨테인먼트는 빠져 있기 때문에, 요소의 크기가 0으로 찌그러질 걱정 없이 어디에나 광범위하게 적용하기 안전한 값입니다.
contain: strict 선언은 contain: size layout paint style (4가지 값을 공백으로 묶어 쓴 것)과 완벽하게 동일하게 동작하며, 가장 강력한 수준의 격리를 제공합니다. 다만 size 컨테인먼트를 포함하고 있기 때문에 사용하기에는 조금 더 위험이 따릅니다. 자식 요소의 크기에 의존해서 크기가 결정되는 박스였다면 크기가 0이 되어 안 보일 위험이 있기 때문이죠.
이런 위험을 방지하려면 strict를 사용할 때는 반드시 크기도 함께 지정해주어야 합니다:
article {
contain: strict;
contain-intrinsic-size: 80vw auto none;
}
위 코드는 아래와 똑같습니다:
article {
contain: size layout paint style;
contain-intrinsic-size: 80vw auto none;
}
content-visibility블로그 홈페이지에서 무한 스크롤로 모든 게시글을 볼 수 있는 경우처럼, 당장 화면 밖(offscreen)에 있어서 렌더링이 필요 없지만 나중에 보여주기 위해 강력한 격리(heavy containment)의 이점을 누리고 싶은 콘텐츠가 아주 많을 때가 있습니다. 이럴 때는 content-visibility: auto를 사용하여 모든 컨테인먼트 기능과 렌더링 지연을 한 번에 적용할 수 있습니다.
content-visibility 속성은 요소가 콘텐츠를 렌더링할지 말지 자체를 제어함과 동시에, 아주 강력한 수준의 컨테인먼트 조합을 강제로 적용합니다. 덕분에 브라우저는 화면에 당장 보이지 않는 수많은 레이아웃 계산과 페인팅 작업을 진짜 필요해질 때까지 아예 생략해버릴 수 있습니다. 이 기능은 초기 페이지 로딩 속도를 엄청나게 빠르게 만들어 줍니다.
👨🏫 강사의 실무 팁! "무한 스크롤의 구세주"
content-visibility: auto;는 최근 프론트엔드 최적화에서 가장 핫한 속성 중 하나입니다. 무한 스크롤로 수백 개의 요소가 DOM에 렌더링되어 있으면 화면이 버벅거리게 되는데, 이 속성 하나만 줘도 화면 밖 요소들은 브라우저가 그리지 않고 무시하기 때문에 60fps의 부드러운 스크롤을 유지할 수 있습니다.
사용할 수 있는 값들은 다음과 같습니다:
visible: 기본 동작입니다. 요소의 내용이 정상적으로 계산되고 그려집니다.hidden: 요소가 자신의 콘텐츠를 건너뜁니다. 건너뛴 콘텐츠는 '페이지 내 찾기(Ctrl+F)', 'Tab 키를 이용한 포커스 이동' 등 브라우저 기능의 접근성에서도 제외되며, 드래그해서 선택하거나 포커스할 수도 없게 됩니다.auto: contain: content를 설정한 것처럼 레이아웃, 스타일, 페인트 컨테인먼트를 켭니다. 만약 해당 요소가 사용자에게 유의미한 상태가 아니라면, 요소의 내용 렌더링을 완전히 건너뜁니다(skips). 하지만 hidden 값과는 다르게, 렌더링을 건너뛰었더라도 사용자 상호작용(포커스, 텍스트 선택, 일반적인 Tab 이동, 페이지 내 찾기 등)에서는 정상적으로 접근이 가능합니다.브라우저(사용자 에이전트)는 콘텐츠가 사용자에게 유의미한지(relevant to the user) 여부를 스스로 판단합니다. 만약 아래의 조건 중 하나라도 충족되면 그 요소는 "사용자에게 유의미한 상태"가 됩니다.
content-visibility: auto가 설정되어 있고, 브라우저가 판단하기에 이 콘텐츠가 "사용자에게 유의미하다"고 결정되면 비로소 브라우저는 그 콘텐츠를 화면에 렌더링합니다.
요소에 content-visibility: hidden을 설정하면, 브라우저에게 "이 요소는 지금 사용자에게 중요하지 않아"라고 말해주는 것과 같습니다. 따라서 브라우저는 이 요소의 콘텐츠 렌더링을 건너뛰고 그리지 않습니다. 이로 인해 성능이 크게 향상됩니다.
또한 요소에 content-visibility: auto를 설정해 둔 경우에도, 브라우저가 판단하기에 해당 콘텐츠가 사용자에게 유의미하지 않다(not relevant)고 판단되면 똑같이 렌더링을 건너뜁니다.
요소가 콘텐츠 렌더링을 건너뛸 때 일어나는 현상은 다음과 같습니다:
visibility: hidden을 설정한 것처럼 내부 콘텐츠가 그려지지(painted) 않습니다.pointer-events: none을 설정한 것처럼 내부 콘텐츠가 마우스나 터치 등의 포인터 이벤트를 받지 못합니다.이러한 현상은 위에서 말한 두 가지 경우(hidden, auto) 모두에서 동일하게 발생합니다. 하지만 content-visibility: auto의 경우, 렌더링이 생략된 상태라도 사용자가 페이지 내 검색을 하거나 포커스를 이동하면 콘텐츠가 "유의미한 상태"로 전환되어 화면에 나타날 수 있습니다. 반면 content-visibility: hidden은 사용자가 탐색하더라도 절대 렌더링되지 않습니다. (display: none과 비슷하지만 렌더링 비용을 저장해둔다는 점에서 차이가 있습니다.)
참고:
요소가content-visibility: hidden상태에서 화면에 보여지는 상태로 넘어갈 때 애니메이션(transition)을 주고 싶다면,transition-behavior: allow-discrete와@starting-style속성을 설정해야 합니다. 자세한 내용은display와content-visibility트랜지션 적용하기 문서를 참고해 보세요.