← 목록으로 돌아가기

CSS @layer 실전 가이드: 디자인 시스템에서 명시도(Specificity) 전쟁을 끝내는 캐스케이드 레이어 설계

CSS

CSS cascade layers and specificity management guide for design systems

!important가 무한 증식하는 순간, 뭔가 잘못된 것이다

디자인 시스템을 운영하다 보면 어느 순간 CSS 파일 전체에서 !important가 조용히 번식해 있는 광경을 목격하게 됩니다. 처음에는 단 한 줄이었습니다. "Tailwind 유틸리티 클래스가 컴포넌트 스타일을 덮어쓰지 못하네, 일단 !important 붙이자." 그 다음에는 라이브러리 팀이 "우리 .Button 스타일이 페이지 CSS에 묻히니까 !important 붙이겠습니다"라고 합니다. 결국 모든 팀이 같은 전략을 쓰고, CSS는 누가 더 세게 소리 지르는지 겨루는 경쟁장이 됩니다.

이 문제의 뿌리는 명시도(Specificity)에 있습니다. 셀렉터가 얼마나 구체적이냐에 따라 어떤 스타일이 이기는지 결정되는데, 서드파티 라이브러리·Tailwind·글로벌 CSS·컴포넌트 스코프 스타일이 한 페이지에 공존할 때 이 우선순위 게임은 예측 불가능한 양상으로 흐릅니다. MUI의 .MuiButton-root가 우리 .btn-primary를 덮어쓰고, shadcn/ui 컴포넌트가 Tailwind hover: 클래스를 무시하고, 분명 올바른 클래스를 붙였는데 왜 적용이 안 되는지 DevTools 앞에서 한참을 보냅니다.

CSS @layer(캐스케이드 레이어)는 이 혼란에 구조적인 해법을 제시합니다. 명시도에 의존하지 않고, 레이어 선언 순서로 스타일 우선순위를 명시적으로 결정합니다. 2026년 기준 모든 주요 브라우저에서 Baseline에 진입한 이 기능이 실무 디자인 시스템에서 어떻게 작동하는지, 5단계 레이어 설계 패턴과 Tailwind v4 통합 전략, 기존 !important 코드를 마이그레이션하는 체크리스트까지 순서대로 살펴봅니다.


1. CSS 명시도가 충돌하는 진짜 이유

명시도 계산은 잘 알려진 규칙입니다. 인라인 스타일 > #id > .class / [attr] / :pseudo-class > element / ::pseudo-element 순서로 가중치가 매겨집니다. 각각을 (a, b, c) 형태로 표현하면, #header .nav a:hover는 (1, 1, 1)로 계산됩니다.

문제는 이 계산이 선언 순서를 완전히 무시한다는 점입니다. 다음 상황을 봅시다.

/* 전역 리셋 또는 페이지 CSS */
.btn-primary {
  background-color: #0070f3;
  color: white;
}

/* MUI가 삽입하는 스타일 (나중에 주입됨) */
.MuiButton-root.MuiButton-contained {
  background-color: #1976d2;
  color: rgba(0, 0, 0, 0.87);
}

/* 우리 커스텀 클래스 */
.my-btn {
  background-color: tomato;
}

.MuiButton-root.MuiButton-contained는 클래스가 두 개이므로 명시도 (0, 2, 0)입니다. .btn-primary.my-btn은 (0, 1, 0)입니다. MUI 스타일이 먼저 선언됐어도, 명시도가 높기 때문에 이깁니다. 아무리 나중에 .my-btn { background: tomato }를 써도 효과가 없습니다.

여기서 사람들이 빠지는 함정 세 가지를 정리합니다.

안티패턴 1: !important 남발. 단기적으로는 작동하지만 !important끼리 충돌하면 다시 선언 순서가 결정권을 가집니다. 더 나중에 선언된 !important가 이기고, 또 그것을 이기려면 더 나중에 선언해야 합니다. 무한 군비 경쟁이 시작됩니다.

안티패턴 2: 글로벌 CSS 우선순위 의존. <head>에서 어떤 <link>가 마지막에 오는지에 의존해 스타일 순서를 관리하는 방식입니다. 번들러 설정이나 동적 로딩 순서가 조금만 바뀌어도 깨집니다. 빌드 파이프라인에 의존한 스타일 관리는 언젠가 반드시 사고가 납니다.

안티패턴 3: 인라인 style 속성 남발. 명시도가 가장 높아 단기 해결은 되지만, 디자인 토큰·다크 모드·반응형 로직을 모두 무력화합니다. CSS 변수 기반 테마가 동작하지 않고, :hover 같은 pseudo-class 스타일도 무시됩니다.


