CSS 및 레이아웃

컨테이너 쿼리: 뷰포트가 아니라 자신의 컨테이너를 아는 컴포넌트

컨테이너 쿼리: 뷰포트가 아니라 자신의 컨테이너를 아는 컴포넌트

상품 카드가 하나 있습니다. 제목 위에 이미지, 가격, "장바구니에 담기" 버튼. 쇼핑몰 메인 그리드에서는 화면 너비가 768px를 넘으면 미디어 쿼리로 가로 레이아웃으로 전환합니다 – 왼쪽에 이미지, 오른쪽에 내용, 그렇게 하는 편이 주어진 너비를 더 잘 활용하기 때문입니다. 완벽하게 작동합니다. 그런데 제품 담당자가 같은 카드를 보고는 기사 본문 옆의 좁은 "추천 상품" 사이드바에도 넣고 싶어합니다. 컴포넌트를 아무 수정 없이 그대로 던져 넣으면 – 무너집니다. 700px 이상을 염두에 두고 설계된 가로 레이아웃이 260px짜리 컬럼에 억지로 들어가려 합니다. 이미지는 얇은 띠로 짓눌리고, 텍스트는 한 줄에 단어 하나씩 줄바꿈됩니다.

CSS를 잘못 짠 게 아닙니다. 미디어 쿼리는 정확히 의도한 대로 작동하고 있습니다 – 다만 잘못된 질문에 답하고 있을 뿐입니다. @media (min-width: 768px)은 이렇게 묻습니다. "뷰포트가 얼마나 넓은가?" 상품 카드가 제대로 보이기 위해 정말 신경 써야 할 것은 뷰포트의 너비가 아니라 부모로부터 받은 공간이 얼마나 되는가입니다. 이 둘은 완전히 다른 질문인데, CSS는 20년 동안 이를 하나로 취급해 왔습니다. 두 번째 질문을 던질 방법 자체가 없었기 때문입니다.

미디어 쿼리로는 충분하지 않은 이유

미디어 쿼리는 전역적인 질문입니다. 컴포넌트가 1200px짜리 컬럼에 있든, 260px짜리 좁은 사이드 카드에 있든 상관없이 – @media (min-width: 768px)은 두 경우 모두 똑같이 true를 반환합니다. 요소의 부모가 아니라 브라우저 창을 묻고 있기 때문입니다. 그래서 미디어 쿼리에만 의존해 작성된 컴포넌트는 처음 설계된 맥락에서만 올바르게 동작하며, 같은 코드가 다른 곳으로 옮겨지는 순간 더 이상 올바르지 않게 됩니다. 이는 컴포넌트가 지켜야 할 근본적인 약속, 즉 레이아웃의 어느 위치에서든 안전하게 재사용할 수 있어야 한다는 약속을 근본적으로 무너뜨립니다.

2023년 이전에는 유일하게 현실적인 우회 방법이 JavaScript의 ResizeObserver였습니다 – 요소를 관찰하고, 크기가 바뀔 때마다 너비를 측정하고, 그 결과를 상태에 저장하고, 조건에 따라 CSS 클래스를 붙이는 방식입니다. 작동은 하지만 대가가 따릅니다. 컴포넌트에 추가되는 상태, 크기가 바뀔 때마다 발생하는 리렌더링, 컴포넌트마다 일일이 손으로 작성해야 하는 코드, 그리고 JavaScript가 실행되기 전까지는 존재하지 않는다는 문제까지. 그래서 서버 사이드 렌더링에서는 ResizeObserver가 무언가를 측정할 틈도 없이 첫 프레임이 "눈 감은 채로" 레이아웃을 보여주게 됩니다.

jsx

// Container Queries 이전의 우회법 – 작동은 하지만 대가가 따른다
import { useEffect, useRef, useState } from "react";
const ProductCard = ({ product }) => {
const cardRef = useRef(null);
const [isWide, setIsWide] = useState(false);
useEffect(() => {
const el = cardRef.current;
const observer = new ResizeObserver((entries) => {
setIsWide(entries[0].contentRect.width > 320);
});
observer.observe(el);
return () => observer.disconnect();
}, []);
return (
<article ref={cardRef} className={`card ${isWide ? "card--wide" : ""}`}>
{/* ... */}
</article>
);
};

