개인정보처리방침© 2026 DEV BAK - 기술블로그. All rights reserved.
DEV BAK - 기술블로그
frontend

Tailwind v4 @container — 사이드바에서도 그리드에서도 깨지지 않는 반응형 컴포넌트

반응형 UI 작업을 하다 보면 어느 순간 이런 벽에 부딪힙니다. 공들여 만든 카드 컴포넌트가 메인 피드에서는 멀쩍멀쩍 잘 동작하는데, 똑같은 코드를 사이드바에 가져다 놓으니 이미지와 텍스트가 뭉개지는 상황이요. 브레이크포인트를 더 잘게 쪼개봐도, 뷰포트를 기준으로 판단하는 미디어 쿼리는 "이 컴포넌트가 지금 얼마나 좁은 곳에 있는지"를 알 방법이 없으니까요.

저도 처음엔 "그냥 미디어 쿼리 더 세밀하게 써서 해결하면 되지 않나?" 했는데, 실제로 써보니 컴포넌트 설계 방식 자체가 달라지더라고요. 컨테이너 쿼리는 컴포넌트가 자신이 놓인 공간을 스스로 인식하게 해주는 CSS 기능으로, Tailwind v4부터 별도 플러그인 없이 핵심 API로 통합됐습니다. 무엇이, 어떻게 달라지는지 한번 같이 살펴봅시다.

이 글에서 다루는 내용

  • @container의 동작 원리와 미디어 쿼리와의 차이점
  • v4.0/v4.3의 변경 사항과 container-type: inline-size vs size의 차이
  • 기본 카드 → 네임드 컨테이너 → @max-* → 대시보드 위젯까지 4가지 예시
  • 실무에서 실제로 발목 잡힌 함정 3가지

핵심 개념

컨테이너 쿼리가 동작하는 방식

작동 원리 자체는 단순합니다. 부모 요소에 @container 클래스를 붙여 쿼리 컨테이너를 선언하면, 브라우저가 그 요소의 크기를 자식에게 알려주는 컨텍스트가 형성됩니다. 그러면 자식은 @sm:, @md:, @lg: 같이 @ 접두사를 붙인 변형자로 그 컨테이너 너비에 반응할 수 있습니다.

html
<!-- 부모에 @container 선언 -->
<div class="@container">
  <!-- 이 div의 직계 조상(@container)이 448px 이상이면 가로 배치 -->
  <div class="flex flex-col @md:flex-row gap-4">
    ...
  </div>
</div>

뷰포트 vs 컨테이너: md:flex-row는 브라우저 창이 768px 이상일 때 적용되고, @md:flex-row는 해당 요소를 감싸는 @container 요소의 너비가 448px 이상일 때 적용됩니다. 기준이 완전히 다릅니다.

여기서 처음 보면 헷갈리는 부분이 있는데, @md:의 기준값이 일반 md:와 다릅니다. 의도적으로 더 작은 값을 씁니다.

변형자 컨테이너 기준 미디어 쿼리 기준
@sm: / sm: 384px 640px
@md: / md: 448px 768px
@lg: / lg: 512px 1024px
@xl: / xl: 576px 1280px

컴포넌트는 보통 전체 뷰포트보다 훨씬 좁은 공간에 배치되니까 이게 맞는 방향입니다. 처음에 이걸 모르고 @lg:를 써놓고 "왜 안 되지?" 하고 한참 디버깅한 기억이 납니다.

v4에서 달라진 것들

v3 시절에는 @tailwindcss/container-queries 플러그인을 별도로 설치해야 했습니다. 버전별 변화를 정리하면 이렇습니다.

  • v4.0 (2025년 1월): 컨테이너 쿼리 빌트인 통합. @container, @max-*, 임의값(@[500px]:) 모두 플러그인 없이 사용 가능
  • v4.1 (2025년 4월): 안정성 및 성능 개선
  • v4.3.0 (2026년 5월): container-size 유틸리티 추가 — 이 버전부터 너비와 높이를 동시에 쿼리할 수 있습니다

한 가지 알아두면 좋은 게 있습니다. Tailwind의 @container 클래스는 내부적으로 container-type: inline-size를 설정합니다. 이 모드에서는 너비만 쿼리 기준으로 삼을 수 있고 높이는 제외됩니다. 높이 기반 쿼리가 필요하다면 v4.3.0에서 추가된 container-size 유틸리티를 써야 하는데, 이건 container-type: size를 설정해 너비와 높이 모두 쿼리 대상으로 만들어줍니다.

