컨테이너 쿼리: 미디어 쿼리로는 부족한 순간
이 글은 React로 반응형 웹 구성하기 시리즈 4편입니다.
지금까지의 모든 반응형은 한 가지 기준을 공유했습니다. 바로 뷰포트 폭입니다. 미디어 쿼리도, vw도, auto-fit도 결국 "화면이 얼마나 넓은가"를 봤습니다.
그런데 실무에서 자주 막히는 지점이 있습니다. 같은 카드 컴포넌트인데, 놓이는 위치에 따라 이상적인 레이아웃이 다른 경우입니다.
- 넓은 본문 영역에 놓이면 → 이미지와 텍스트를 좌우로 배치
- 좁은 사이드바에 놓이면 → 이미지 위, 텍스트 아래로 세로 배치
문제는, 좁은 사이드바가 넓은 데스크톱 화면 안에 있을 수 있다는 점입니다. 뷰포트는 1440px인데 사이드바는 280px입니다. 이때 미디어 쿼리로는 답이 안 나옵니다. 미디어 쿼리는 뷰포트만 보기 때문에 "1440px = 넓음"으로 판단해 사이드바 카드까지 좌우 배치로 만들어 버립니다.
이 문제를 정면으로 푸는 게 컨테이너 쿼리입니다.
한눈에 보면
- 미디어 쿼리는 뷰포트 기준, 컨테이너 쿼리는 컴포넌트가 놓인 부모(컨테이너) 기준으로 반응합니다.
- 덕분에 같은 컴포넌트를 어디에 놓아도 자기 폭에 맞게 스스로 바뀝니다. 진짜 재사용 가능한 반응형 컴포넌트가 됩니다.
- 부모에
container-type: inline-size를 선언하고, 자식에서@container (min-width: ...)로 조건을 겁니다. cqi/cqw같은 컨테이너 기준 단위도 함께 씁니다.- 2023년 이후 모든 주요 브라우저에서 지원됩니다(Baseline). 다만 컨테이너로 지정하면 그 요소의 크기 계산 방식이 바뀌는 점은 알아둬야 합니다.
- 미디어 쿼리를 대체하는 게 아니라, 컴포넌트 단위 반응은 컨테이너 쿼리 / 페이지 골격은 미디어 쿼리로 역할을 나눕니다.
뷰포트 기준 vs 컨테이너 기준
두 방식의 판단 기준이 어떻게 다른지부터 보겠습니다.
flowchart TD
q["카드의 레이아웃을 언제 바꿀까?"]
q --> media["미디어 쿼리<br/>= 뷰포트 폭을 본다"]
q --> container["컨테이너 쿼리<br/>= 부모(컨테이너) 폭을 본다"]
media --> mp["뷰포트 1440px<br/>→ '넓다'고 판단"]
mp --> mfail["사이드바(280px) 안의<br/>카드까지 좌우 배치로<br/>→ 깨짐"]
container --> cp["부모가 280px<br/>→ '좁다'고 판단"]
cp --> cok["사이드바 카드는 세로 배치<br/>본문 카드는 좌우 배치<br/>→ 각자 맞게"]
style mfail fill:#fee2e2,stroke:#ef4444,color:#111
style cok fill:#dcfce7,stroke:#22c55e,color:#111핵심 차이는 "무엇의 폭을 보느냐"입니다. 미디어 쿼리는 컴포넌트가 어디에 놓였든 항상 뷰포트만 보고, 컨테이너 쿼리는 그 컴포넌트를 감싼 컨테이너의 폭을 봅니다.
컨테이너 쿼리 기본 문법
컨테이너 쿼리는 두 단계로 씁니다. 먼저 부모를 컨테이너로 등록하고, 자식에서 컨테이너 조건을 겁니다.
/* 1. 부모를 컨테이너로 등록 */
.card-slot {
container-type: inline-size;
/* 필요하면 이름도 부여 */
container-name: card;
}
/* 2. 자식에서 컨테이너 폭 기준으로 조건 */
.card {
display: grid;
gap: 12px;
grid-template-columns: 1fr; /* 기본: 세로 배치 */
}
@container card (min-width: 400px) {
.card {
grid-template-columns: 160px 1fr; /* 컨테이너가 400px 이상이면 좌우 배치 */
}
}몇 가지 짚을 점이 있습니다.
container-type: inline-size: 가로(인라인) 방향 크기만 컨테이너 기준으로 삼습니다. 반응형은 대부분 가로 기준이라 이 값을 가장 많이 씁니다.container-type: size: 가로와 세로 모두를 기준으로 삼습니다. 다만 이 경우 높이도 컨테이너 기준이 되어 콘텐츠에 따라 높이가 자라던 요소가 영향을 받을 수 있어 주의가 필요합니다.@container card (min-width: 400px): 여기서400px은 뷰포트가 아니라 가장 가까운card컨테이너의 폭입니다.
컨테이너 기준 단위: cqi, cqw
미디어 쿼리 세계에 vw가 있다면, 컨테이너 쿼리 세계에는 cqi(inline)와 cqw(width)가 있습니다. 컨테이너 폭의 1%를 뜻합니다.
@container card (min-width: 400px) {
.card-title {
/* 컨테이너 폭에 비례하되, 2편처럼 상·하한을 건다 */
font-size: clamp(1rem, 5cqi, 1.5rem);
}
}폰트나 여백을 "뷰포트가 아니라 이 컴포넌트가 차지한 폭에 비례"하게 만들 수 있습니다.
React 예시: 어디에 놓아도 맞는 카드
컨테이너 쿼리의 진가는 하나의 컴포넌트를 여러 위치에 그대로 재사용할 때 드러납니다. 같은 <ProductCard />를 본문과 사이드바에 넣어 보겠습니다.
/* ProductCard.module.css */
.slot {
container-type: inline-size;
}
.card {
display: grid;
gap: 12px;
grid-template-columns: 1fr; /* 좁을 때: 이미지 위, 텍스트 아래 */
}
.thumb {
width: 100%;
aspect-ratio: 16 / 9;
object-fit: cover;
}
/* 컨테이너가 380px 이상이면 좌우 배치로 전환 */
@container (min-width: 380px) {
.card {
grid-template-columns: 140px 1fr;
align-items: center;
}
.thumb {
aspect-ratio: 1 / 1;
}
}import styles from './ProductCard.module.css';
type Product = { title: string; price: string; image: string };
export function ProductCard({ product }: { product: Product }) {
return (
<div className={styles.slot}>
<article className={styles.card}>
<img className={styles.thumb} src={product.image} alt="" />
<div>
<h3>{product.title}</h3>
<p>{product.price}</p>
</div>
</article>
</div>
);
}이제 이 카드를 어디에 놓든 컴포넌트 코드를 바꿀 필요가 없습니다.
export function Page() {
return (
<div className="layout">
<main style={{ width: '640px' }}>
{/* 넓은 컨테이너 → 자동으로 좌우 배치 */}
<ProductCard product={product} />
</main>
<aside style={{ width: '280px' }}>
{/* 좁은 컨테이너 → 자동으로 세로 배치 */}
<ProductCard product={product} />
</aside>
</div>
);
}같은 컴포넌트, 같은 props인데 놓인 곳의 폭에 따라 알아서 다르게 배치됩니다. 아래처럼요.
● 본문(640px) 안의 카드 — 컨테이너가 넓음 → 좌우 배치
+---------------------------------+
| +-------+ 제목 |
| | 이미지| 가격 |
| +-------+ |
+---------------------------------+
● 사이드바(280px) 안의 카드 — 컨테이너가 좁음 → 세로 배치
+----------------+
| +------------+ |
| | 이미지 | |
| +------------+ |
| 제목 |
| 가격 |
+----------------+Tailwind CSS에서 쓰기
Tailwind CSS v3 기준으로는 @tailwindcss/container-queries 플러그인을 추가하면 @container를 유틸리티로 쓸 수 있습니다. (v4에서는 기본 내장입니다. 사용 중인 버전에 맞춰 설정을 확인하세요.)
// 플러그인 설치 후: 부모에 @container, 자식에 컨테이너 브레이크포인트 접두사(@sm 등)
export function ProductCard({ product }: { product: Product }) {
return (
<div className="@container">
<article className="grid grid-cols-1 gap-3 @sm:grid-cols-[140px_1fr] @sm:items-center">
<img
className="aspect-video w-full object-cover @sm:aspect-square"
src={product.image}
alt=""
/>
<div>
<h3>{product.title}</h3>
<p>{product.price}</p>
</div>
</article>
</div>
);
}여기서 컨테이너 접두사 @sm은 **컨테이너 폭 24rem(384px)**을 뜻해, 위 CSS 예시의 380px과 거의 같은 지점입니다. 주의할 점은 이 @sm이 뷰포트 브레이크포인트 sm(640px)과 값이 다르다는 것입니다. 컨테이너 접두사(@sm)와 뷰포트 접두사(sm)는 이름이 비슷하지만 기준도 값도 다르니 혼동하지 않도록 합니다.
미디어 쿼리와 어떻게 나눠 쓸까
컨테이너 쿼리가 미디어 쿼리를 없애는 건 아닙니다. 둘은 보는 대상이 달라서 역할을 나눠 갖습니다.
| 관점 | 미디어 쿼리 (@media) |
컨테이너 쿼리 (@container) |
|---|---|---|
| 기준 | 뷰포트 폭 | 부모 컨테이너 폭 |
| 잘 맞는 대상 | 페이지 골격, 전역 레이아웃 | 재사용되는 개별 컴포넌트 |
| 재사용성 | 놓이는 위치에 따라 깨질 수 있음 | 어디에 놓아도 자기 폭에 맞게 반응 |
| 설정 비용 | 없음 | 부모에 container-type 선언 필요 |
| 주의점 | 컨테이너 문맥을 모름 | size 지정 시 높이 계산 방식이 바뀜 |
| 브라우저 지원 | 오래전부터 전면 지원 | 2023년 이후 주요 브라우저(Baseline) |
정리하면 이렇게 나누는 게 깔끔합니다.
- 페이지 전체 골격(헤더/사이드바/본문 배치, 데스크톱↔모바일 전환) → 미디어 쿼리
- 여러 곳에서 재사용되는 컴포넌트(카드, 위젯, 미디어 오브젝트) → 컨테이너 쿼리
정리하면
컨테이너 쿼리는 반응형의 기준을 "화면"에서 "컴포넌트가 놓인 자리"로 옮깁니다.
- 미디어 쿼리는 뷰포트, 컨테이너 쿼리는 부모 폭 — 사이드바 안의 카드 문제는 컨테이너 쿼리로만 깔끔하게 풀립니다.
- 부모에
container-type: inline-size, 자식에@container (min-width: ...)가 기본 패턴입니다. - 컨테이너 기준 단위
cqi/cqw로 폰트·여백도 컴포넌트 폭에 비례시킬 수 있습니다. - 역할 분담: 페이지 골격은 미디어 쿼리, 재사용 컴포넌트는 컨테이너 쿼리.
컨테이너 쿼리 덕분에 "어디에 놓아도 알아서 맞는" 컴포넌트를 CSS만으로 만들 수 있게 됐습니다. 그렇다면 이 반응형 규칙들을 React 프로젝트에서 어떤 스타일링 도구로 표현하는 게 좋을까요? 다음 글에서는 Tailwind, CSS-in-JS, CSS Modules를 반응형을 표현하는 관점에서 비교합니다.
