dev.syw

유틸리티·컴포넌트·토큰 기반 설계와 스코프 전략으로 대규모에서 무너지지 않는 CSS를 만든다.

실전 스타일 설계 패턴과 확장 가능한 아키텍처

입문편에서 CSS 변수(커스텀 프로퍼티)를 선언하고 BEM 네이밍으로 컴포넌트를 분리하는 법을 익혔다면, 이제는 그 위에 무엇을 쌓아야 하는지 생각할 때입니다. 소규모 프로젝트에서는 작동하던 설계가 팀 규모가 커지거나 컴포넌트 수가 수백 개를 넘으면 갑자기 관리 불가 상태가 됩니다. 변수 이름이 충돌하고, 전역 스타일이 의도치 않게 오염되고, 반응형 규칙이 뷰포트가 아니라 컴포넌트 컨텍스트에 맞지 않는 문제가 대표적입니다.

이 레슨에서는 디자인 토큰 계층화부터 CSS 캡슐화, 컨테이너 쿼리, 논리 속성, 상태·변형 관리까지 대규모 CSS를 설계할 때 반드시 알아야 할 패턴을 실무 맥락과 함께 다룹니다.

학습 목표

  • 디자인 토큰을 원시·시맨틱·컴포넌트 세 계층으로 나눠 체계적으로 선언하고 활용할 수 있다.
  • 유틸리티 우선컴포넌트 기반 방법론을 비교하고, 프로젝트 상황에 맞게 혼합 전략을 수립할 수 있다.
  • CSS ModulesShadow DOM을 사용해 스타일 스코프를 캡슐화하고 충돌을 방지할 수 있다.
  • 컨테이너 쿼리로 뷰포트가 아닌 컴포넌트 단위의 반응형 설계를 구현할 수 있다.
  • 논리 속성으로 다국어·RTL 레이아웃에 강건한 CSS를 작성할 수 있다.

디자인 토큰 계층화 — 원시·시맨틱·컴포넌트 변수

입문편에서는 --color-blue: #3b82f6 같은 단순 변수를 선언했습니다. 실무에서는 이 변수가 수십 개로 불어나면서 "어느 값을 어디서 써야 하는지" 알 수 없는 혼돈이 생깁니다. 이를 해결하는 방법이 3계층 토큰 설계입니다.

  • 원시 토큰(Primitive tokens): 팔레트의 원재료. 이름이 값 자체를 나타냄.
  • 시맨틱 토큰(Semantic tokens): 의미·의도를 이름에 담음. 원시 토큰을 참조.
  • 컴포넌트 토큰(Component tokens): 특정 컴포넌트에만 쓰이는 토큰. 시맨틱 토큰을 참조.
/* ── 1단계: 원시 토큰 ── */
:root {
  --primitive-blue-500: #3b82f6;
  --primitive-blue-600: #2563eb;
  --primitive-blue-700: #1d4ed8;
  --primitive-gray-100: #f3f4f6;
  --primitive-gray-900: #111827;
  --primitive-space-4: 1rem;
  --primitive-space-8: 2rem;
  --primitive-radius-md: 0.5rem;
}

/* ── 2단계: 시맨틱 토큰 ── */
:root {
  --color-brand-primary:      var(--primitive-blue-500);
  --color-brand-primary-dark: var(--primitive-blue-700);
  --color-surface-default:    var(--primitive-gray-100);
  --color-text-primary:       var(--primitive-gray-900);
  --space-component-gap:      var(--primitive-space-4);
  --radius-component:         var(--primitive-radius-md);
}

/* ── 3단계: 컴포넌트 토큰 ── */
.button {
  --button-bg:             var(--color-brand-primary);
  --button-bg-hover:       var(--color-brand-primary-dark);
  --button-padding-inline: var(--primitive-space-4);
  --button-radius:         var(--radius-component);

  background-color: var(--button-bg);
  border-radius:    var(--button-radius);
  padding-inline:   var(--button-padding-inline);
  color:            #fff;
  transition:       background-color 0.2s;
}

