PUBLISHED

Container Query

작성일: 2024.11.07

Container Query

웹 애플리케이션을 제작할 때 반응형 디자인은 더 이상 선택이 아니라 기본 전제에 가깝다. 화면 크기와 디바이스 환경이 워낙 다양해졌기 때문에, UI가 뷰포트 크기에 따라 유연하게 변화하도록 만드는 일은 거의 모든 프로젝트에서 필수적으로 요구된다. 이를 위해 가장 널리 사용되어 온 수단이 CSS Media Query다. 미디어 쿼리는 뷰포트의 너비나 높이를 기준으로 스타일을 분기할 수 있게 해주며, 오랜 시간 동안 반응형 웹의 핵심 도구로 자리 잡아 왔다.

하지만 미디어 쿼리는 본질적으로 “브라우저 전체 화면”을 기준으로 동작한다는 한계를 가진다. 뷰포트 크기는 변하지 않는데, 컴포넌트가 놓인 영역의 크기만 달라지는 상황에는 제대로 대응하기 어렵다. 이 경우, 컴포넌트는 같은 뷰포트 안에 있음에도 전혀 다른 레이아웃 맥락에 놓이게 되지만, 미디어 쿼리는 이를 구분하지 못한다.

이 한계를 보완하기 위해 그동안은 ResizeObserver와 useEffect 같은 JavaScript 기반의 접근을 사용해 왔다. 컴포넌트의 실제 크기를 관찰하고, 그 결과에 따라 상태를 변경한 뒤 조건부 스타일이나 클래스를 적용하는 방식이다. 물론 동작은 가능하지만, 스타일 로직이 JS로 흘러 들어오면서 복잡도가 급격히 상승하고, 선언적인 CSS의 장점도 상당 부분 잃게 된다.

이러한 배경 속에서 등장한 것이 CSS Container Query다. 컨테이너 쿼리는 뷰포트가 아닌 “특정 요소의 크기”를 기준으로 스타일을 분기할 수 있도록 해준다. 즉, 개별 컴포넌트가 자신이 놓인 컨텍스트의 크기에 반응하도록 만들 수 있다. 더 이상 JS의 도움 없이, CSS만으로 컴포넌트 단위의 반응형 디자인이 가능해진 것이다.

이 변화가 갖는 의미는 꽤 크다. 컴포넌트는 더 이상 특정 레이아웃이나 화면 크기에 종속되지 않고, 자신이 들어간 공간의 크기에 따라 스스로 스타일을 조정한다. 그 결과, 컴포넌트의 재사용성이 크게 향상되고, 복잡한 레이아웃을 가진 웹 애플리케이션에서도 훨씬 예측 가능한 UI 설계가 가능해진다.

예를 들어 사용자의 취향에 따라 사이드바를 접거나 펼칠 수 있는 서비스를 생각해보자. 유튜브가 대표적인 사례다. 사이드바의 상태에 따라 실제 콘텐츠 영역의 너비는 크게 달라지지만, 브라우저 뷰포트 자체는 변하지 않는다. 이 상황에서 미디어 쿼리만으로 디자인을 처리하려면, 사이드바의 상태를 고려한 복잡한 분기 로직이 필요해진다.

반면 컨테이너 쿼리를 사용하면 기준은 명확해진다. “콘텐츠가 놓인 영역의 너비”만을 기준으로 스타일을 결정하면 된다. 사이드바가 열려 있든 닫혀 있든, 컴포넌트는 자신이 들어 있는 컨테이너의 크기에만 반응한다. 같은 뷰포트 안에서도, 컨테이너 크기에 따라 동일한 컴포넌트가 서로 다른 모습으로 렌더링될 수 있는 이유다.

컨테이너 쿼리를 사용하기 위해서는 ResizeObserver와 마찬가지로 기준이 될 ‘컨테이너 요소’를 명시해야 한다. 이를 위해 CSS에서는 container-type 속성을 사용한다. 이 속성이 적용된 요소는 컨테이너로 동작하며, 그 크기 변화를 기준으로 하위 요소의 스타일을 분기할 수 있다.