컨테이너 쿼리는 JavaScript 코드 단 한 줄, 상태 하나, 리렌더링 하나 없이 같은 문제를 해결합니다 – "내가 쓸 수 있는 공간이 얼마나 되는가"라는 질문이 CSS가 원래 그 질문을 던지던 곳, 즉 스타일시트로 되돌아가기 때문입니다.

실제로 내부에서 어떻게 작동하는가: 컨테인먼트, 단순한 새 문법이 아니다

@container 문법 자체는 이름만 다른 @media처럼 보입니다 – 하지만 진짜 차이는 다른 곳에 있습니다. 브라우저가 이런 쿼리에 답하는 것을 애초에 허락하기 전에 반드시 일어나야 하는 일이 있다는 점입니다. 어떤 요소가 "쿼리 컨테이너"가 되려면 그렇다고 명시적으로 선언해야 합니다.

css

.card-slot {
container-type: inline-size;
container-name: card;
}
@container card (min-width: 400px) {
.card {
grid-template-columns: 40% 1fr;
}
}

container-type: inline-size는 단순히 쿼리 모드를 켜는 스위치가 아니라 – 별도의 명세인 CSS Containment를 선언하는 것입니다. 이는 브라우저에게 이렇게 말합니다. "이 요소의 콘텐츠는 인라인 축 크기에 영향을 주지 않으니, 레이아웃을 계산할 때 이 요소를 독립된 섬처럼 안전하게 취급해도 좋다." 이는 의미 없는 구현 세부 사항이 아니라, 컨테이너 쿼리가 애초에 무한 루프 없이 존재할 수 있기 위한 필수 조건입니다. 만약 브라우저가 요소로 하여금 자신의 너비에 반응해 크기를 바꾸는 동시에 자신의 콘텐츠를 통해 그 너비에 영향을 주는 것을 동시에 허용한다면, 순환 의존성이 생겨납니다. 콘텐츠가 컨테이너의 크기를 바꾼다 → 크기 변화가 @container를 전환시킨다 → 새 규칙이 콘텐츠를 바꾼다 → 콘텐츠가 다시 컨테이너 크기를 바꾼다. 컨테인먼트는 이 순환을 근원에서 끊어버립니다. 특정 축에서 컨테이너의 크기는 내부에 무엇이 있든 독립적으로 결정되도록 강제하는 것입니다.

이는 container-type 값을 고를 때 구체적이고 실질적인 결과로 이어집니다.

  • inline-size – 인라인 축(보통 가로 방향)에만 컨테인먼트를 적용합니다. 요소의 높이는 여전히 콘텐츠에 따라 자유롭게 결정됩니다. 상품 카드 예제에서처럼, 95%의 경우에 사용하게 되는 값입니다.
  • size – 양쪽 축 모두에 컨테인먼트를 적용합니다. 요소는 크기를 정할 때 콘텐츠에 전혀 의존하지 않게 되므로, 반드시 heightaspect-ratio 등으로 명시적인 높이를 지정해야 합니다 – 그러지 않으면 컨테인먼트가 요소를 콘텐츠의 자연스러운 높이로부터 차단해버려 높이가 사실상 0으로 줄어듭니다.
  • normal – 기본값으로, 컨테인먼트가 적용되지 않으며 해당 요소는 쿼리 컨테이너가 되지 않습니다.

container-name은 선택 사항이지만 습관으로 들일 가치가 있습니다 – 이것이 없으면 @container (min-width: 400px)은 그것이 무엇이든 가장 가까운 컨테이너 조상에게 질문을 던집니다. 단순한 구조의 컴포넌트라면 별문제가 되지 않지만, 중첩된 레이아웃(패널 안의 카드, 컬럼 안의 패널 – 패널도 컬럼도 모두 컨테이너인 경우)에서는 이름이 붙은 컨테이너가 DOM 트리 상의 우연한 가까움에 기대는 대신, 정확히 어느 조상에게 묻고 있는지를 명확히 해줍니다.

