모던 CSS 컴포넌트 아키텍처 — 네이티브 기능만으로 설계하는 컴포넌트 시스템

12 min read
CSSContainer QueriesCascade Layers컴포넌트
모던 CSS 컴포넌트 아키텍처 — 네이티브 기능만으로 설계하는 컴포넌트 시스템

전처리기 없이도 된다

핵심: Sass/Less 없이 네이티브 CSS만으로 컴포넌트 기반 아키텍처를 구축할 수 있는 시대가 왔다.

CSS Nesting, @layer, Container Queries, :has(), color-mix() — 이 기능들이 전부 브라우저에 내장되어 있다. 예전에는 Sass의 중첩이나 BEM 네이밍 컨벤션에 기대야 했던 것들을, 이제는 CSS 명세 자체가 해결한다.

비유하면 이렇다. 예전에는 요리를 하려면 밀키트(전처리기)가 필수였는데, 이제 슈퍼마켓(브라우저)에서 **손질된 재료(네이티브 기능)**를 직접 살 수 있게 된 거다.

기존 방식네이티브 대체
Sass 중첩CSS Nesting
BEM + !important 전쟁@layer (Cascade Layers)
JS 미디어쿼리 리스너Container Queries
부모 기반 JS 토글:has() 셀렉터
Sass color 함수color-mix()

CSS Nesting — 중첩이 네이티브다

Sass에서 가장 많이 쓰이던 기능이 중첩(nesting)이다. 이제 네이티브 CSS에서 동일하게 사용할 수 있다.

components/card.css — 네이티브 CSS Nesting
.card {
  padding: 1.5rem;
  border-radius: 0.75rem;
  background: var(--surface);
 
  & .card-title {
    font-size: 1.25rem;
    font-weight: 600;
    margin-block-end: 0.5rem;
  }
 
  & .card-body {
    color: var(--text-secondary);
    line-height: 1.6;
  }
 
  &:hover {
    box-shadow: 0 4px 12px rgb(0 0 0 / 0.1);
  }
}

&는 부모 셀렉터를 참조한다. Sass와 거의 동일한 문법이지만, 빌드 스텝 없이 브라우저가 직접 해석한다.

[💡 잠깐! 이 용어는?] CSS Nesting: CSS 규칙 안에 하위 규칙을 중첩해서 작성하는 기능. &로 부모 셀렉터를 참조하며, Sass의 중첩과 거의 동일한 문법이다. 2023년 말부터 주요 브라우저에서 모두 지원한다.


Cascade Layers — 우선순위 전쟁의 종결

CSS에서 가장 골치 아픈 문제 중 하나가 특이성(specificity) 관리다. 라이브러리 CSS가 내 스타일을 덮어쓰거나, !important를 남발하게 되는 상황 말이다.

@layer는 CSS 규칙을 **레이어(계층)**로 나누고, 레이어 간 우선순위를 명시적으로 선언한다.

styles/layers.css — Cascade Layers 구조
@layer reset, base, components, utilities;
 
@layer reset {
  *,
  *::before,
  *::after {
    box-sizing: border-box;
    margin: 0;
    padding: 0;
  }
}
 
@layer base {
  body {
    font-family: system-ui, sans-serif;
    line-height: 1.6;
    color: var(--text);
    background: var(--bg);
  }
}
 
@layer components {
  .btn {
    padding: 0.5rem 1rem;
    border-radius: 0.375rem;
    font-weight: 500;
    cursor: pointer;
  }
}
 
@layer utilities {
  .sr-only {
    position: absolute;
    width: 1px;
    height: 1px;
    overflow: hidden;
    clip: rect(0, 0, 0, 0);
  }
}

첫 줄의 @layer reset, base, components, utilities;가 핵심이다. 뒤에 선언된 레이어가 높은 우선순위를 가진다. 셀렉터 특이성과 관계없이 utilities 레이어의 규칙이 components를 이긴다.

비유하면 이렇다. 기존 CSS는 "누가 더 큰 목소리(특이성)로 말하느냐"의 싸움이었는데, @layer발언 순서를 미리 정해놓는 것과 같다. 순서가 뒤인 사람이 무조건 우선이다.

[💡 잠깐! 이 용어는?] 특이성(Specificity): CSS에서 같은 요소에 여러 규칙이 적용될 때, 어떤 규칙이 이기는지를 결정하는 점수 체계. #id > .class > element 순이다. @layer는 이 특이성 싸움 자체를 레이어 단위로 격리한다.


Button 컴포넌트 — Custom Properties API와 :has()

컴포넌트별 디자인 토큰을 커스텀 프로퍼티로 노출하면, 사용하는 쪽에서 유연하게 변형할 수 있다.