너비 변화만 추적하고 싶다면 inline-size를, 높이까지 포함해 추적하고 싶다면 size를 사용하면 된다. 일반적인 반응형 UI에서는 가로 너비에 따라 레이아웃이 바뀌는 경우가 많기 때문에, container-type: inline-size가 가장 흔하게 사용된다.

untitled
CSS
.root {
  container-type: inline-size;
}

.description {
  font-family: Pretendard;
  font-size: 3.6rem;
}

@container (max-width: 700px) {
  .description {
    font-size: 2.4rem;
  }
}
untitled
TSX
return (
  <div className={styles.root}>
    <p className={styles.description}>{/* ... */}</p>
  </div>
);

이 코드에서 .description은 뷰포트가 아니라 .root 요소의 너비를 기준으로 폰트 크기를 조절한다. 같은 화면 크기라 하더라도 .root가 배치된 위치나 레이아웃에 따라 결과가 달라질 수 있다.

기본적으로 컨테이너 쿼리는 가장 가까운 조상 컨테이너를 기준으로 동작한다. 하지만 특정 컨테이너를 명시적으로 기준으로 삼고 싶다면 container-name을 사용할 수 있다. 이를 통해 중첩된 구조에서도 어떤 컨테이너를 참조할지 명확히 지정할 수 있다.

untitled
CSS
.root {
  container-name: profile-container;
  container-type: inline-size;
}

.someWeirdContainer {
  container-type: inline-size;
}

.description {
  font-family: Pretendard;
  font-size: 3.6rem;
}

@container profile-container (max-width: 700px) {
  .description {
    font-size: 2.4rem;
  }
}
untitled
TSX
return (
  <div className={styles.root}>
    <div className={styles.someWeirdContainer}>
      <p className={styles.description}>{/* ... */}</p>
    </div>
  </div>
);

이 경우 .description은 가장 가까운 컨테이너인 someWeirdContainer가 아니라, profile-container라는 이름을 가진 .root를 기준으로 스타일이 결정된다. 복잡한 DOM 구조에서도 기준을 명확히 잡을 수 있다는 점에서 매우 유용하다.

컨테이너 쿼리는 순수 CSS 문법이기 때문에, CSS나 CSS Module에서는 그대로 사용할 수 있다. Styled-components나 Vanilla Extract 같은 CSS-in-JS 계열 라이브러리에서도, 어차피 런타임에 문자열이나 객체를 CSS로 변환하는 구조이기 때문에 각자의 문법을 통해 컨테이너 쿼리를 지원한다.

untitled
TSX
// Vanilla Extract
export const container = style({
  containerType: 'inline-size',
  containerName: 'containerName',
  width: '100%',
  padding: '20px',
});

export const responsiveText = style({
  fontSize: '16px',
  '@container containerName': {
    '(min-width: 500px)': {
      fontSize: '20px',
    },
    '(min-width: 800px)': {
      fontSize: '24px',
    },
  },
});

한편 Tailwind CSS는 아직 컨테이너 쿼리를 공식적으로 지원하지는 않지만, 플러그인 형태로 확장해 사용할 수 있다. 다만 현재로서는 container-name까지 완전히 지원하지는 않는 것으로 보인다. 그럼에도 불구하고, 컨테이너 쿼리 자체가 제공하는 개념적 이점은 충분히 크다.

CSS Container Query는 반응형 디자인의 기준을 “화면”에서 “컴포넌트”로 끌어내린다. 이는 단순한 문법 추가가 아니라, UI를 바라보는 관점 자체를 바꾸는 변화다. 컴포넌트가 더 이상 주변 레이아웃을 추측하지 않고, 자신이 놓인 공간에만 집중할 수 있게 되면서, 재사용성과 예측 가능성은 자연스럽게 따라온다. 복잡한 웹 애플리케이션을 다룰수록, 이 차이는 생각보다 훨씬 크게 체감된다.