CSS Containment: @container를 선언하면 해당 요소는 CSS Containment 컨텍스트를 형성합니다. 브라우저가 이 요소의 레이아웃을 격리해서 크기를 계산하고, 그 정보를 자식 요소에 전달합니다. 그래서 반드시 조상에 @container가 있어야 자식의 @md: 같은 변형자가 동작합니다. container-type: inline-size(기본)는 가로 크기만, size는 가로·세로 모두 컨테이너 컨텍스트에 포함합니다.

브라우저 지원율도 충분합니다. Chrome 105+, Firefox 110+, Safari 16+ 기준 93% 이상이라 프로덕션 도입에 큰 부담이 없는 시점입니다.


실전 적용

이 개념이 실무에서 어떻게 쓰이는지, 가장 단순한 예시부터 시작해 복잡도를 조금씩 높여가며 살펴봅시다.

예시 1: 기본 컨테이너 쿼리 — 가장 단순한 형태

처음 컨테이너 쿼리를 도입할 때는 이름도 임의값도 없이, 그냥 @container와 @md:flex-row만으로 시작할 수 있습니다.

html
<div class="@container">
  <div class="flex flex-col @md:flex-row gap-4 p-4 bg-white rounded-lg shadow">
    <img
      class="w-full rounded-lg object-cover"
      src="/product.jpg"
      alt="상품 이미지"
    >
    <div>
      <h3 class="text-lg font-bold">상품명</h3>
      <p class="text-sm text-gray-600">상품 설명이 들어갑니다.</p>
    </div>
  </div>
</div>

컨테이너가 448px(@md:) 미만이면 세로로 쌓이고, 이상이면 가로로 펼쳐집니다. 이 카드를 좁은 사이드바에 놓든 넓은 메인 영역에 놓든, 공간에 맞게 스스로 조정됩니다. 미디어 쿼리로는 구현하기 까다로웠던 동작이죠.

예시 2: 네임드 컨테이너로 실무 카드 완성

실무에서 가장 자주 만나는 패턴입니다. 같은 카드가 3열 그리드에도, 좁은 사이드바에도, 모달 안에도 들어가야 하는 상황이요. 컨테이너에 이름을 붙이면 임의 브레이크포인트와 함께 훨씬 정밀하게 제어할 수 있습니다.

html
<div class="@container/card">
  <div class="flex flex-col @[480px]/card:flex-row @[750px]/card:items-center gap-4 p-4">
    <img
      class="w-full @[480px]/card:w-32 rounded-lg object-cover"
      src="/product.jpg"
      alt="상품 이미지"
    >
    <div class="flex-1">
      <h3 class="text-lg @[750px]/card:text-2xl font-bold">상품명</h3>
      <p class="text-sm text-gray-600">상품 설명이 들어갑니다.</p>
    </div>
  </div>
</div>
클래스 역할
@container/card card라는 이름의 쿼리 컨테이너 선언
@[480px]/card:flex-row card 컨테이너가 480px 이상이면 가로 배치
@[750px]/card:items-center 750px 이상이면 세로 중앙 정렬 추가
@[480px]/card:w-32 컨테이너가 넓을 때만 이미지 고정 너비 적용
@[750px]/card:text-2xl 750px 이상이면 큰 제목 크기

이 컴포넌트를 어느 레이아웃에 갖다 놓더라도 CSS를 건드릴 필요가 없습니다. 이름(/card)을 붙이는 게 귀찮아 보이지만, 중첩 구조가 생기면 이 이름이 빛을 발합니다.

html
<!-- 외부 컨테이너 -->
<div class="@container/main">
  <!-- 내부 중첩 컨테이너 -->
  <aside class="@container/sidebar">
    <nav class="flex flex-col @sm/main:flex-row @sm/sidebar:flex-col">
      <!-- /main을 기준으로도, /sidebar를 기준으로도 독립적으로 반응 -->
    </nav>
  </aside>
</div>

@container/{name} + @{size}/{name}: 패턴으로 원하는 조상 컨테이너를 정확히 지정할 수 있습니다. 대시보드처럼 레이아웃이 복잡한 페이지에서 이 패턴이 특히 유용합니다.

예시 3: @max-*로 좁을 때를 먼저 처리하기

솔직히 처음엔 @max-* 없이 모바일 퍼스트로만 써보려고 했는데, 이미 기본값을 큰 쪽으로 잡아둔 컴포넌트에 덮어쓸 때 역방향 쿼리가 훨씬 읽기 편하더라고요.