실전 예제: 카드 하나, 두 가지 맥락, JavaScript는 제로

서두에서 등장한 상품 카드로 돌아가 봅니다 – 이번에는 같은 컴포넌트가 자신이 어디에 삽입되었는지 전혀 알지 못한 채로도, 넓은 그리드와 좁은 사이드바 양쪽 모두에서 올바르게 렌더링되도록 만들어 보겠습니다.

jsx

const ProductCard = ({ product }) => (
<div className="product-card-slot">
<article className="product-card">
<img
src={product.image}
alt={product.name}
className="product-card__image"
/>
<div className="product-card__body">
<h3 className="product-card__title">{product.name}</h3>
<p className="product-card__price">{product.price}</p>
<button type="button" className="product-card__cta">
장바구니에 담기
</button>
</div>
</article>
</div>
);
export default ProductCard;

css

/* 컴포넌트의 부모가 쿼리 컨테이너로 선언된다 */
.product-card-slot {
container-type: inline-size;
container-name: product-card;
}
/* 기본 레이아웃: 이미지가 콘텐츠 위에 – 좁은 공간에서 안전하다 */
.product-card {
display: grid;
grid-template-columns: 1fr;
gap: 12px;
}
.product-card__image {
width: 100%;
aspect-ratio: 4 / 3;
object-fit: cover;
border-radius: 8px;
}
/* 뷰포트가 아니라 컨테이너 너비가 360px를 넘으면 – 가로 레이아웃으로 전환 */
@container product-card (min-width: 360px) {
.product-card {
grid-template-columns: 40% 1fr;
align-items: center;
}
.product-card__image {
aspect-ratio: 1 / 1;
height: 100%;
}
}

여기서 핵심 결정은 CSS가 아니라 구조 속에 숨어 있습니다. container-type은 카드 자체인 .product-card가 아니라 카드의 부모.product-card-slot에 놓여 있습니다. 이는 우연도 아니고 과한 스타일도 아닙니다 – 명세는 요소가 @container를 통해 자기 자신의 크기를 조회하는 것을 명시적으로 금지합니다(다시 그 순환 의존성 문제입니다. 요소는 컨테이너를 정의하는 동시에 자신의 크기에 반응해 스타일링될 수 없습니다). 그래서 일반적으로 반복되는 패턴은 바깥에 얇은 래퍼 컨테이너를 두고, 그 안에 실제 컴포넌트를 두어 @container 블록 안에서 스타일을 입히는 것입니다. 같은 CSS 파일을 상품 그리드(슬롯이 480px인 곳)와 사이드바(슬롯이 240px인 곳)에 각각 한 번씩 넣으면, prop도, 수정자 클래스도, JavaScript 코드 한 줄도 없이 서로 다른 두 가지 올바른 레이아웃이 만들어집니다.

컨테이너 단위: 뷰포트 없이도 얻는 유동성

컨테이너 쿼리는 같은 명세에 속한, 덜 알려진 두 번째 요소도 함께 가져왔습니다. 뷰포트가 아니라 컨테이너를 기준으로 한 상대 단위들입니다 – cqw(컨테이너 너비의 1%), cqh(높이의 1%), cqicqb(인라인/블록 축의 1%로, 세로쓰기 언어에서도 올바르게 동작합니다), 그리고 cqmin/cqmaxvmin/vmax와 비슷하지만 컨테이너의 더 작은 쪽 혹은 더 큰 쪽 치수를 기준으로 계산됩니다.

이들의 자연스러운 활용처는 사용자의 화면이 얼마나 넓은지와 무관하게, 컴포넌트 내부에서 이루어지는 유동적인 타이포그래피입니다.

css

.product-card__title {
/* 화면 너비가 아니라 카드 너비에 맞춰 크기가 조정된다 */
font-size: clamp(1rem, 0.85rem + 2cqi, 1.375rem);
}