.button:hover {
  background-color: var(--button-bg-hover);
}

💡 TIP 컴포넌트 토큰은 컴포넌트 내부에 선언하세요. 외부에서 --button-bg를 오버라이드하면 전체 팔레트를 건드리지 않고도 테마를 바꿀 수 있습니다.

계층이 나뉘면 다크 모드 전환도 시맨틱 계층만 바꾸면 됩니다. 원시 토큰이나 컴포넌트 토큰은 손댈 필요가 없습니다.

/* 다크 모드: 시맨틱 토큰만 재정의 */
@media (prefers-color-scheme: dark) {
  :root {
    --color-surface-default: #1f2937;
    --color-text-primary:    #f9fafb;
    --color-brand-primary:   var(--primitive-blue-600);
  }
}

유틸리티 우선 vs 컴포넌트 기반 — 방법론 비교와 혼합 전략

유틸리티 우선(Utility-First) 방식은 Tailwind CSS가 대표적으로, HTML 클래스 조합으로 스타일을 구성합니다. 컴포넌트 기반(Component-Based) 방식은 BEM·SMACSS처럼 의미론적 클래스에 스타일을 묶습니다.

기준유틸리티 우선컴포넌트 기반
CSS 번들 크기퍼지 후 매우 작음컴포넌트가 늘수록 증가
마크업 가독성클래스 나열로 장황의미론적으로 깔끔
재사용성클래스 자체가 재사용 단위컴포넌트 클래스가 재사용 단위
오버라이드 위험낮음 (특이도 균일)높음 (구체적 선택자 충돌)
디자인 시스템 적합성토큰 연동이 복잡토큰 직접 참조 용이

실무에서는 두 방법론을 혼합하는 것이 현실적입니다. 레이아웃·간격·색상은 유틸리티, 복잡한 인터랙션 상태는 컴포넌트 클래스로 나누는 전략이 효과적입니다.

/* ── 컴포넌트 기반: 인터랙션 상태 관리 ── */
.card {
  /* 구조·기본 스타일만 정의 */
  border-radius: var(--radius-component);
  overflow: hidden;
  box-shadow: 0 1px 3px rgb(0 0 0 / 0.12);
  transition: box-shadow 0.2s, transform 0.2s;
}

.card--elevated:hover {
  box-shadow: 0 8px 24px rgb(0 0 0 / 0.2);
  transform: translateY(-2px);
}

.card--disabled {
  opacity: 0.5;
  pointer-events: none;
}
<!-- ── 혼합: 유틸리티(간격·색상) + 컴포넌트(상태) ── -->
<!-- ✅ 권장: 레이아웃은 유틸리티, 상태는 컴포넌트 클래스 -->
<div class="card card--elevated p-4 bg-white text-gray-900">...</div>

<!-- ❌ 피해야 할 패턴: 모든 상태를 유틸리티로 표현 -->
<div class="rounded-lg overflow-hidden shadow hover:shadow-xl hover:-translate-y-0.5 transition-all ...">
HTML

⚠️ 주의 유틸리티 클래스로 hover:, disabled: 등 인터랙션 변형을 과도하게 표현하면 마크업이 로직의 일부가 되어 버립니다. 상태가 3개 이상 조합될 때는 컴포넌트 클래스로 추출하세요.

CSS Modules와 Shadow DOM으로 스타일 캡슐화

CSS Modules

CSS Modules는 빌드 타임에 클래스 이름을 고유한 해시로 변환해 스코프를 보장합니다. React·Vue 프로젝트에서 널리 쓰입니다.

/* Button.module.css */
.root {
  background-color: var(--color-brand-primary);
  border-radius:    var(--radius-component);
  padding:          0.5rem 1.25rem;
  color:            #fff;
  font-weight:      600;
}