@max-*는 "이 컨테이너가 특정 크기 미만일 때"를 표현합니다. 모바일 퍼스트(@sm:, @md:로 점점 키워나가기)와 반대 방향인데, 선택 기준은 간단합니다. 기본 상태를 넓은 레이아웃으로 정의했고 좁을 때만 예외 처리를 하고 싶다면 @max-*가 더 읽기 쉽습니다. 반대로 기본 상태가 좁은 쪽이라면 @sm:, @md:를 쌓아올리는 게 자연스럽습니다.

html
<div class="@container">
  <!-- 컨테이너가 448px 미만이면 작은 텍스트, 이상이면 기본 크기 -->
  <p class="@max-md:text-sm text-base leading-relaxed">
    컨테이너 크기에 따라 폰트가 달라집니다.
  </p>
 
  <!-- 좁을 때만 단순화되는 버튼 레이블 -->
  <button class="@max-sm:px-2 @max-sm:text-xs px-4 py-2 text-sm bg-blue-500 text-white rounded">
    <span class="@max-sm:hidden">지금 구매하기</span>
    <span class="@sm:hidden">구매</span>
  </button>
</div>

예시 4: 대시보드 위젯 — 컨테이너가 배치를 스스로 결정할 때

이 패턴을 처음 써봤을 때 "아, 이래서 컨테이너 쿼리가 필요했구나" 싶었습니다. 같은 위젯이 그리드에서 1열을 차지할 때와 2열을 차지할 때 전혀 다른 레이아웃이 필요한데, 페이지 레이아웃 코드를 건드리지 않고 위젯 스스로 결정하게 만들 수 있거든요.

html
<!-- 그리드에서 1열이면 좁게, 2열이면 넓게 -->
<div class="@container/widget col-span-1 md:col-span-2">
  <div class="flex flex-col @lg/widget:flex-row gap-4 p-6 bg-white rounded-xl shadow">
 
    <!-- 요약 수치 영역 -->
    <div class="@lg/widget:w-1/3 bg-blue-50 rounded-lg p-4">
      <p class="text-4xl font-bold text-blue-600">1,284</p>
      <p class="text-sm text-gray-500 mt-1">이번 달 방문자</p>
      <p class="text-xs text-green-600 mt-2">↑ 12% 지난달 대비</p>
    </div>
 
    <!-- 상세 데이터 테이블 -->
    <div class="@lg/widget:w-2/3">
      <table class="w-full text-sm">
        <thead>
          <tr class="border-b text-left text-gray-500">
            <th class="pb-2">페이지</th>
            <th class="pb-2">방문수</th>
            <th class="pb-2">이탈률</th>
          </tr>
        </thead>
        <tbody>
          <tr class="border-b">
            <td class="py-2">/home</td>
            <td>512</td>
            <td>34%</td>
          </tr>
          <tr class="border-b">
            <td class="py-2">/docs</td>
            <td>389</td>
            <td>21%</td>
          </tr>
          <tr>
            <td class="py-2">/pricing</td>
            <td>383</td>
            <td>45%</td>
          </tr>
        </tbody>
      </table>
    </div>
 
  </div>
</div>

col-span-1일 때(위젯이 좁음)는 수치와 테이블이 세로로 쌓이고, col-span-2로 넓어지면 /widget 컨테이너가 @lg 기준(512px)을 넘는 순간 자동으로 옆으로 펼쳐집니다. 페이지 레이아웃 코드는 그리드 열 수만 결정하고, 위젯 내부 배치는 위젯이 전담합니다.


장단점 분석

직접 써본 소감으로는, 컨테이너 쿼리는 "컴포넌트 재사용이 잦은 프로젝트"에서 효과가 가장 극적으로 나타납니다. 반면 단점 중에서 실무에서 실제로 발목 잡혔던 건 두 가지였습니다 — 높이 쿼리 우회와 중첩 컨테이너 디버깅이요.

장점

  • 컴포넌트 이식성: 사이드바·그리드·모달 어디에 놓여도 동일하게 동작
  • 플러그인 불필요: v4부터 빌트인 — 별도 패키지 설치·유지보수 부담 없음
  • @max-* 지원: 역방향(최대 너비 기준) 쿼리도 클래스 한 줄로 처리
  • 네임드 컨테이너: 중첩 구조에서 원하는 조상 컨테이너를 정확히 타겟팅
  • 미디어 쿼리와 공존: @media와 @container는 독립 CSS 규칙으로 충돌 없이 혼용 가능

