Prologue: 미디어 쿼리의 불편한 진실
반응형 웹 개발을 하다 보면 어느 순간 이런 상황을 만나게 돼.
ProductCard 컴포넌트를 만들었어. 사이드바에서는 세로로 쌓여야 하고, 메인 콘텐츠 영역에서는 가로로 나란히 놓여야 해. 대시보드에서는 3열, 모바일에서는 1열.
미디어 쿼리로 해결하려니:
/* 뷰포트 너비 기준 */
@media (max-width: 768px) {
.product-card { flex-direction: column; }
}
@media (min-width: 769px) and (max-width: 1024px) {
.sidebar .product-card { flex-direction: column; }
.main-content .product-card { flex-direction: row; }
}
/* 이것도 사이드바 열림/닫힘 상태엔 안 맞아... */
뷰포트 너비는 알 수 있는데, 컴포넌트가 실제로 얼마나 넓은 공간에 있는지는 모르잖아. 이게 미디어 쿼리의 근본적인 한계야.
Container Queries는 이 문제를 해결하기 위해 태어났다.
핵심 개념: 뷰포트가 아닌 부모를 보는 쿼리
비유로 이해하기
미디어 쿼리는 마치 건물 전체의 평수를 보고 방 인테리어를 결정하는 것과 같아. 건물이 100평이든 200평이든 상관없이, 방 자체가 얼마나 큰지가 중요한데 말이야.
Container Queries는 각 방의 크기를 직접 측정해서 그 방에 맞는 인테리어를 결정하는 방식이야.
/* 미디어 쿼리: 뷰포트(건물 전체)를 본다 */
@media (min-width: 768px) {
.card { display: flex; }
}
/* Container Query: 부모 컨테이너(방)를 본다 */
@container card-wrapper (min-width: 400px) {
.card { display: flex; }
}
@container 문법 완전 분석
기본 구조
/* 1단계: 컨테이너 정의 */
.card-wrapper {
container-type: inline-size;
/* 또는 container-name까지 함께 */
container-name: card-wrapper;
/* 단축 속성 */
container: card-wrapper / inline-size;
}
/* 2단계: 컨테이너 쿼리 작성 */
@container card-wrapper (min-width: 400px) {
.card {
display: flex;
flex-direction: row;
}
}
container-type 옵션
| 값 | 의미 | 쿼리 가능 축 |
|---|---|---|
inline-size | 인라인 방향(보통 가로) 크기 기준 | 너비 |
size | 가로 + 세로 모두 기준 | 너비, 높이 |
normal | 기본값, 쿼리 불가 | 없음 |
대부분의 경우 inline-size를 쓰면 돼. size는 높이도 고려할 때만.
/* 가장 흔한 패턴 */
.container {
container-type: inline-size;
}
/* 이름 붙이기 (권장) */
.sidebar {
container-type: inline-size;
container-name: sidebar;
}
/* 단축형 */
.card-list {
container: card-list / inline-size;
}
이름 없는 컨테이너
/* container-name 없이도 쓸 수 있다 */
.wrapper {
container-type: inline-size;
}
/* 이름 없이 쿼리하면 가장 가까운 컨테이너 조상을 봄 */
@container (min-width: 400px) {
.card { display: flex; }
}
이름이 없으면 DOM 트리에서 가장 가까운 container-type이 설정된 조상을 참조해.
실전: 카드 컴포넌트 예시
문제 상황
<!-- 같은 컴포넌트, 다른 맥락 -->
<main>
<!-- 메인 그리드: 넓은 공간 -->
<div class="main-grid">
<div class="card-wrapper">
<article class="product-card">...</article>
</div>
</div>
</main>
<aside>
<!-- 사이드바: 좁은 공간 -->
<div class="sidebar">
<div class="card-wrapper">
<article class="product-card">...</article>
</div>
</div>
</aside>
Container Queries로 해결
/* 컨테이너 설정 */
.card-wrapper {
container-type: inline-size;
container-name: product-card;
}
/* 기본 스타일: 좁은 공간 (세로 레이아웃) */
.product-card {
display: grid;
grid-template-rows: auto 1fr auto;
border-radius: 12px;
overflow: hidden;
background: white;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
.product-card__image {
width: 100%;
aspect-ratio: 16/9;
object-fit: cover;
}
.product-card__content {
padding: 1rem;
}
.product-card__title {
font-size: 1rem;
margin-bottom: 0.5rem;
}
.product-card__price {
font-size: 1.2rem;
font-weight: bold;
color: #e44;
}
/* 400px 이상: 가로 레이아웃 */
@container product-card (min-width: 400px) {
.product-card {
display: grid;
grid-template-columns: 200px 1fr;
grid-template-rows: 1fr auto;
}
.product-card__image {
grid-row: 1 / 3;
aspect-ratio: 3/4;
height: 100%;
}
.product-card__title {
font-size: 1.2rem;
}
}
/* 600px 이상: 더 풍부한 레이아웃 */
@container product-card (min-width: 600px) {
.product-card {
grid-template-columns: 300px 1fr;
}
.product-card__content {
padding: 1.5rem;
}
.product-card__title {
font-size: 1.5rem;
}
/* 이 크기에서만 설명 텍스트 표시 */
.product-card__description {
display: block;
color: #666;
margin-bottom: 1rem;
}
}
이제 같은 카드가 사이드바에서는 세로로, 메인 그리드에서는 가로로, 넓은 영역에서는 설명까지 보여주는 풍부한 레이아웃으로 렌더링돼. 뷰포트 너비와 무관하게, 컨테이너 크기만 보고.
container-name의 위력: 중첩 컨테이너
여러 컨테이너가 중첩될 때 이름이 중요해져.
/* 두 개의 컨테이너 설정 */
.page-layout {
container: page / inline-size;
}
.sidebar {
container: sidebar / inline-size;
}
.widget {
container: widget / inline-size;
}
/* 이름으로 특정 컨테이너 참조 */
@container page (min-width: 1200px) {
.sidebar { width: 300px; }
}
@container sidebar (max-width: 250px) {
.widget { padding: 0.5rem; }
}
@container widget (min-width: 200px) {
.widget-content { display: flex; }
}
이름 없이 쿼리하면 항상 가장 가까운 조상 컨테이너를 참조해. 명시적으로 특정 컨테이너를 참조하고 싶으면 이름을 써야 해.
Container Query Units: cqw, cqh, cqi, cqb
Container Queries에는 전용 단위가 있어. 뷰포트 기반 vw, vh의 컨테이너 버전이야.
| 단위 | 의미 |
|---|---|
cqw | 컨테이너 너비의 1% |
cqh | 컨테이너 높이의 1% |
cqi | 컨테이너 인라인 크기의 1% |
cqb | 컨테이너 블록 크기의 1% |
cqmin | cqi와 cqb 중 더 작은 값 |
cqmax | cqi와 cqb 중 더 큰 값 |
/* 실용적인 예시 */
.card-title {
/* 컨테이너 너비에 따라 폰트 크기 자동 조절 */
font-size: clamp(1rem, 4cqw, 2rem);
}
.card-image {
/* 컨테이너 너비의 40% */
width: 40cqw;
}
.card-padding {
/* 컨테이너 크기에 비례한 패딩 */
padding: 2cqi;
}
특히 clamp() + cqw 조합은 컨테이너 크기에 따라 부드럽게 변하는 유체 타이포그래피를 구현할 때 강력해:
.hero-title {
/* 최소 1.5rem, 최대 3rem, 그 사이는 컨테이너 너비 6%에 비례 */
font-size: clamp(1.5rem, 6cqw, 3rem);
}
미디어 쿼리와 조합하기
Container Queries가 미디어 쿼리를 대체하는 게 아니야. 서로 다른 문제를 해결해.
역할 분담
/* 미디어 쿼리: 전체 레이아웃 구조 */
@media (min-width: 1024px) {
.page-layout {
display: grid;
grid-template-columns: 280px 1fr;
}
}
/* Container Query: 개별 컴포넌트 내부 */
.card-wrapper {
container-type: inline-size;
}
@container (min-width: 400px) {
.card {
flex-direction: row;
}
}
미디어 쿼리가 잘 하는 것:
- 전체 페이지 레이아웃 변경
- 내비게이션 패턴 변경 (햄버거 메뉴 등)
- 폰트 크기 기준 (전체 타이포그래피 스케일)
- 배경색, 색상 테마
Container Queries가 잘 하는 것:
- 재사용 컴포넌트의 내부 레이아웃
- 위젯, 카드, 패널 등 아이솔레이션된 UI
- 사이드바 vs 메인 콘텐츠의 동일 컴포넌트 처리
실제 조합 패턴
/* 레이아웃 */
@media (min-width: 768px) {
.app-layout {
display: grid;
grid-template-columns: 240px 1fr;
}
.sidebar { container: sidebar / inline-size; }
.main { container: main-content / inline-size; }
}
/* 컴포넌트 - 어디 있든 공간에 맞게 */
.post-card-wrapper {
container: post-card / inline-size;
}
@container post-card (min-width: 300px) {
.post-card { /* 기본 */ }
}
@container post-card (min-width: 500px) {
.post-card {
display: flex;
gap: 1rem;
}
}
React + Tailwind에서 Container Queries
Tailwind CSS v3.2+ 지원
# @tailwindcss/container-queries 플러그인 설치
npm install @tailwindcss/container-queries
// tailwind.config.js
module.exports = {
plugins: [
require('@tailwindcss/container-queries'),
],
}
function ProductCard({ product }) {
return (
// @container 클래스로 컨테이너 설정
<div className="@container">
<article className="flex flex-col @[400px]:flex-row gap-4 p-4 bg-white rounded-xl shadow">
<img
src={product.image}
className="w-full @[400px]:w-48 aspect-video @[400px]:aspect-square object-cover rounded-lg"
alt={product.name}
/>
<div className="flex-1">
<h3 className="text-base @[400px]:text-lg @[600px]:text-xl font-semibold">
{product.name}
</h3>
<p className="hidden @[600px]:block text-gray-600 mt-1">
{product.description}
</p>
<span className="text-red-500 font-bold text-lg mt-2 block">
{product.price}
</span>
</div>
</article>
</div>
);
}
이름 있는 컨테이너 (Tailwind)
// @container/[name] 패턴
<div className="@container/sidebar">
<nav className="flex-col @[250px]/sidebar:flex-row">
...
</nav>
</div>
스타일 쿼리: 값에 따른 쿼리 (실험적)
Container Queries의 확장으로 **스타일 쿼리(Style Queries)**도 들어오고 있어. CSS custom property 값에 따라 쿼리하는 방식이야.
/* 아직 실험적이지만 미래는 이쪽 */
.card-wrapper {
container-type: style;
--variant: featured;
}
@container style(--variant: featured) {
.card {
border: 2px solid gold;
background: #fffbe6;
}
}
// JavaScript/React에서 CSS custom property로 컨텍스트 전달
function Card({ variant = 'default' }) {
return (
<div
className="card-wrapper"
style={{ '--variant': variant } as React.CSSProperties}
>
<article className="card">...</article>
</div>
);
}
CSS custom property를 통해 JavaScript 상태와 CSS 사이의 다리가 되는 거야. variant prop을 CSS만으로 처리할 수 있게 된다.
성능 이슈 없을까?
"레이아웃 계산이 더 복잡해지는 거 아냐?" 하는 걱정이 있을 수 있어.
실제로 브라우저들은 Container Queries를 효율적으로 구현했어. 컨테이너 크기가 바뀔 때만 자식 요소들의 스타일을 다시 계산하고, 컨테이너 크기가 그대로면 재계산 없어.
주의할 점:
/* 이러면 컨테이너가 자기 자신의 크기를 쿼리하는 순환 참조 발생 가능 */
.card {
container-type: inline-size;
}
@container (min-width: 400px) {
.card {
width: 600px; /* 컨테이너 크기 자체를 변경 → 무한 루프 위험 */
}
}
/* 안전한 패턴: 내부 자식 요소만 변경 */
@container (min-width: 400px) {
.card > .card-content {
display: flex; /* OK */
}
}
컨테이너 쿼리 안에서 컨테이너 자체의 크기를 바꾸는 건 피해야 해. 내부 자식 요소들의 스타일만 변경하는 게 안전해.
브라우저 지원 현황 (2026년 3월)
| 브라우저 | Size Queries | Style Queries |
|---|---|---|
| Chrome 105+ | 지원 | 실험적 |
| Edge 105+ | 지원 | 실험적 |
| Safari 16+ | 지원 | 일부 |
| Firefox 110+ | 지원 | 지원 예정 |
전 세계 브라우저 지원율 약 90%+. 지금 바로 프로덕션에서 써도 된다.
폴백이 필요하다면:
/* 지원 안 하는 브라우저용 기본 스타일 */
.card {
display: flex;
flex-direction: column;
}
/* Container Queries 지원하면 덮어씀 */
@supports (container-type: inline-size) {
.card-wrapper {
container-type: inline-size;
}
@container (min-width: 400px) {
.card {
flex-direction: row;
}
}
}
마이그레이션 가이드
기존 미디어 쿼리 기반 컴포넌트를 Container Queries로 마이그레이션하는 체크리스트:
- 컨테이너 식별: 어떤 wrapper 요소가 컨테이너가 될지 결정
- container-type 추가: wrapper에
container-type: inline-size설정 - 브레이크포인트 단위 변환:
@media (min-width: Xpx)→@container (min-width: Ypx)(Y는 컨테이너 기준으로 조정) - 테스트: 다양한 위치(사이드바, 메인, 다른 그리드)에서 컴포넌트 확인
- cq 단위 활용: 폰트 크기나 간격에
cqw단위 적용 검토
/* Before: 미디어 쿼리 */
@media (min-width: 768px) {
.card { display: flex; }
}
@media (min-width: 1024px) {
.card { gap: 2rem; }
}
/* After: Container Queries */
.card-wrapper {
container: card / inline-size;
}
@container card (min-width: 300px) {
.card { display: flex; }
}
@container card (min-width: 500px) {
.card { gap: 2rem; }
}
정리
Container Queries는 CSS 역사상 가장 오래 기다려온 기능 중 하나야. 미디어 쿼리가 "페이지 레이아웃"의 도구라면, Container Queries는 "컴포넌트 레이아웃"의 도구.
핵심 포인트:
container-type: inline-size로 컨테이너 선언@container (min-width: Xpx)문법으로 쿼리cqw,cqi등 컨테이너 상대 단위 활용- 미디어 쿼리와 대체 관계가 아니라 보완 관계
- 브라우저 지원율 90%+, 지금 써도 됨
진정한 컴포넌트 기반 개발은 컴포넌트가 어디에 위치하든 그 공간에 맞게 스스로 적응하는 것부터 시작해. Container Queries가 그 꿈을 이루게 해준다.