/* variant: 컴포넌트 토큰 오버라이드 */
.root.ghost {
  --button-bg: transparent;
  background-color: var(--button-bg);
  border: 2px solid var(--color-brand-primary);
  color: var(--color-brand-primary);
}

/* 상태 클래스는 :global()로 외부에서 주입 가능 */
.root:global(.is-loading) {
  cursor: wait;
  opacity: 0.7;
}
// Button.jsx
import styles from './Button.module.css';

function Button({ variant = 'filled', isLoading, children }) {
  return (
    <button
      className={[
        styles.root,
        variant === 'ghost' ? styles.ghost : '',
        isLoading ? 'is-loading' : '',    // :global() 대상
      ].filter(Boolean).join(' ')}
    >
      {children}
    </button>
  );
}

빌드 후 Button_root__3xK2a처럼 해시가 붙어 다른 컴포넌트의 .root와 절대 충돌하지 않습니다. 디자인 토큰(CSS 변수)은 :root에 선언되므로 Modules와 함께 사용해도 전역에서 참조 가능합니다.

Shadow DOM

웹 컴포넌트의 Shadow DOM은 CSS가 호스트 외부로 유출되거나, 외부 스타일이 내부로 침투하는 것을 브라우저 수준에서 차단합니다.

class MyCard extends HTMLElement {
  connectedCallback() {
    const shadow = this.attachShadow({ mode: 'open' });

    // 스타일이 Shadow 내부에만 적용됨
    shadow.innerHTML = `
      <style>
        :host {
          display: block;
          border-radius: var(--radius-component, 0.5rem);
          overflow: hidden;
        }
        :host([variant="outlined"]) {
          border: 2px solid var(--color-brand-primary, #3b82f6);
          background: transparent;
        }
        .body {
          padding: var(--space-component-gap, 1rem);
        }
      </style>
      <div class="body">
        <slot></slot>
      </div>
    `;
  }
}
customElements.define('my-card', MyCard);
JavaScript
<!-- 외부에서 CSS 변수로 테마 주입 가능 -->
<style>
  my-card {
    --radius-component: 1rem;
    --color-brand-primary: #8b5cf6;
  }
</style>
<my-card variant="outlined">내용</my-card>
HTML

💡 TIP Shadow DOM은 외부 CSS를 차단하지만, CSS 커스텀 프로퍼티(변수)는 Shadow boundary를 관통합니다. 이 특성을 이용해 테마 시스템을 설계하면 캡슐화와 테마 유연성을 동시에 얻을 수 있습니다.

컨테이너 쿼리로 컴포넌트 단위 반응형 설계

입문편 미디어쿼리는 뷰포트 너비를 기준으로 레이아웃을 바꿨습니다. 문제는 같은 카드 컴포넌트가 사이드바에 배치되면 좁고, 메인 영역에 배치되면 넓다는 사실을 미디어쿼리는 알 수 없다는 점입니다. 컨테이너 쿼리가 이 문제를 해결합니다.

/* 1. 컨테이너로 지정 */
.card-wrapper {
  container-type: inline-size;
  container-name: card;
}

/* 2. 컨테이너 크기 기준으로 내부 컴포넌트 반응 */
.card {
  display: grid;
  grid-template-columns: 1fr;  /* 기본: 단열 */
  gap: 1rem;
}

/* 컨테이너가 480px 이상일 때 가로 배치 */
@container card (inline-size >= 480px) {
  .card {
    grid-template-columns: auto 1fr;
  }

  .card__image {
    width: 180px;
    aspect-ratio: 1;
  }
}

/* 컨테이너가 720px 이상일 때 액션 버튼 추가 노출 */
@container card (inline-size >= 720px) {
  .card__actions {
    display: flex;
  }
}
<!-- 사이드바: container 좁으므로 단열 레이아웃 -->
<aside class="sidebar">
  <div class="card-wrapper">
    <article class="card">...</article>
  </div>