2. @layer 동작 원리: 명시도보다 레이어 순서가 먼저다

CSS Working Group이 명세한 캐스케이드 레이어(CSS Cascade Level 5, www.w3.org/TR/css-cascade-5/)는 기존 캐스케이드 알고리즘에 새로운 우선순위 축을 추가합니다. 브라우저가 "어떤 스타일을 적용할까" 결정하는 순서는 다음과 같이 바뀝니다.

  1. Origin(출처): 브라우저 기본값 < 사용자 스타일 < 작성자 스타일
  2. 레이어(Layer) 순서: 나중에 선언된 레이어가 이긴다
  3. 명시도: 같은 레이어 안에서만 적용
  4. 선언 순서: 명시도가 같을 때 마지막에 선언된 것이 이긴다

핵심은 레이어 순서가 명시도보다 먼저 평가된다는 점입니다. 다음 예시를 보면 직관적으로 이해됩니다.

/* 레이어 선언 순서 확정 (이 순서가 우선순위 순서) */
@layer reset, base, components, utilities;

@layer reset {
  /* 명시도가 높은 셀렉터여도 reset 레이어는 항상 가장 약함 */
  #root h1 {
    font-size: 16px;
  }
}

@layer utilities {
  /* 명시도가 낮은 단일 클래스여도 utilities 레이어는 항상 이긴다 */
  .text-xl {
    font-size: 1.25rem;
  }
}

#root h1의 명시도는 (1, 0, 1)로 매우 높지만, reset 레이어에 속합니다. .text-xl은 (0, 1, 0)으로 명시도가 낮지만, utilities 레이어에 속합니다. 결과는 .text-xl이 이깁니다. 레이어 순서가 명시도 계산을 완전히 압도합니다.

레이어 바깥에 선언된 스타일은 모든 명명 레이어보다 높은 우선순위를 갖습니다. W3C 명세(CSS Cascade Level 5)는 이를 "암묵적인 최종 레이어(implicit final layer)"로 취급한다고 정의합니다. 이 점을 활용해 레이어에 속하지 않는 스타일을 "최종 오버라이드 존"으로 쓸 수 있습니다.

익명 레이어(@layer { ... } 이름 없이 선언)는 한 번 닫히면 나중에 다시 열어서 규칙을 추가할 수 없습니다. 명명 레이어와 섞이면 선언 순서로만 우선순위가 정해집니다. 실무에서는 혼란을 피하기 위해 항상 명명 레이어를 사용하는 것을 권장합니다.


3. 5단계 레이어 설계 패턴

디자인 시스템 마이그레이션 작업에서 가장 효과적이었던 레이어 구조는 5단계로 나누는 방식이었습니다. 각 레이어가 담당하는 책임 영역을 명확히 구분하면, 어떤 스타일이 어디에 있어야 하는지 팀 전체가 빠르게 합의할 수 있습니다.

/* entry.css 또는 global.css 최상단에 레이어 순서 선언 */
@layer reset, tokens, base, components, utilities;

레이어 우선순위 비교표

레이어우선순위책임 영역예시
reset가장 낮음브라우저 기본값 제거margin: 0, box-sizing: border-box
tokens낮음CSS 커스텀 프로퍼티(디자인 토큰) 정의--color-primary: #0070f3
base중간태그 수준 기본 스타일body { font-family: ... }, a { color: ... }
components높음재사용 컴포넌트 스타일.btn, .card, .modal
utilities가장 높음단일 목적 유틸리티.text-center, .mt-4, Tailwind 클래스

실제 구현 코드입니다.

/* 1. reset 레이어 */
@layer reset {
  *, *::before, *::after {
    box-sizing: border-box;
    margin: 0;
    padding: 0;
  }
  img, video {
    max-width: 100%;
    display: block;
  }
}

/* 2. tokens 레이어: CSS 커스텀 프로퍼티로 디자인 토큰 정의 */
@layer tokens {
  :root {
    --color-primary-500: #0070f3;
    --color-primary-600: #005cc5;
    --color-neutral-900: #111827;
    --space-4: 1rem;
    --space-6: 1.5rem;
    --radius-md: 0.375rem;
    --font-body: 'Pretendard', -apple-system, sans-serif;
  }
}

/* 3. base 레이어: 태그 기본 스타일 */
@layer base {
  body {
    font-family: var(--font-body);
    color: var(--color-neutral-900);
    line-height: 1.6;
  }
  h1, h2, h3 {
    line-height: 1.2;
    font-weight: 700;
  }
  a {
    color: var(--color-primary-500);
    text-decoration: underline;
  }
}