components/button.css — Custom Properties API
.btn {
  --_btn-bg: var(--btn-bg, var(--primary));
  --_btn-color: var(--btn-color, white);
  --_btn-padding: var(--btn-padding, 0.625rem 1.25rem);
 
  background: var(--_btn-bg);
  color: var(--_btn-color);
  padding: var(--_btn-padding);
  border: none;
  border-radius: 0.375rem;
  font-weight: 600;
  cursor: pointer;
  transition: opacity 0.2s;
 
  &:hover {
    opacity: 0.85;
  }
 
  &:focus-visible {
    outline: 2px solid var(--_btn-bg);
    outline-offset: 2px;
  }
}
 
.btn-group:has(.btn:focus-visible) {
  outline: 2px solid var(--primary);
  border-radius: 0.5rem;
}

--_btn-bg에서 _ 접두사는 "내부용" 관례다. 외부에서 --btn-bg를 설정하면 그 값을 쓰고, 없으면 --primary 폴백을 사용한다.

:has() 셀렉터는 자식/후손의 상태에 따라 부모를 스타일링할 수 있게 해준다. 위 예시에서 .btn-group 안의 버튼이 포커스를 받으면, 그룹 전체에 아웃라인이 표시된다. 예전에는 JavaScript로만 가능했던 패턴이다.


Card 컴포넌트 — Container Queries와 유동 타이포그래피

미디어 쿼리는 뷰포트 크기를 기준으로 레이아웃을 바꾼다. 그런데 컴포넌트가 사이드바에 들어가면? 뷰포트는 넓은데 컴포넌트 공간은 좁다. 미디어 쿼리로는 이 상황을 처리할 수 없다.

Container Queries는 컨테이너 크기를 기준으로 반응한다.

components/card.css — Container Queries
.card-wrapper {
  container-type: inline-size;
  container-name: card;
}
 
.card {
  display: grid;
  gap: 1rem;
  padding: 1.5rem;
  border-radius: 0.75rem;
  background: var(--surface);
}
 
@container card (min-width: 400px) {
  .card {
    grid-template-columns: 200px 1fr;
  }
 
  .card-title {
    font-size: clamp(1.125rem, 3cqi, 1.5rem);
  }
}
 
@container card (min-width: 700px) {
  .card {
    grid-template-columns: 300px 1fr;
    padding: 2rem;
  }
}

container-type: inline-size로 컨테이너를 선언하고, @container card (min-width: 400px)로 해당 컨테이너의 너비에 따라 스타일을 분기한다. cqi 단위는 컨테이너 인라인 크기의 1%를 뜻한다. clamp()와 결합하면 컨테이너 크기에 따라 유동적으로 변하는 타이포그래피를 만들 수 있다.

[💡 잠깐! 이 용어는?] Container Queries: 부모 컨테이너의 크기를 기준으로 스타일을 적용하는 기능. 미디어 쿼리가 "브라우저 창이 이 크기일 때"라면, Container Queries는 "이 박스가 이 크기일 때"다.


네비게이션 컴포넌트는 두 가지 흥미로운 패턴을 보여준다.

네임드 컨테이너로 격리

components/nav.css — 네임드 컨테이너
.nav-container {
  container-type: inline-size;
  container-name: nav;
}
 
@container nav (max-width: 600px) {
  .nav-list {
    flex-direction: column;
  }
 
  .nav-item {
    border-bottom: 1px solid var(--border);
  }
}

컨테이너에 이름(nav)을 붙이면, 중첩된 컨테이너가 있어도 정확히 어떤 컨테이너를 기준으로 할지 지정할 수 있다.

수량 쿼리로 아이템 개수에 반응

components/nav.css — 수량 쿼리
.nav-list {
  display: flex;
  gap: 0.5rem;
}
 
.nav-item:first-child:nth-last-child(n + 6),
.nav-item:first-child:nth-last-child(n + 6) ~ .nav-item {
  flex: 1;
  text-align: center;
  font-size: 0.875rem;
}

아이템이 6개 이상일 때 레이아웃을 바꾸는 트릭이다. :first-child:nth-last-child(n + 6)은 "첫 번째 자식이면서, 뒤에서 세었을 때 6번째 이상인 요소" — 즉 전체 아이템이 6개 이상일 때만 매칭된다.


Color Functions — color-mix()

색상 조합을 CSS 단독으로 처리할 수 있다. Sass의 darken(), lighten(), mix()를 대체한다.

styles/colors.css — color-mix() 활용
:root {
  --primary: #3b82f6;
  --primary-hover: color-mix(in srgb, var(--primary), black 15%);
  --primary-light: color-mix(in srgb, var(--primary), white 30%);
  --primary-subtle: color-mix(in srgb, var(--primary), transparent 85%);
}
 