</aside>

<!-- 메인: container 넓으므로 가로 레이아웃 자동 적용 -->
<main class="main-content">
  <div class="card-wrapper">
    <article class="card">...</article>
  </div>
</main>
HTML

container-type에는 inline-size(너비 기준)와 size(너비+높이)가 있습니다. size는 높이도 쿼리 대상으로 포함하므로 성능 비용이 크기 때문에 대부분의 경우 inline-size가 권장됩니다.

컨테이너 쿼리 단위 (cqi, cqb)

컨테이너 쿼리 단위를 사용하면 컨테이너 크기에 상대적인 크기를 지정할 수 있습니다.

.card__title {
  /* 컨테이너 인라인 사이즈의 5% */
  font-size: clamp(1rem, 5cqi, 2rem);
}

⚠️ 주의 container-type을 설정하면 해당 요소는 **새로운 쌓임 맥락(stacking context)**을 생성하고, position: fixed 자식이 컨테이너 기준으로 배치됩니다. 모달·드롭다운이 컨테이너 안에 있다면 의도치 않은 클리핑이 발생할 수 있습니다.

논리 속성과 국제화 대응 레이아웃

한국어·영어처럼 좌에서 우로(LTR) 읽는 언어와 아랍어·히브리어처럼 우에서 좌로(RTL) 읽는 언어를 동시에 지원하려면 방향에 의존하지 않는 속성을 써야 합니다.

논리 속성은 물리적 방향(left, right, top, bottom) 대신 글쓰기 방향(writing mode) 기준의 inline-start, inline-end, block-start, block-end를 사용합니다.

물리 속성논리 속성설명
margin-leftmargin-inline-start인라인 축 시작
margin-rightmargin-inline-end인라인 축 끝
padding-toppadding-block-start블록 축 시작
widthinline-size인라인 방향 크기
heightblock-size블록 방향 크기
border-leftborder-inline-start인라인 시작 테두리
text-align: lefttext-align: start시작 방향 정렬
/* ❌ 물리 속성: RTL에서 뒤집히지 않음 */
.nav-item {
  padding-left: 1rem;
  border-left: 3px solid var(--color-brand-primary);
  text-align: left;
}

/* ✅ 논리 속성: LTR/RTL 자동 대응 */
.nav-item {
  padding-inline-start: 1rem;
  border-inline-start: 3px solid var(--color-brand-primary);
  text-align: start;
}
/* 카드 레이아웃 전체를 논리 속성으로 */
.card {
  inline-size: 100%;
  max-inline-size: 40rem;
  padding-block: 1.5rem;
  padding-inline: 2rem;
  margin-inline: auto;      /* 가운데 정렬 */
  border-start-start-radius: var(--radius-component);
  border-start-end-radius:   var(--radius-component);
}

/* RTL 레이아웃 확인: HTML에서 dir="rtl" 적용 시 자동 반영 */
<!-- RTL 언어 사이트 -->
<html lang="ar" dir="rtl">
  <!-- .card 스타일이 자동으로 오른쪽 기준으로 전환 -->
</html>
HTML

💡 TIP writing-mode: vertical-rl을 사용하는 세로쓰기(일본어 등) 레이아웃에서도 논리 속성은 동일하게 동작합니다. padding-block-start는 가로쓰기(horizontal-tb)에서는 위쪽 패딩이 되지만, 세로쓰기 vertical-rl에서는 오른쪽 패딩이 됩니다. 즉 물리 방향이 writing-mode에 따라 자동으로 달라집니다.

상태·변형(Variant) 관리 패턴과 안티패턴 제거

컴포넌트가 복잡해지면 상태(state)와 변형(variant)이 폭발적으로 늘어납니다. 잘못 설계하면 "헬 CSS"가 됩니다.

안티패턴: 클래스 중첩 오버라이드

