이 글은 누구를 위한 것인가
- 미디어쿼리로 반응형을 구현하다가 컴포넌트를 다른 레이아웃에 재사용하지 못하는 팀
!important와 명시도(specificity) 싸움을 반복하는 CSS 아키텍처에 지친 엔지니어- Container Queries와 Cascade Layers가 무엇인지 알지만 실제로 어떻게 쓰는지 감이 안 오는 개발자
미디어쿼리의 근본적 한계
반응형 카드 컴포넌트를 만든다고 하자.
/* 기존 미디어쿼리 방식 */
.card {
flex-direction: column;
}
@media (min-width: 768px) {
.card {
flex-direction: row;
}
}
이 카드를 사이드바(좁은 영역)에 배치하면 뷰포트가 768px 이상이어도 카드는 가로 배치가 된다. 카드가 실제로 얼마나 넓은 공간에 있는지는 알지 못하고, 뷰포트 크기로만 판단하기 때문이다.
결과는 레이아웃마다 별도 CSS 오버라이드, 또는 컴포넌트 Props로 레이아웃 정보를 전달하는 JS 의존 패턴이다.
Container Queries: 부모 크기를 기준으로 반응
@container는 뷰포트가 아닌 부모 컨테이너의 크기를 기준으로 스타일을 변경한다.
기본 사용법
/* 1. 컨테이너를 선언 */
.card-wrapper {
container-type: inline-size;
/* container-name: card; */ /* 선택적: 이름 지정 */
}
/* 2. 컨테이너 쿼리 작성 */
.card {
flex-direction: column;
}
@container (min-width: 480px) {
.card {
flex-direction: row;
}
}
이제 .card는 뷰포트가 아니라 .card-wrapper의 너비가 480px 이상일 때 가로 배치가 된다. 사이드바에 있든, 풀 와이드 레이아웃에 있든 올바르게 동작한다.
container-type 옵션
| 값 | 의미 |
|---|---|
inline-size | 가로 크기만 기준 (가장 많이 사용) |
size | 가로 + 세로 크기 기준 |
normal | 기본값, 쿼리 기준으로 사용 불가 |
size는 높이도 추적하므로 성능 비용이 더 크다. 대부분의 경우 inline-size로 충분하다.
이름 있는 컨테이너
중첩된 컨테이너가 있을 때 어떤 컨테이너를 기준으로 할지 명시할 수 있다.
.sidebar {
container-type: inline-size;
container-name: sidebar;
}
.main-content {
container-type: inline-size;
container-name: main;
}
/* sidebar 컨테이너 기준 */
@container sidebar (min-width: 300px) {
.widget { font-size: 0.875rem; }
}
/* main 컨테이너 기준 */
@container main (min-width: 600px) {
.widget { font-size: 1rem; }
}
실전 예제: 재사용 가능한 반응형 카드
/* card.css */
.card-container {
container-type: inline-size;
}
.card {
display: grid;
grid-template-columns: 1fr;
gap: 1rem;
padding: 1rem;
}
.card__image {
aspect-ratio: 16 / 9;
object-fit: cover;
border-radius: 0.5rem;
}
/* 카드 컨테이너가 400px 이상이면 가로 배치 */
@container (min-width: 400px) {
.card {
grid-template-columns: 200px 1fr;
align-items: start;
}
.card__image {
aspect-ratio: 1;
}
}
/* 600px 이상이면 이미지를 더 크게 */
@container (min-width: 600px) {
.card {
grid-template-columns: 280px 1fr;
}
}
이 카드를 어디에 배치해도 컴포넌트 CSS를 수정할 필요가 없다. 배치하는 쪽에서 컨테이너 너비만 조절하면 카드가 알아서 적응한다.
컨테이너 쿼리 단위 (cqi, cqw)
/* cqi: inline-size(너비)의 백분율 */
@container (min-width: 400px) {
.card__title {
font-size: clamp(1rem, 3cqi, 1.5rem);
/* 컨테이너 너비의 3%, 최소 1rem, 최대 1.5rem */
}
}
뷰포트 단위(vw)와 달리 컨테이너 크기 기준이라 컴포넌트 스케일링에 자연스럽게 쓸 수 있다.
Cascade Layers: 스타일 우선순위 명시화
CSS 명시도 문제는 오랫동안 개발자를 괴롭혀 왔다. 서드파티 라이브러리 스타일을 오버라이드하려고 불필요하게 깊은 셀렉터를 쓰거나 !important를 남발하게 된다.
@layer는 CSS 캐스케이드에 레이어 개념을 추가해 우선순위를 셀렉터 명시도가 아닌 레이어 순서로 명시적으로 관리한다.
기본 사용법
/* 레이어 순서 먼저 선언: 나중에 선언된 레이어가 우선 */
@layer reset, base, components, utilities, overrides;
@layer reset {
*, *::before, *::after { box-sizing: border-box; }
body { margin: 0; }
}
@layer base {
body { font-family: system-ui, sans-serif; line-height: 1.6; }
h1, h2, h3 { font-weight: 700; }
}
@layer components {
.button {
padding: 0.5rem 1rem;
border-radius: 0.375rem;
cursor: pointer;
}
}
@layer utilities {
.mt-4 { margin-top: 1rem; }
.text-center { text-align: center; }
}
@layer overrides {
/* 특정 페이지나 컨텍스트의 예외 처리 */
.homepage .button { border-radius: 9999px; }
}
핵심: overrides 레이어의 .homepage .button은 명시도가 낮아도 components 레이어의 .button을 이긴다. 레이어 순서가 명시도보다 우선하기 때문이다.
레이어 우선순위 규칙
레이어 없음 (unlayered) > 마지막 선언 레이어 > ... > 첫 번째 선언 레이어
레이어에 속하지 않은 스타일이 가장 강하다. 서드파티 라이브러리를 레이어로 감싸면 쉽게 오버라이드할 수 있다.
실전 예제: 서드파티 라이브러리 통제
/* 서드파티 라이브러리를 레이어로 격리 */
@layer third-party;
@import url('some-ui-library.css') layer(third-party);
/* 이제 레이어 없는 우리 스타일이 항상 이긴다 */
.button {
background: var(--color-interactive-primary); /* 라이브러리 스타일 덮어씀 */
}
!important 없이 서드파티 라이브러리 스타일을 깔끔하게 오버라이드할 수 있다.
Tailwind CSS와 함께 사용
Tailwind v4는 Cascade Layers를 기본 지원한다. 커스텀 CSS를 레이어로 관리하면 Tailwind 유틸리티와의 충돌을 제어할 수 있다.
@import 'tailwindcss'; /* base, components, utilities 레이어 포함 */
@layer components {
/* Tailwind components 레이어와 같은 우선순위 */
.btn-primary {
@apply px-4 py-2 bg-blue-600 text-white rounded-lg;
}
}
@layer overrides {
/* 유틸리티보다 우선 */
.no-transition {
transition: none !important; /* 이제 !important 없어도 됨 */
transition: none;
}
}
Container Queries + Cascade Layers 조합
두 기능을 함께 쓰면 컴포넌트 CSS 아키텍처가 깔끔해진다.
/* design-system/layers.css */
@layer reset, tokens, base, components, overrides;
/* design-system/components/card.css */
@layer components {
.card-container {
container-type: inline-size;
}
.card {
display: grid;
grid-template-columns: 1fr;
gap: var(--space-4);
padding: var(--space-4);
}
@container (min-width: 400px) {
.card {
grid-template-columns: auto 1fr;
}
}
}
/* 특정 페이지 오버라이드 */
@layer overrides {
.product-page .card-container {
container-type: inline-size;
max-width: 900px;
}
}
브라우저 지원 현황 (2026년 기준)
| 기능 | Chrome | Firefox | Safari | Edge |
|---|---|---|---|---|
Container Queries (@container) | 105+ | 110+ | 16+ | 105+ |
| Container Query Units (cqi, cqw) | 105+ | 110+ | 16+ | 105+ |
Cascade Layers (@layer) | 99+ | 97+ | 15.4+ | 99+ |
전 세계 브라우저 사용률 기준으로 두 기능 모두 90% 이상 지원된다. 프로덕션에서 폴리필 없이 사용 가능한 수준이다.
IE11 지원이 필요한 경우는 해당 없다. 레거시 지원이 필요하다면 @supports로 점진적 강화를 적용한다.
/* 점진적 강화 */
.card {
/* 기본: 미디어쿼리 기반 */
flex-direction: column;
}
@media (min-width: 768px) {
.card { flex-direction: row; }
}
/* container queries 지원 시 오버라이드 */
@supports (container-type: inline-size) {
.card { flex-direction: column; } /* 기본으로 리셋 */
.card-container { container-type: inline-size; }
@container (min-width: 400px) {
.card { flex-direction: row; }
}
}
마이그레이션 전략
기존 미디어쿼리 기반 코드베이스를 한 번에 바꾸려 하지 않는다.
권장 순서:
-
@layer구조 먼저 도입 — 기존 코드를 레이어로 분류하면 명시도 문제가 즉시 해결되고, 기능 변경 없이 적용 가능하다. -
신규 컴포넌트부터
@container적용 — 재사용성이 중요한 카드, 위젯, 미디어 블록부터 시작한다. -
레이아웃 컴포넌트에는 미디어쿼리 유지 — 페이지 전체 레이아웃(사이드바 ON/OFF, 그리드 컬럼 수)은 여전히 뷰포트 기반이 맞다.
@container는 컴포넌트 내부 반응형에 집중한다.
맺으며
Container Queries와 Cascade Layers는 CSS 반응형 설계의 패러다임을 바꾼다. 뷰포트 중심에서 컴포넌트 중심으로, 암묵적 명시도 싸움에서 명시적 레이어 우선순위로.
두 기능 모두 브라우저 지원이 충분하고, 기존 코드와 점진적으로 병행할 수 있다. @layer는 오늘 당장 도입해도 리스크가 없다. @container는 다음에 만드는 재사용 컴포넌트부터 적용해보는 것을 권한다. 한 번 쓰면 미디어쿼리로 돌아가기 어렵다.