이미 익숙한 vw 단위를 활용한 clamp() 트릭과의 차이는 미묘하지만 실제로는 근본적입니다. vw로 크기가 조정되는 제목은 페이지 전체와 함께 크기가 바뀌므로, 같은 카드의 두 사본 – 하나는 전체 너비 그리드에, 다른 하나는 좁은 사이드바에 – 이 동일한 글자 크기를 갖게 됩니다. 둘 다 같은 뷰포트를 보고 있기 때문입니다. 반면 cqi로 크기가 조정되는 제목은 그 특정 카드에 실제로 주어진 공간에 반응합니다 – 뷰포트 너비는 계속 똑같더라도 사이드바에서는 더 작게, 그리드에서는 더 크게 나타납니다.

함정과 좋은 습관

  • 명시적 높이 없는 container-type: size는 콘텐츠를 집어삼킵니다. 블록 축의 컨테인먼트는 요소를 자식의 자연스러운 높이로부터 차단합니다 – heightaspect-ratio를 지정하지 않으면 컨테이너는 사실상 높이가 0으로 줄어들고, 자식 요소들은 DOM에는 여전히 존재하지만 화면에서는 시각적으로 사라집니다. 대부분의 경우 inline-size만으로 충분하며 이런 문제 자체가 생기지 않습니다.
  • 컨테이너는 자기 자신을 조회할 수 없습니다. @container가 "작동하지 않는다"고 씨름하고 있다면 가장 먼저 의심해야 할 것이 바로 이것입니다. container-type@container로 스타일링되는 선택자가 같은 요소에 놓여 있는 경우입니다. 위의 상품 카드 예제처럼 이 둘을 분리하십시오 – container-type은 래퍼에, 대상 스타일은 자식 요소에 두어야 합니다.
  • 컨테이너 쿼리는 미디어 쿼리를 대체하는 것이 아니라 보완합니다. 미디어 쿼리는 여전히 페이지 수준의 결정(사이드바를 아예 보여줄지, 내비게이션을 햄버거 메뉴로 바꿀지 등)에 적합한 도구입니다. 컨테이너 쿼리는 그 컴포넌트가 어디에 배치되든 컴포넌트 수준의 결정을 담당합니다. 2026년의 좋은 레이아웃은 보통 둘 다 사용하며, 각자가 실제로 던지는 질문에 맞는 자리에서 사용합니다.
  • 모든 div를 "혹시 몰라서" 컨테이너로 만들지 마십시오. 컨테인먼트는 렌더링 엔진에 실질적인 비용을 발생시킵니다 – "나를 독립된 레이아웃 단위로 취급하라"는 신호이며, 실제로 자신의 크기에 반응하는 컴포넌트가 있을 때 유용한 것이지, DOM 트리 전체에 기본값으로 걸어둘 설정이 아닙니다.
  • 브라우저 지원은 더 이상 문제가 아닙니다. container-type@container는 2023년 초에 베이스라인(Baseline, 널리 지원됨) 상태에 도달했습니다 – 폴리필도 @supports도 없이 Chrome, Safari, Firefox에서 네이티브로 동작합니다. cqw/cqi를 비롯한 나머지 단위들도 정확히 같은 지원 범위를 갖습니다.

마무리

재사용 가능한 컴포넌트란 자신이 놓일 수 있는 모든 위치를 일일이 처리할 만큼 충분한 prop과 수정자 클래스를 갖춘 컴포넌트가 아니라, 자신에게 얼마만큼의 공간이 주어졌는지 스스로 알고 아무 도움 없이 그에 반응하는 컴포넌트입니다. 미디어 쿼리는 20년 동안 CSS의 유일한 반응형 도구였기 때문에, 사실은 뷰포트가 아니라 부모가 문제였던 상황에서도 자연스럽게 남용되어 왔습니다. 컨테이너 쿼리는 같은 메커니즘의 또 다른 문법적 변형이 아니라, DOM 트리의 올바른 위치에서 던지는 다른 질문이며, 그 답이 결코 순환에 빠지지 않도록 보장하는 컨테인먼트가 뒷받침합니다. 이 글의 코드 – 컴포넌트 하나, CSS 파일 하나, JavaScript 제로 – 는 그리드에서도, 사이드바에서도, 그리고 아직 여러분이 언젠가 그곳에 끼워 넣게 될 줄 모르는 그 어떤 다른 자리에서도 올바르게 동작합니다.