/* ❌ 안티패턴: 특이도 전쟁 */
.btn { background: blue; }
.btn.btn-primary { background: darkblue; }
.btn.btn-primary.btn-large { font-size: 1.25rem; padding: 1rem 2rem; }
.modal .btn.btn-primary { background: purple; } /* 컨텍스트 오버라이드 */

이런 구조에서는 새 변형을 추가할 때마다 기존 선택자를 분석해야 하고, 특이도 문제로 !important가 등장하기 시작합니다.

권장 패턴: CSS 커스텀 프로퍼티 기반 변형

/* ✅ 권장: 컴포넌트 토큰을 variant별로 오버라이드 */
.btn {
  /* 컴포넌트 토큰 기본값 */
  --btn-bg:          var(--color-brand-primary);
  --btn-color:       #fff;
  --btn-font-size:   1rem;
  --btn-padding:     0.5rem 1.25rem;
  --btn-border:      none;

  background-color: var(--btn-bg);
  color:            var(--btn-color);
  font-size:        var(--btn-font-size);
  padding:          var(--btn-padding);
  border:           var(--btn-border);
  border-radius:    var(--radius-component);
  cursor:           pointer;
}

/* 변형: 토큰만 교체 */
.btn[data-variant="ghost"] {
  --btn-bg:     transparent;
  --btn-color:  var(--color-brand-primary);
  --btn-border: 2px solid var(--color-brand-primary);
}

.btn[data-variant="danger"] {
  --btn-bg:    #dc2626;
  --btn-color: #fff;
}

/* 크기 변형 */
.btn[data-size="lg"] {
  --btn-font-size: 1.125rem;
  --btn-padding:   0.75rem 2rem;
}

/* 상태: CSS 의사 클래스로 분리 */
.btn:disabled {
  opacity: 0.5;
  cursor: not-allowed;
}

.btn:focus-visible {
  outline: 3px solid var(--color-brand-primary);
  outline-offset: 2px;
}
<!-- 변형 조합이 클래스 없이 가능 -->
<button class="btn" data-variant="ghost" data-size="lg">취소</button>
<button class="btn" data-variant="danger">삭제</button>
<button class="btn" disabled>비활성</button>
HTML

data-* 속성을 변형 키로 쓰면 클래스 조합 폭발을 막고, JavaScript에서 dataset으로 쉽게 제어할 수 있습니다.

:has() 선택자로 부모 상태 표현

/* ✅ 입력 필드가 invalid일 때 레이블 색상 변경 */
.form-group:has(input:invalid) .form-label {
  color: #dc2626;
}

/* ✅ 체크박스 선택 시 카드 전체 강조 */
.card:has(input[type="checkbox"]:checked) {
  outline: 2px solid var(--color-brand-primary);
  outline-offset: 2px;
}

기존에는 JavaScript로 부모 클래스를 토글해야 했던 패턴을 CSS만으로 표현할 수 있습니다.

레이어(@layer)와 변형 관리

CSS Cascade Layers를 사용하면 변형 오버라이드의 충돌을 계층적으로 해결할 수 있습니다.

/* 레이어 선언 순서가 우선순위를 결정 */
@layer base, components, variants, utilities;

@layer base {
  .btn { /* 기반 스타일 */ }
}

@layer components {
  .card { /* 컴포넌트 스타일 */ }
}

@layer variants {
  /* 변형은 항상 components 위에 쌓임 */
  .btn[data-variant="ghost"] { /* 토큰 오버라이드 */ }
}

@layer utilities {
  /* 유틸리티는 최상위 — 강제 적용 */
  .sr-only { position: absolute; clip: rect(0 0 0 0); /* ... */ }
}

레이어 내부의 특이도 전쟁은 레이어 순서로 자동 해소됩니다. !important를 쓰지 않아도 됩니다.