/* 4. components 레이어: 재사용 UI 컴포넌트 */
@layer components {
  .btn-primary {
    display: inline-flex;
    align-items: center;
    gap: var(--space-4);
    padding: 0.5rem var(--space-6);
    background-color: var(--color-primary-500);
    color: white;
    border-radius: var(--radius-md);
    font-weight: 600;
    transition: background-color 150ms ease;
  }

  .btn-primary:hover {
    background-color: var(--color-primary-600);
  }
}

/* 5. utilities 레이어: 단일 목적 오버라이드 */
@layer utilities {
  .sr-only {
    position: absolute;
    width: 1px;
    height: 1px;
    overflow: hidden;
    clip: rect(0, 0, 0, 0);
    white-space: nowrap;
  }
  .mt-auto { margin-top: auto; }
  .w-full { width: 100%; }
}

이 구조의 핵심은 utilities 레이어가 가장 높은 우선순위를 가지므로, 단일 클래스 유틸리티가 명시도 높은 컴포넌트 스타일도 안전하게 오버라이드한다는 점입니다. Tailwind를 비롯한 유틸리티 퍼스트 도구들이 이 레이어에 들어오면 !important 없이도 모든 컴포넌트 스타일을 덮어쓸 수 있습니다.


4. Tailwind CSS v4와 @layer 통합 전략

Tailwind CSS v4는 PostCSS 플러그인 방식을 떠나 네이티브 CSS 기반 통합으로 전환됐습니다. tailwind.config.js 대신 CSS 파일 자체에서 설정하고, 내부적으로 @layer 구조를 적극 활용합니다. 우리 레이어 시스템과 Tailwind를 함께 쓸 때 발생할 수 있는 충돌을 예방하는 전략이 필요합니다.

Before (v3 방식, 레이어 구조 없음):

/* v3에서 Tailwind 지시어는 전역 스코프에 주입됨 */
@tailwind base;
@tailwind components;
@tailwind utilities;

/* 이 커스텀 스타일이 Tailwind utilities와 같은 레벨에서 경쟁 */
.btn-primary {
  background: #0070f3;
}

After (v4 + @layer 통합):

/* global.css */
@import "tailwindcss"; /* v4: 단일 import로 처리 */

/* Tailwind v4는 내부적으로 @layer base, components, utilities 세 레이어를 사용 */
/* 우리 레이어 선언을 추가로 삽입해 커스텀 스코프를 확보 */
@layer reset, tokens;

@layer tokens {
  :root {
    --color-primary-500: #0070f3;
  }
}

@layer components {
  /* Tailwind의 base보다 높고, Tailwind utilities보다 낮은 스코프 */
  .btn-primary {
    padding: 0.5rem 1.25rem;
    background-color: var(--color-primary-500);
    border-radius: 0.375rem;
    color: white;
  }
}

/* v4에서 커스텀 유틸리티는 @utility 디렉티브로 등록 */
/* @layer utilities에 직접 쓰면 Tailwind 내부 레이어와 합산되어 선언 순서에 의존하게 됨 */
@utility shadow-brand {
  box-shadow: 0 4px 14px 0 rgba(0, 112, 243, 0.39);
}

Tailwind v4가 내부적으로 사용하는 레이어는 base, components, utilities 세 가지입니다. 커스텀 유틸리티를 추가할 때는 @layer utilities 블록 대신 @utility 디렉티브를 사용하는 것이 공식 권장 방식입니다. @utility로 등록된 클래스는 Tailwind 유틸리티 레이어에 자동으로 포함되며, 네이밍 충돌 위험을 줄이려면 팀 전용 접두어를 붙이는 것이 좋습니다.


5. 서드파티 컴포넌트 라이브러리 스타일 안전하게 오버라이드하기

shadcn/ui, Radix UI, MUI를 프로젝트에 도입하면 라이브러리가 주입하는 스타일과 우리 스타일 간의 우선순위 분쟁이 발생합니다. @layer를 사용하면 이 문제를 깔끔하게 해결할 수 있습니다.

MUI를 예로 들겠습니다. MUI는 기본적으로 <style> 태그를 <head>에 동적으로 삽입합니다. 삽입 시점에 따라 우선순위가 바뀌기 때문에 결과가 예측 불가능합니다. MUI는 StyledEngineProviderinjectFirst prop으로 삽입 위치를 제어하는 방법을 제공하는데, 이를 @layer와 결합하면 완전히 예측 가능해집니다.