단점 및 주의사항

항목 내용 대응 방안
높이 쿼리 변형자 미지원 @container(기본)는 container-type: inline-size라 너비만 쿼리 가능 v4.3.0의 container-size 클래스로 container-type: size 설정 후 @[height>200px]: 임의 변형자로 우회
컨테이너 선언 필수 반드시 조상에 @container가 있어야 동작 래퍼 div 추가 감수 또는 레이아웃 구조 재설계
디버깅 복잡도 중첩 컨테이너가 많으면 어느 기준인지 추적 어려움 네이밍 철저히, Chrome DevTools 컨테이너 패널 활용
구형 브라우저 미지원 Chrome 105+ 등 모던 브라우저만 지원 레거시 지원 필요 시 폴리필 또는 점진적 적용 검토

실무에서 가장 흔한 실수

  1. @container 없이 @md: 쓰기 — 조상에 @container가 없으면 변형자가 그냥 무시됩니다. 적용이 안 된다 싶으면 부모 트리를 먼저 확인해보시면 좋습니다.
  2. md:와 @md:의 브레이크포인트 혼동 — md:는 768px, @md:는 448px입니다. 둘 다 쓰는 컴포넌트에서 값이 다르다는 걸 잊으면 예상치 못한 레이아웃 깨짐이 생깁니다.
  3. 모든 반응형을 컨테이너 쿼리로 대체하려는 시도 — 헤더 높이, 사이드바 존재 여부처럼 페이지 전체 구조를 결정하는 건 여전히 미디어 쿼리가 자연스럽습니다. "페이지 레이아웃은 md:·lg:, 컴포넌트 내부는 @md:·@lg:"로 역할을 나누는 게 유지보수에 훨씬 편합니다.

마치며

컨테이너 쿼리는 컴포넌트가 자신이 놓인 공간을 스스로 인식하게 해줌으로써, 재사용성과 이식성의 근본적인 한계를 해결합니다. Tailwind v4에서 별도 설치 없이 쓸 수 있게 됐고, 브라우저 지원도 충분하니 지금 시작해도 좋은 시점입니다.

지금 바로 시작해볼 수 있는 3단계:

  1. Tailwind를 v4로 올려볼 수 있습니다.
    공식 마이그레이션 문서를 참고하시면 v3 → v4 변경사항을 단계적으로 적용할 수 있습니다.

  2. 가장 많이 재사용되는 카드·리스트 아이템 하나에 @container를 감싸보시면 좋습니다.
    Chrome DevTools의 컨테이너 쿼리 패널에서 컨테이너 경계를 시각적으로 확인하면서 실험하면 훨씬 빠르게 감이 잡힙니다.

  3. 사이드바나 모달처럼 너비가 달라지는 컨텍스트에 같은 컴포넌트를 배치해볼 수 있습니다.
    CSS 한 줄 수정 없이 레이아웃이 알아서 적응하는 순간, 왜 이 패러다임이 주목받는지 체감이 됩니다.


참고 자료

  • Tailwind CSS v4.0 공식 블로그 | tailwindcss.com
  • Responsive design 공식 문서 | tailwindcss.com
  • Tailwind CSS v4 Container Queries: Modern Responsive Design | SitePoint
  • Component-First Responsive Design with Tailwind v4 | Kickstage
  • Container Queries in Tailwind CSS | Frontend Notes
  • Replace Complex Media Queries With Tailwind Container Queries | Strapi
  • Build Smarter Responsive UIs with Tailwind CSS 4 Container Queries | Medium
  • CSS Container Queries: A Practical Guide 2026 | Mantlr
  • GitHub — tailwindlabs/tailwindcss-container-queries (구 플러그인)
#TailwindCSS#컨테이너쿼리#반응형UI#CSS-Containment#미디어쿼리#TailwindV4#프론트엔드#컴포넌트설계#그리드레이아웃#대시보드UI
공유하기

목차

핵심 개념컨테이너 쿼리가 동작하는 방식v4에서 달라진 것들실전 적용예시 1: 기본 컨테이너 쿼리 — 가장 단순한 형태예시 2: 네임드 컨테이너로 실무 카드 완성예시 3:예시 4: 대시보드 위젯 — 컨테이너가 배치를 스스로 결정할 때장단점 분석장점단점 및 주의사항실무에서 가장 흔한 실수마치며참고 자료