요약

  • 3계층 토큰 설계(원시·시맨틱·컴포넌트)는 다크 모드·테마 전환·컴포넌트 커스터마이징을 일관성 있게 해결한다.
  • 유틸리티 우선컴포넌트 기반 방법론은 대립이 아니라 상호 보완 관계이며, 레이아웃은 유틸리티, 상태·변형은 컴포넌트 클래스로 역할을 나누는 혼합 전략이 효과적이다.
  • CSS Modules는 빌드 타임 해시로, Shadow DOM은 브라우저 네이티브 캡슐화로 스타일 충돌을 방지하며, CSS 변수는 두 방식 모두에서 Shadow boundary를 관통한다.
  • 컨테이너 쿼리는 뷰포트가 아닌 컨테이너 크기를 기준으로 컴포넌트 레이아웃을 전환해 재사용성과 반응성을 동시에 높인다.
  • 논리 속성(inline-start, block-end 등)은 LTR/RTL·세로쓰기를 CSS 수정 없이 지원하는 국제화 필수 기법이다.
  • 커스텀 프로퍼티 기반 변형 관리@layer 활용은 특이도 전쟁과 !important 남용 같은 CSS 안티패턴을 구조적으로 제거한다.

연습문제

  1. 다음 원시 토큰을 기반으로 시맨틱 토큰과 버튼 컴포넌트 토큰을 완성하세요. 다크 모드에서 배경색과 텍스트 색상이 반전되어야 합니다.

    :root {
      --primitive-indigo-500: #6366f1;
      --primitive-indigo-700: #4338ca;
      --primitive-white: #ffffff;
      --primitive-gray-900: #111827;
      --primitive-gray-50: #f9fafb;
    }
    

    힌트 시맨틱 토큰에서 --color-surface--color-text-primary를 정의하고, 다크 모드 @media에서 해당 토큰만 재정의하세요.

  2. 아래 .card 컴포넌트가 **좁은 컨테이너(400px 미만)**에서는 이미지가 상단에 단열로 쌓이고, **넓은 컨테이너(400px 이상)**에서는 이미지가 왼쪽 180px 고정 가로 배치가 되도록 컨테이너 쿼리를 작성하세요.

    <div class="card-container">
      <div class="card">
        <img class="card__image" src="..." alt="">
        <div class="card__body">...</div>
      </div>
    </div>
    
    HTML

    힌트 .card-containercontainer-type: inline-size를 설정하고, @container로 분기하세요.

  3. 다음 !important와 클래스 중첩 오버라이드로 가득한 코드를 커스텀 프로퍼티 기반 변형 패턴으로 리팩터링하세요.

    .alert { background: #fef2f2; color: #991b1b; padding: 1rem; border-radius: 0.5rem; }
    .alert.alert-success { background: #f0fdf4 !important; color: #166534 !important; }
    .alert.alert-warning { background: #fffbeb !important; color: #92400e !important; }
    .sidebar .alert { padding: 0.5rem !important; }
    

    힌트 --alert-bg, --alert-color, --alert-padding 토큰을 정의하고, data-variantdata-size 속성으로 변형을 표현하세요.

  4. 아래 레이아웃 코드를 논리 속성으로 전환해 RTL 언어도 별도 CSS 없이 지원되도록 수정하세요.

    .nav-link {
      padding-left: 1.25rem;
      padding-right: 1.25rem;
      padding-top: 0.5rem;
      padding-bottom: 0.5rem;
      border-left: 4px solid transparent;
      text-align: left;
    }
    
    .nav-link.active {
      border-left-color: #6366f1;
      text-align: left;
    }
    

    힌트 padding-inline, padding-block, border-inline-start, text-align: start를 사용하세요.

💡 연습문제 풀이

불러오는 중…

함께 보면 좋은 자료

댓글 0

CSS 심화” 강좌에 대한 댓글입니다.

댓글을 작성하려면 로그인이 필요합니다.