// app/providers.tsx (Next.js App Router 기준)
import { StyledEngineProvider } from '@mui/material/styles';

export function Providers({ children }: { children: React.ReactNode }) {
  return (
    // MUI 스타일이 우리 CSS보다 먼저 삽입되게 함
    <StyledEngineProvider injectFirst>
      {children}
    </StyledEngineProvider>
  );
}
/* global.css: MUI 스타일을 가장 낮은 레이어로 격리 */
@layer mui, reset, tokens, base, components, utilities;

@layer components {
  /* 단순 클래스 하나로 MUI 버튼 스타일 오버라이드 — 명시도 경쟁 없음 */
  .btn-brand {
    background-color: var(--color-primary-500);
    border-radius: var(--radius-md);
    font-family: var(--font-body);
  }
}

shadcn/ui는 CSS 변수 기반으로 설계되어 @layer base에서 변수를 재정의하는 것만으로 테마 오버라이드가 가능합니다.

@layer base {
  :root {
    /* shadcn/ui가 사용하는 CSS 변수 재정의 */
    --background: 0 0% 100%;
    --foreground: 222.2 84% 4.9%;
    --primary: 221.2 83.2% 53.3%;
    --primary-foreground: 210 40% 98%;
    --radius: 0.375rem;
  }

  .dark {
    --background: 222.2 84% 4.9%;
    --foreground: 210 40% 98%;
  }
}

이 방식은 shadcn/ui 컴포넌트 소스를 건드리지 않고도 브랜드 테마를 적용할 수 있어, 라이브러리 업데이트 시 오버라이드가 사라지는 위험이 없습니다.


6. 기존 !important 코드를 @layer 구조로 마이그레이션하는 6단계

실무 프로젝트를 마이그레이션하면서 정리한 단계별 체크리스트입니다. 대규모 레거시 코드베이스는 한 번에 전환하지 않고 점진적으로 적용해야 사이드 이펙트를 관리할 수 있습니다.

# 1단계: 현황 파악 — 프로젝트 내 !important 개수 파악
grep -r --include="*.css" --include="*.scss" --include="*.tsx"   "!important" ./src | wc -l

# 2단계: 서드파티 스타일 출처 파악
# Chrome DevTools → Sources → 적용된 스타일의 파일 위치 확인
# "user agent stylesheet", "injected style" 등 구분

3단계: 레이어 선언 파일 생성

엔트리 CSS 파일 최상단에 레이어 선언을 추가합니다. 이 시점에서 기존 스타일은 레이어 바깥에 있으므로 최고 우선순위를 유지합니다. 아무것도 깨지지 않습니다.

/* 기존 스타일 변경 없이 레이어 선언만 추가 */
@layer reset, tokens, base, components, utilities;

/* 기존 스타일들은 레이어 바깥에 있으므로 여전히 최우선 */

4단계: 리셋과 베이스 스타일을 레이어로 이동

가장 낮은 우선순위가 되어도 되는 스타일부터 레이어로 옮깁니다. 브라우저 리셋, body 기본 스타일, 타이포그래피 기반 스타일이 대상입니다.

5단계: 컴포넌트 스타일 이동 및 !important 제거

컴포넌트 스타일을 @layer components로 이동하면서 !important를 하나씩 제거합니다. 제거 후 시각적으로 깨지는 부분을 Storybook이나 브라우저에서 확인합니다. 깨진다면 레이어 순서 조정이나 셀렉터 수정으로 해결합니다.

6단계: 유틸리티 클래스 이동 및 Tailwind 통합

Tailwind 유틸리티와 커스텀 유틸리티를 @layer utilities로 통합합니다. 이 단계가 완료되면 !important는 인라인 style 속성 대응처럼 정말 불가피한 경우에만 남아 있어야 합니다.

팀 내부에서 마이그레이션하면서 가장 고생한 구간은 4단계와 5단계 사이였습니다. 수년간 누적된 컴포넌트 스타일이 서로 !important로 얽혀 있어, 하나를 제거하면 다른 것이 의도치 않게 드러났습니다. 이를 해결하기 위해 @layer components 안에서도 서브 레이어를 나누는 방식을 사용했습니다.

/* components 레이어 내 서브 레이어로 라이브러리와 커스텀 분리 */
@layer components {
  @layer lib, custom;

  @layer lib {
    /* 서드파티 컴포넌트 스타일 */
    .MuiButton-root { /* ... */ }
  }

  @layer custom {
    /* 우리 컴포넌트 스타일 — lib보다 항상 높음 */
    .btn-primary { /* ... */ }
  }
}

7. 브라우저 지원 현황과 안전한 도입 전략