.badge {
  background: var(--primary-subtle);
  color: var(--primary);
  padding: 0.25rem 0.75rem;
  border-radius: 999px;
}

color-mix(in srgb, var(--primary), black 15%)는 primary 색상에 검정을 15% 섞는다. transparent를 섞으면 알파 값을 조절할 수 있다. Sass 함수와 달리 런타임에 동작하므로, 테마 전환 시 커스텀 프로퍼티 값만 바꾸면 파생 색상이 자동으로 갱신된다.


점진적 도입 전략

이 모든 기능을 한꺼번에 적용할 필요는 없다. 브라우저 지원 현황과 프로젝트 상황에 맞게 단계적으로 도입하는 것이 현실적이다.

기능브라우저 지원도입 시점
CSS Nesting모든 주요 브라우저지금 바로 — Sass 중첩 대체
@layer모든 주요 브라우저지금 바로 — 새 프로젝트부터 적용
:has()모든 주요 브라우저지금 바로 — JS 토글 대체
Container Queries모든 주요 브라우저컴포넌트 단위로 — 사이드바/카드부터
color-mix()모든 주요 브라우저디자인 시스템에 — Sass 색상 함수 대체

2026년 기준으로 위 기능들은 전부 주요 브라우저(Chrome, Firefox, Safari, Edge)에서 지원된다. IE를 지원해야 하는 레거시 프로젝트가 아니라면 바로 사용할 수 있다.


정리

  • CSS Nesting: Sass 중첩을 네이티브로 대체한다. & 문법이 거의 동일하다.
  • @layer: 특이성 전쟁을 끝낸다. 레이어 순서로 우선순위를 명시적으로 관리한다.
  • :has(): 자식 상태에 따라 부모를 스타일링한다. JavaScript 의존도를 줄인다.
  • Container Queries: 컴포넌트가 들어간 공간에 반응한다. 뷰포트가 아니라 컨테이너 크기 기준이다.
  • color-mix(): 런타임 색상 조합. 테마 시스템과 궁합이 좋다.

결국 방향은 하나다. 전처리기나 JavaScript에 의존하던 패턴을 CSS 네이티브로 가져오는 것. 빌드 스텝이 줄고, 런타임 비용이 줄고, 브라우저가 직접 최적화할 수 있는 영역이 넓어진다.


참고:

관심 있을 만한 포스트

SVG 아이콘 — 코드 배포 없이 프로덕트 팀이 직접 관리하는 법

CSS mask-image와 S3를 조합해 개발자 개입 없이 아이콘을 교체하는 패턴을 소개한다.

SVGCSS

CSS 한 줄로 업그레이드 — 지금 바로 적용할 수 있는 12가지 모던 CSS 속성

aspect-ratio부터 scrollbar-gutter까지, 한 줄 추가로 스타일시트를 현대화하는 12가지 CSS 속성을 정리한다.

CSS모던 CSS

CSS @property — 커스텀 속성에 타입을 부여하는 방법

CSS @property at-rule로 커스텀 속성에 타입 정의, 상속 제어, 폴백을 추가해 렌더링 안정성과 애니메이션 가능성을 확보하는 방법을 다룬다.

CSS@property

CSS :near() — 마우스가 '가까이' 오면 반응하는 새로운 의사 클래스

CSS Working Group에 제안된 :near() 의사 클래스는 포인터 근접성을 감지해 호버 전에 UI를 활성화하는 새로운 상호작용 패턴을 연다.

CSS:near()

CSS만으로 커스텀 셀렉트 박스 — JavaScript 150줄이 사라지는 순간

Chrome 135에 도입된 appearance: base-select와 sibling-index()로 JavaScript 없이 완전한 커스텀 드롭다운을 구현하는 방법을 분석한다.

CSSbase-select

sibling-index()로 만드는 CSS 스크롤 소용돌이 — JavaScript 없이 수백 개 요소 애니메이션

CSS sibling-index()와 scroll-driven animations를 결합해 순수 CSS만으로 텍스트 보텍스 효과를 구현하는 기법을 다룬다.

CSSsibling-index

Interop 2026 — 브라우저 전쟁이 끝나고 표준 전쟁이 시작됐다

Chrome, Safari, Firefox가 합의한 20개 웹 표준 집중 영역과 프론트엔드 개발자가 주목해야 할 핵심 기능을 정리한다.

InteropCSS

CSS Stacking Context — z-index: 99999를 줬는데 왜 안 올라올까

CSS 쌓임 맥락의 생성 조건, z-index의 실제 작동 원리, 그리고 레이아웃 버그를 디버깅하는 실전 전략을 정리한다.

CSSz-index