웹 애플리케이션을 개발하다 보면 사이드바, 모달, 메인 콘텐츠 영역 등 다양한 위치에서 재사용되는 카드 컴포넌트의 레이아웃이 지저분하게 깨지는 현상을 경험해봤을 것이다. 다만 대부분의 개발자들은 뷰포트 기준 미디어 쿼리의 한계 때문이라는 정확한 원인이나 해결책을 모르는 경우가 많다.
이번에는 CSS Container Queries가 정확히 뭔지, 왜 필요한지, 어떻게 작성하는지 완벽하게 정리해서 소개하겠다.
기존 미디어 쿼리(@media)는 브라우저 창(뷰포트)의 너비나 높이를 기준으로 스타일을 적용한다. 이 방식은 전체 페이지 레이아웃을 잡을 때는 유용하지만, 독립적인 컴포넌트를 만들 때는 치명적인 단점이 있다. 예를 들어, 동일한 상품 카드 컴포넌트가 메인 영역(너비 800px)에 있을 때는 가로형 배치로 보여야 하고, 사이드바(너비 300px)에 있을 때는 세로형 배치로 보여야 하는 상황을 생각해 보자.
뷰포트 너비가 1200px일 때, 사이드바에 들어간 카드 컴포넌트는 부모 영역이 좁음에도 불구하고 뷰포트 기준 미디어 쿼리에 걸려 가로형으로 펼쳐지면서 레이아웃이 지저분하게 깨진다. 이 문제를 해결하기 위해 등장한 것이 바로 컨테이너 쿼리(Container Queries)다. 컨테이너 쿼리는 브라우저 화면이 아니라 해당 컴포넌트를 감싸고 있는 부모 컨테이너의 크기를 직접 감지하여 스타일을 변경한다.
| 구분 | 미디어 쿼리 (@media) | 컨테이너 쿼리 (@container) |
|---|---|---|
| 기준 대상 | 브라우저 뷰포트(Viewport) 크기 | 부모 조상 요소(Container) 크기 |
| 재사용성 | 배치되는 위치에 따라 CSS 클래스를 추가해야 함 | 어느 부모 요소에 넣어도 스스로 크기 조절 |
| 주요 용도 | 전체 페이지 레이아웃(Grid/Flex) 구성 | 독립적인 모듈형 UI 컴포넌트 제작 |
컨테이너 쿼리를 사용하려면 먼저 어떤 요소를 크기 감지 기준(컨테이너)으로 삼을지 지정해야 한다. 부모 요소에 container-type 속성을 선언하고, 하위 요소에서는 @container 규칙을 사용하여 조건부 스타일을 작성한다.
container-type에는 다음과 같은 주요 값이 사용된다.
- inline-size: 부모 요소의 인라인 방향(주로 가로 너비) 크기 변화를 감지한다. 가장 흔히 사용된다.
- size: 부모 요소의 가로 너비와 세로 높이 모두를 감지한다. 스크롤이나 높이 재계산 시 주의가 필요하다.
- normal: 요소를 크기 감지 컨테이너에서 제외한다.
필요에 따라 container-name 속성으로 컨테이너에 이름을 부여하면, 특정 부모 컨테이너만 지정해서 쿼리를 적용할 수도 있다. shorthand 속성인 container를 이용해 container: sidebar / inline-size; 와 같이 한 줄로 작성하는 것도 가능하다.
부모 컨테이너 너비에 따라 가로 레이아웃과 세로 레이아웃으로 유연하게 변환되는 상품 카드 컴포넌트를 작성해 보자.
<div class="card-wrapper">
<div class="card">
<img src="thumb.jpg" alt="상품 이미지" class="card-thumb" />
<div class="card-body">
<h3 class="card-title">프리미엄 개발자 키보드</h3>
<p class="card-desc">실무 최적화 타건감을 제공하는 기계식 키보드입니다.</p>
</div>
</div>
</div>
미디어 쿼리를 사용하면 뷰포트 너비가 넓을 때 사이드바 안의 카드가 무조건 가로로 배열되어 이미지가 비정상적으로 일그러진다.
/* 뷰포트 기준이므로 부모 영역 너비와 상관없이 반응함 */
.card {
display: flex;
flex-direction: column;
}
@media (min-width: 768px) {
.card {
flex-direction: row; /* 사이드바(너비 300px)에 있어도 뷰포트가 768px 이상이면 가로로 변경됨 */
}
}
부모 요소인 .card-wrapper를 컨테이너로 선언하고 @container를 통해 부모 너비가 400px 이상일 때만 가로 레이아웃을 적용한다.
/* 부모 요소를 인라인 크기 감지 컨테이너로 설정 */
.card-wrapper {
container-type: inline-size;
container-name: card-container;
width: 100%;
}
.card {
display: flex;
flex-direction: column;
gap: 12px;
border: 1px solid #ddd;
border-radius: 8px;
padding: 16px;
}
/* 부모 컨테이너 너비가 400px 이상일 때만 가로 배치 */
@container card-container (min-width: 400px) {
.card {
flex-direction: row;
align-items: center;
}
.card-thumb {
width: 120px;
height: 120px;
object-fit: cover;
}
}
메인 콘텐츠 영역(너비 600px)에 배치된 .card-wrapper 내부의 카드는 부모 너비가 400px을 넘으므로 가로로 깔끔하게 배치된다. 반면 동일한 HTML과 CSS를 사용하는 사이드바 영역(너비 320px) 내부의 카드는 부모 너비가 400px 미만이므로 세로 형태를 유지한다. 브라우저 전체 창 크기와 무관하게 컴포넌트가 들어간 위치에 맞게 완벽한 반응형 UI가 완성된다.
컨테이너 쿼리를 다룰 때 개발자들이 자주 겪는 실수를 정리했다. 잘못된 선언으로 인해 스타일이 아예 적용되지 않거나 레이아웃 루프 에러가 발생하는 상황을 방지하자.
✗ 잘못된 예: 컴포넌트 자기 자신에 container-type을 주고 내부에서 그 크기를 변경하려는 경우
/* 자기 자신의 크기를 감지하면서 동시에 크기를 바꾸면 무한 순환 스타일 재계산 에러 발생 */
.card {
container-type: inline-size;
}
@container (min-width: 400px) {
.card {
width: 500px; /* 자식/본인의 크기를 바꿔 컨테이너 조건 상태가 연쇄적으로 변경됨 */
}
}
✓ 올바른 예: 반드시 감싸고 있는 래퍼(부모) 요소를 컨테이너로 설정하고 자식 요소의 스타일을 변경
.card-wrapper {
container-type: inline-size;
}
@container (min-width: 400px) {
.card {
display: flex;
flex-direction: row;
}
}
또한 container-type: size 설정은 높이 축까지 포함하므로, 자식 요소의 높이가 부모 컨테이너의 높이에 영향을 주는 일반적인 문서 흐름에서는 높이 계산 무한 루프를 유발할 수 있다. 특별히 세로 높이를 기준으로 레이아웃을 변경해야 하는 상황이 아니라면 가로 너비만 감지하는 inline-size를 사용하는 것이 안전하다.
CSS Container Queries는 컴포넌트 기반 현대 웹 개발 방식에 꼭 필요한 강력한 레이아웃 도구다. 작은 최적화와 올바른 속성 활용 습관이 모여서 유지보수하기 쉬운 프론트엔드 코드베이스를 만든다는 점을 잊지 말자. 이 글의 실전 예제 코드를 참고해 감싸는 부모 래퍼 요소에 container-type을 적용해 보면, 컴포넌트 재사용성이 극대화된 깔끔한 반응형 웹을 구현할 수 있을 것이다.