@layer는 2026년 기준 Web Platform Baseline에서 "Widely available" 상태입니다. Chrome 99+, Firefox 97+, Safari 15.4+에서 지원하며, 글로벌 사용률 기준 94% 이상의 브라우저가 지원합니다. MDN 문서(developer.mozilla.org/en-US/docs/Web/CSS/@layer)에서 최신 지원 현황을 확인할 수 있습니다.

레거시 브라우저(IE 11, Safari 15.3 이하)를 지원해야 하는 프로젝트라면 점진적 향상(Progressive Enhancement) 방식을 취합니다. @layer를 모르는 브라우저는 @layer 블록 자체를 무시합니다. 레이어 바깥에 기본 스타일을 먼저 선언해두면, 폴백 브라우저에서도 일반 캐스케이드 규칙(선언 순서 기반)으로 그 스타일이 적용됩니다.

/* 폴백: @layer 미지원 브라우저에서도 기본 스타일이 동작 */
/* 레이어 바깥에 기본값을 먼저 선언해두는 것이 핵심 */
.btn-primary {
  background-color: #0070f3;
  color: white;
  padding: 0.5rem 1.25rem;
}

/* @layer 지원 브라우저에서는 레이어 체계로 강화됨 */
@layer components {
  .btn-primary {
    background-color: var(--color-primary-500);
    border-radius: var(--radius-md);
  }
}

CSS Container Query와 함께 컴포넌트 단위 스타일을 관리하는 패턴은 CSS Container Query 실전 가이드에서 더 자세히 다루고 있습니다.


8. 결론: 팀이 합의할 수 있는 CSS 아키텍처 실무 체크리스트

CSS @layer는 마법이 아닙니다. 레이어 순서를 잘못 선언하거나, 레이어 바깥에 스타일을 무분별하게 두거나, 팀 컨벤션 없이 각자 레이어에 스타일을 밀어 넣으면 결국 레이어 기반 혼란으로 대체될 뿐입니다.

@layer가 진짜 효과를 발휘하는 것은 팀 전체가 레이어 아키텍처를 이해하고 동의했을 때입니다. 디자인 시스템 문서에 다음 내용을 반드시 명시하기를 권장합니다.

실무 도입 전 확인해야 할 체크리스트 5가지입니다.

  • 레이어 순서가 엔트리 CSS에 단일 소스로 선언되어 있는가. @layer reset, tokens, base, components, utilities; 선언이 전역 CSS 파일 최상단에 정확히 한 곳에만 있어야 합니다. 여러 파일에 흩어지면 임포트 순서에 다시 의존하게 됩니다.
  • 서드파티 스타일이 가장 낮은 레이어에 격리되어 있는가. MUI, Radix, Ant Design 등 외부 라이브러리 스타일은 우리가 제어하기 어렵습니다. 이들을 별도 레이어(@layer mui, @layer antd)로 격리하면 라이브러리 업데이트 시 스타일 충돌이 격리됩니다.
  • !important가 레이어 바깥에서만, 인라인 스타일 대응 목적으로만 쓰이는가. 레이어 내부에서 !important를 사용하면 같은 레이어 내 다른 스타일은 이길 수 없지만, 더 높은 레이어의 스타일에는 여전히 집니다. !important는 레이어 체계 밖의 인라인 스타일에 대응하는 최후의 수단으로만 써야 합니다.
  • Tailwind 유틸리티가 utilities 레이어에서 컴포넌트 스타일을 정상적으로 오버라이드하는가. text-center, mt-4 같은 Tailwind 클래스를 컴포넌트에 적용했을 때 컴포넌트 스타일이 무시되지 않는지 Storybook에서 확인합니다.
  • 레이어 구조와 컨벤션이 팀 스타일 가이드에 문서화되어 있는가. 신규 입사자가 "이 스타일은 어느 레이어에 넣어야 합니까?"라고 물었을 때 문서를 보고 스스로 결정할 수 있어야 합니다. 구두로만 전달된 컨벤션은 시간이 지나면 반드시 무너집니다.

CSS 명시도 전쟁은 기술 문제가 아니라 아키텍처 문제입니다. @layer는 그 아키텍처를 CSS 언어 수준에서 표현하는 수단입니다. 레이어 구조를 한 번 올바르게 잡아두면, 새 라이브러리를 도입하거나 컴포넌트를 추가할 때 "이 스타일이 어디서 이기는가"를 DevTools 앞에서 추측하는 시간이 사라집니다. 그 시간만큼 실제 UI를 만드는 데 쓸 수 있습니다.