추천 포스트

TanStack DB 실전 도입: 클라이언트 사이드 DB가 낙관적 업데이트를 어떻게 바꾸는가
frontend

TanStack DB 실전 도입: 클라이언트 사이드 DB가 낙관적 업데이트를 어떻게 바꾸는가

솔직히 "클라이언트 사이드 임베디드 DB"라는 말을 처음 들었을 때 '또 유행어겠지' 싶었습니다. TanStack Query가 이미 서버 상태 관리를 잘 해주고 있는데, 그 위에 DB를 하나 더 올린다는 게 무슨 의미인가 싶었거든요. 그런데 TanStack Query에서 낙관적 업...

2026년 06월 23일읽는 데 17분
엣지 MFE 오케스트레이션으로 TTFB를 줄이는 Cloudflare Workers 구현법
frontend

엣지 MFE 오케스트레이션으로 TTFB를 줄이는 Cloudflare Workers 구현법

솔직히 처음 마이크로 프론트엔드(MFE) 이야기를 들었을 때, "팀마다 독립 배포가 된다고? 좋아 보이는데, 그럼 사용자 브라우저에서 각자 번들을 다 받아야 하는 거 아냐?" 하는 생각부터 들었습니다. 클라이언트 사이드 컴포지션의 고질적인 문제, 그러니까 여러 MFE의 JS 번들이 순차...

2026년 06월 23일읽는 데 23분
EAA 발효 후 프론트엔드 접근성 점검 체크리스트 15가지 (유럽 접근성법 WCAG 2.1 AA 기준)
frontend

EAA 발효 후 프론트엔드 접근성 점검 체크리스트 15가지 (유럽 접근성법 WCAG 2.1 AA 기준)

2025년 6월 28일, 슬랙에서 흘러온 메시지를 보다가 멈췄습니다. "EAA 발효됐는데, 우리 서비스 괜찮아?" 처음엔 "EU 법이니까 유럽에서 직접 사업하는 회사 얘기겠지" 싶었는데, 역외 적용 조항을 읽고 나서 생각이 달라졌습니다. EU 고객에게 서비스를 제공하는 기업이라면 본사가...

2026년 06월 26일읽는 데 26분
린팅이 62배 빨라지면 개발 습관이 달라진다 — ESLint에서 Oxlint로, Vite 프로젝트 마이그레이션 경험
frontend

린팅이 62배 빨라지면 개발 습관이 달라진다 — ESLint에서 Oxlint로, Vite 프로젝트 마이그레이션 경험

린터가 느리다고 느낀 적 있으신가요? 저도 그랬습니다. 중간 규모 React 프로젝트에서 를 돌릴 때마다 30초 가까이 기다리는 게 당연한 줄 알았는데, Oxlint로 바꾸고 나서 499ms가 나오는 걸 보고 처음엔 "설정이 잘못된 거 아닌가?" 의심했을 정도입니다. 공식 벤치마크 수치...

2026년 06월 23일읽는 데 19분
CSS @layer로 마이크로 프론트엔드 스타일 충돌 잡기 | Cascade Layers 격리 전략
frontend

CSS @layer로 마이크로 프론트엔드 스타일 충돌 잡기 | Cascade Layers 격리 전략

마이크로 프론트엔드(MFE, Micro Frontend) 프로젝트를 처음 진행했을 때, 팀A가 배포한 버튼 스타일이 팀B의 화면에서 멋대로 바뀌어 있는 걸 보고 꽤 당황했던 기억이 있습니다. 그 시절엔 CSS Modules로 클래스명 해시를 만들거나, 선택자 앞에 팀 prefix를 붙이...

2026년 06월 23일읽는 데 22분
Webpack을 Rsbuild로 옮겼더니 프로덕션 빌드가 74% 빨라졌다 — 마이그레이션 현실과 Rspack 함정
frontend

Webpack을 Rsbuild로 옮겼더니 프로덕션 빌드가 74% 빨라졌다 — 마이그레이션 현실과 Rspack 함정

솔직히 저도 처음엔 "또 새로운 빌드 도구야?"라는 반응이었습니다. Vite가 나왔을 때도 그랬고, Turbopack 발표 때도 그랬죠. 그런데 Alan 핀테크 팀의 사례를 보고 나서는 그냥 지나칠 수가 없었습니다. 가장 복잡한 프로젝트에서 프로덕션 빌드 시간이 2분 10초에서 34초로...

2026년 06월 23일읽는 데 19분