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

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

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

MFE를 운영 중이거나 CSS 충돌로 고생해본 적 있다면 이 글이 딱 맞습니다. MFE는 서로 독립적으로 배포되는 복수의 프론트엔드 앱이 하나의 DOM에 마운트되는 아키텍처로, 각 팀이 CSS를 따로 관리하다 보면 전역 스타일이 뒤섞이는 문제가 발생합니다. 2022년 초 주요 브라우저들이 잇따라 지원을 시작한 CSS @layer는 이 고질적인 문제를 꽤 우아하게 풀어주는 도구입니다. 2026년 기준 브라우저 지원율이 96%를 넘어선 지금, 이 기능은 더 이상 "미래의 기술"이 아니라 오늘 프로덕션에 쓸 수 있습니다.

@layer를 이해하면, 선택자 명시도(specificity)나 파일 로드 순서에 의존하지 않고 스타일 우선순위를 선언만으로 제어하는 방법을 갖게 됩니다.


핵심 개념

CSS @layer, 캐스케이드에 계층을 만들다

CSS 캐스케이드는 여러 스타일 규칙이 같은 요소에 적용될 때 어떤 것이 이길지 결정하는 메커니즘입니다. 전통적으로 이 결정은 세 가지 기준을 따랐습니다: 명시도(specificity), 소스 순서(source order), 출처(origin). 그런데 MFE처럼 여러 팀이 동시에 CSS를 주입하는 환경에서는 이 규칙이 예측 불가능한 결과를 낳습니다.

@layer는 이 캐스케이드에 명시적인 레이어 구조를 추가합니다. 레이어 선언 순서 자체가 우선순위가 되기 때문에, 어떤 파일이 먼저 로드됐든 어떤 선택자가 더 구체적이든 상관없이 결과를 예측할 수 있습니다.

css
/* 셸(host) 앱 — 전체 레이어 순서를 여기서 한 번만 선언 */
@layer reset, tokens, vendor, shell, mfe-a, mfe-b, overrides;

이 한 줄이 전부입니다. 뒤에 선언된 레이어일수록 높은 우선순위를 가지므로, mfe-b의 스타일은 동일 선택자 기준으로 mfe-a보다 항상 이깁니다. 팀 간 "내 CSS가 당신 CSS보다 우선해야 한다"는 협약을 코드로 명문화할 수 있는 거죠.

Cascade Layers — CSS 캐스케이드에 이름 붙은 레이어를 추가하는 기능. 레이어 내부에서는 여전히 기존 명시도 규칙이 적용되지만, 레이어 간 충돌에서는 명시도보다 레이어 선언 순서가 우선합니다.

레이어 선언과 스타일 정의는 반드시 분리해야 합니다

처음 @layer를 쓰다 보면 선언과 정의를 한 블록에 섞어 쓰게 되는데, 이건 유지보수할 때 꽤 헷갈립니다.

css
/* ✅ 올바른 패턴: 순서 선언 따로, 스타일 정의 따로 */
@layer reset, vendor, shell, mfe-a; /* 선언: 파일 맨 위에서 한 번만 */
 
@layer reset { /* 정의: 실제 스타일 */
  *, *::before, *::after { box-sizing: border-box; }
}
 
@layer shell { /* 정의: 실제 스타일 */
  .nav { background: var(--color-surface); }
}

@layer reset, vendor, shell;처럼 순서만 선언하는 블록과, @layer reset { ... }처럼 실제 스타일을 담는 블록은 역할이 다릅니다. 최초 선언은 셸 파일 한 곳에서만, 별도 블록으로 관리하면 레이어 구조 전체가 훨씬 명확해집니다.

레이어 외부(Unlayered) 스타일의 특성

저도 처음에 이걸 몰라서 꽤 당황스러운 상황을 겪었는데, 어떤 레이어에도 속하지 않는 스타일은 모든 레이어보다 높은 우선순위를 갖습니다.

css
@layer shell {
  .btn { background: blue; } /* 레이어 안 */
}
 
.btn { background: red; } /* 레이어 밖 — 항상 이긴다 */

결과는 red입니다. 이 특성은 기존 프로젝트에 @layer를 점진적으로 도입할 때 오히려 유용하게 활용할 수 있습니다. 기존 코드를 그대로 두고, 새로 추가하는 스타일만 레이어 안에 넣으면 기존 스타일의 우선순위가 자연스럽게 유지됩니다.

중요: MFE 팀 중 하나가 레이어를 사용하지 않으면 그 팀의 스타일이 레이어를 쓰는 모든 팀의 스타일을 덮어씁니다. PostCSS로 레이어 래핑을 빌드 파이프라인에서 강제하는 것을 권장하는 이유가 바로 여기 있습니다.

참고로, 레이어 안에 또 다른 레이어를 중첩(nested @layer)하는 것도 가능합니다. @layer mfe-a { @layer base, components; }처럼 팀 내부에서도 계층 구조를 만들 수 있어, 규모가 커진 팀에서 유용하게 활용할 수 있습니다.

MFE 스타일 충돌 패턴과 @layer 접근

MFE에서 CSS 충돌이 발생하는 가장 흔한 패턴을 정리하면 이렇습니다.

문제 상황 기존 접근 @layer 접근
팀A, 팀B가 같은 .card 클래스 사용 prefix 네이밍 규칙, CSS Modules 각 팀을 별도 레이어에 격리
Bootstrap 같은 서드파티 CSS가 자체 스타일을 덮어씀 !important 추가 서드파티를 vendor 레이어에 가둠
MFE 스타일 로드 순서가 환경마다 달라짐 로드 순서 엄격 관리 레이어 선언 순서가 절대 기준

실전 적용

예시 1: 셸 앱에서 전역 레이어 계약 정의

MFE 아키텍처에서 레이어 선언은 반드시 셸(host) 앱에서 한 번만, 가장 먼저 이루어져야 합니다. 이 선언이 전체 스타일 우선순위의 헌법 역할을 합니다.

css
/* host/shell.css */
@layer reset, tokens, vendor, shell, mfe-a, mfe-b, overrides;
 
@layer reset {
  *, *::before, *::after {
    box-sizing: border-box;
    margin: 0;
    padding: 0;
  }
}
 
@layer tokens {
  :root {
    --color-primary: #0070f3;
    --color-surface: #ffffff;
    --spacing-base: 8px;
    --font-size-base: 1rem;
  }
}
 
@layer shell {
  .layout { display: grid; grid-template-columns: 240px 1fr; }
  .nav { background: var(--color-surface); }
}
레이어 역할 담당
reset 브라우저 기본 스타일 초기화 셸 팀
tokens 디자인 토큰(색상, 간격 등) 디자인 시스템 팀
vendor 서드파티 라이브러리 자동 격리
shell 글로벌 레이아웃, 네비게이션 셸 팀
mfe-a, mfe-b 각 마이크로 앱 스타일 각 팀
overrides 긴급 오버라이드 관리 필요

예시 2: 서드파티 CSS를 vendor 레이어에 격리

이걸 처음 적용했을 때 가장 뿌듯했던 순간이 바로 서드파티 격리였습니다. Bootstrap, Ant Design처럼 글로벌 스타일을 대량으로 주입하는 라이브러리를 MFE 환경에서 쓸 때, 레이어 격리 없이는 다른 팀의 스타일이 예상치 못하게 덮어씌워질 수 있습니다.

css
/* 외부 CSS 라이브러리를 vendor 레이어 안에 격리 */
@import url("bootstrap.min.css") layer(vendor);
@import url("ant-design.min.css") layer(vendor);
 
/* 이제 vendor보다 높은 레이어에서 자유롭게 커스터마이징 */
@layer shell {
  .btn {
    background: var(--color-primary);
    border-radius: 6px;
  }
}

@import ... layer(vendor) 문법을 쓰면 외부 CSS 파일 전체를 지정한 레이어 안에 가두어버립니다. 솔직히 이 부분을 처음 알았을 때 꽤 감동받았습니다.

한 가지 덧붙이자면, vendor 레이어 안에서 Bootstrap이 !important를 사용하더라도, 레이어 외부의 일반 스타일(unlayered)보다는 낮은 우선순위를 갖습니다. vendor 레이어 안의 !important는 같은 레이어 내부에서만 명시도 전쟁을 일으킬 뿐, 상위 레이어나 레이어 외부 스타일을 침범하지 못합니다.

예시 3: Module Federation 환경에서 MFE별 레이어 자동 래핑

이걸 처음 적용했을 때 팀 내에서 가장 논쟁이 됐던 부분이 바로 "모든 개발자가 레이어를 직접 선언해야 하는가"였습니다. PostCSS(CSS를 변환하는 빌드 타임 도구)를 활용하면 개발자가 개별 파일을 수동으로 건드리지 않아도 빌드 시 자동으로 레이어를 적용할 수 있습니다.

Module Federation(Webpack/Rspack 기반 MFE 번들 공유 도구)으로 원격 앱을 로드할 때, 간단한 커스텀 PostCSS 플러그인으로 이 작업을 자동화할 수 있습니다.

javascript
// mfe-a/postcss.config.js
const wrapInLayer = (layerName) => ({
  postcssPlugin: 'postcss-layer-wrap',
  Once(root, { postcss }) {
    const layer = postcss.atRule({ name: 'layer', params: layerName });
    layer.append(root.nodes.map(n => n.clone()));
    root.removeAll();
    root.append(layer);
  }
});
 
module.exports = {
  plugins: [wrapInLayer('mfe-a')]
};
css
/* 빌드 전 mfe-a/styles.css */
.card { padding: 16px; }
.header { font-size: 1.5rem; }
 
/* 빌드 후 (자동 변환) */
@layer mfe-a {
  .card { padding: 16px; }
  .header { font-size: 1.5rem; }
}
단계 동작 담당자
빌드 시 PostCSS가 CSS 파일 전체를 @layer mfe-a { } 로 래핑 CI/빌드 파이프라인
런타임 시 셸의 레이어 선언 순서에 따라 우선순위 결정 브라우저
충돌 발생 시 셸의 레이어 선언만 수정 셸 팀

예시 4: CSS Modules와 @layer 혼합 사용 (이중 격리)

더 견고한 격리가 필요하다면 CSS Modules와 @layer를 함께 사용하는 방법도 있습니다. CSS Modules는 클래스명 해시로 선택자 충돌을 방지하고, @layer는 전역 우선순위를 제어합니다. 두 방식을 겹치면 서로의 약점을 보완합니다.

css
/* mfe-b/Button.module.css */
@layer mfe-b {
  .button {
    background: var(--color-primary);
    padding: calc(var(--spacing-base) * 1.5) calc(var(--spacing-base) * 3);
    border: none;
    cursor: pointer;
  }
 
  .button:hover {
    opacity: 0.85;
  }
}

빌드 결과물에서 .button은 해시 클래스로 변환되면서, 동시에 mfe-b 레이어 안에 들어갑니다. 실제 브라우저에서 파싱되는 CSS는 이런 형태입니다.

css
/* 빌드 결과물 */
@layer mfe-b {
  .button_x7k2p {
    background: var(--color-primary);
    padding: calc(var(--spacing-base) * 1.5) calc(var(--spacing-base) * 3);
    border: none;
    cursor: pointer;
  }
 
  .button_x7k2p:hover {
    opacity: 0.85;
  }
}

해시 덕분에 다른 팀이 .button이라는 클래스를 써도 선택자 충돌이 없고, @layer 덕분에 레이어 선언 순서대로 우선순위가 결정됩니다. 선택자 충돌과 캐스케이드 충돌을 모두 막는 형태입니다.

CSS-in-JS와의 통합

Emotion이나 styled-components를 사용하는 경우, 런타임에 <style> 태그를 동적으로 주입하는 방식이라 @layer와의 통합이 조금 더 신경 쓰입니다. 가장 간단한 방법은 CSS-in-JS 라이브러리의 삽입 지점을 레이어 선언 이후로 고정하는 것입니다.

jsx
// Emotion: insertionPoint를 레이어 선언 이후로 지정
import createCache from '@emotion/cache';
 
const cache = createCache({
  key: 'mfe-a',
  insertionPoint: document.querySelector('#emotion-insertion-point'), // shell.css 이후 위치
});
jsx
// MUI: injectFirst로 삽입 순서 제어
import { StyledEngineProvider } from '@mui/material';
 
<StyledEngineProvider injectFirst>
  <App />
</StyledEngineProvider>

런타임 주입이라는 특성상 레이어 계층 구조와 완벽하게 연동하기는 어렵습니다. CSS-in-JS가 주요 기술 스택이라면, 스타일이 unlayered 영역에 주입되도록 의도적으로 설계하거나, 해당 MFE의 스타일은 overrides 레이어처럼 가장 높은 우선순위에 두는 전략도 고려해볼 수 있습니다.


장단점 분석

솔직히 말하면, @layer가 만능 해결책은 아닙니다. 어떤 도구든 한계가 있고, 팀 상황에 따라 도입 비용도 달라집니다.

장점

항목 내용
예측 가능한 우선순위 제어 !important 없이 레이어 선언 순서만으로 스타일 우선순위를 명확히 정의 가능
로드 순서 독립성 파일이 어떤 순서로 로드되든 레이어 선언 순서가 절대 기준으로 작동
점진적 도입 가능 기존 코드 전체를 바꾸지 않고 @layer 래핑만으로 시작 가능
디버깅 용이성 브라우저 DevTools에서 레이어별 스타일 구조를 시각적으로 확인
서드파티 제어 외부 라이브러리 전체를 한 레이어에 격리해 오버라이드 충돌 방지
디자인 토큰 공유 tokens 레이어를 모든 MFE가 공유 참조하는 구조로 설계 가능

단점 및 주의사항

항목 내용 대응 방안
팀 간 레이어 계약 필요 전역 레이어 순서를 사전에 합의해야 하며 암묵적 결합이 됨 셸 repo의 shell.css를 단일 진실 공급원으로 공식화
Unlayered 스타일 주의 레이어를 쓰지 않는 MFE 하나가 전체 레이어 구조를 무력화할 수 있음 PostCSS 자동 래핑으로 레이어 사용을 파이프라인에서 강제
로드 타이밍 이슈 셸의 @layer 선언이 MFE 스타일보다 반드시 먼저 로드되어야 함 <link rel="preload">나 번들 설정으로 로드 순서 보장
Shadow DOM과 단절 Shadow DOM 경계 안의 @layer는 전역 레이어와 연결되지 않음 Shadow DOM을 쓰는 컴포넌트는 별도 격리 전략 병행
CSS-in-JS 통합 복잡 Emotion, styled-components는 런타임에 스타일을 주입해 별도 처리 필요 insertionPoint API나 injectFirst 옵션 활용

Shadow DOM — HTML 요소 내부에 완전히 독립된 DOM 트리를 만드는 기술. 스타일이 외부와 완전히 격리되어 @layer의 전역 레이어 구조가 Shadow DOM 경계를 넘지 못합니다. Web Components 기반 MFE에서는 이 점을 별도로 고려해야 합니다.

실무에서 가장 흔한 실수

  1. MFE 하나가 레이어 없이 CSS를 주입하는 경우 — 레이어 격리를 아무리 열심히 해도 레이어 밖의 CSS 하나가 전체를 깨트릴 수 있습니다. PostCSS 자동 래핑을 빌드 파이프라인에 추가하면 이 문제를 구조적으로 차단할 수 있습니다. 개발자가 실수로 레이어를 빠뜨려도 빌드 단계에서 자동으로 감싸줍니다.

  2. 셸의 레이어 선언보다 MFE 스타일이 먼저 로드되는 경우 — 셸의 @layer 선언 전에 MFE 스타일이 이미 브라우저에 파싱됐다면 레이어 순서가 의도와 다르게 적용될 수 있습니다. 셸 CSS를 HTML <head> 최상단에 위치시키는 것이 안전합니다.

  3. vendor 레이어 안 !important 동작을 오해하는 경우 — vendor 레이어 안의 !important는 레이어 외부의 일반 스타일보다 낮은 우선순위를 갖습니다. 단, 레이어 외부에도 !important가 있다면 그쪽이 최우선입니다. @layer가 !important 자체를 없애주는 게 아니라, !important의 역전 규칙도 레이어 구조를 따르게 됩니다. 이 미묘한 차이를 모르면 예상치 못한 상황에서 헷갈릴 수 있습니다.


마치며

CSS @layer는 MFE 환경의 스타일 충돌 문제를 !important 없이, 선언 순서만으로 풀 수 있게 해주는 현대적인 도구입니다.

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

  1. 외부 CSS 라이브러리 격리부터 시작해보시면 좋습니다. 현재 프로젝트에서 Bootstrap, Tailwind 등 외부 라이브러리 import 구문을 @import url("library.css") layer(vendor); 형태로 바꿔보시면 좋습니다. 작은 변경이지만 서드파티 스타일 오버라이드가 얼마나 편해지는지 바로 느낄 수 있습니다.

  2. 셸 앱에 레이어 선언 파일을 추가해보시면 됩니다. shell.css 파일 최상단에 @layer reset, tokens, vendor, shell; 한 줄을 추가하고, 기존 스타일을 해당 레이어로 이동시켜 보시면 됩니다. 브라우저 DevTools의 Styles 패널에서 레이어별로 스타일이 구분되는 모습을 확인해볼 수 있습니다.

  3. 새 MFE 앱이 합류할 때 PostCSS 자동 래핑을 설정하면, 이 시점부터 새 팀이 합류해도 레이어 계약이 자동으로 유지됩니다. 빌드 파이프라인에 간단한 PostCSS 플러그인 하나만 추가하면 충분합니다.


참고 자료

  • CSS @layer | MDN Web Docs
  • 종속 계층 학습 | MDN (한국어)
  • Cascade Layers Guide | CSS-Tricks
  • Integrating CSS Cascade Layers To An Existing Project | Smashing Magazine
  • How to scale CSS in micro frontends | LogRocket Blog
  • Style Isolation | Module Federation 공식 문서
  • CSS Layers | Material UI 공식 문서
  • CSS의 @layer로 스타일 우선순위 정하기 | Dale Seo
  • CSS의 캐스케이드 레이어에 관하여 | Witch-Work
  • CSS Cascade Layers Explainer | odd-bird
#CSS@layer#마이크로프론트엔드#CascadeLayers#CSS캐스케이드#PostCSS#CSSModules#ModuleFederation#CSS-in-JS#ShadowDOM#디자인토큰
공유하기

목차

핵심 개념CSS @layer, 캐스케이드에 계층을 만들다레이어 선언과 스타일 정의는 반드시 분리해야 합니다레이어 외부(Unlayered) 스타일의 특성MFE 스타일 충돌 패턴과 @layer 접근실전 적용예시 1: 셸 앱에서 전역 레이어 계약 정의예시 2: 서드파티 CSS를 vendor 레이어에 격리예시 3: Module Federation 환경에서 MFE별 레이어 자동 래핑예시 4: CSS Modules와 @layer 혼합 사용 (이중 격리)CSS-in-JS와의 통합장단점 분석장점단점 및 주의사항실무에서 가장 흔한 실수마치며참고 자료

추천 포스트

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

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

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

2026년 06월 23일읽는 데 19분
Tailwind v4 @container — 사이드바에서도 그리드에서도 깨지지 않는 반응형 컴포넌트
frontend

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

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

2026년 06월 23일읽는 데 18분
TanStack DB 실전 도입: 클라이언트 사이드 DB가 낙관적 업데이트를 어떻게 바꾸는가
frontend

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

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

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

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

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

2026년 06월 23일읽는 데 19분
View Transitions API — 2025 Baseline 달성 이후, 라이브러리 없이 구현하는 프로덕션 전환 애니메이션
frontend

View Transitions API — 2025 Baseline 달성 이후, 라이브러리 없이 구현하는 프로덕션 전환 애니메이션

솔직히 처음 이 API를 봤을 때 "이게 된다고?" 싶었습니다. Framer Motion이나 GSAP 없이, CSS 몇 줄과 JavaScript 메서드 하나만으로 앱 같은 화면 전환을 구현한다는 게 너무 좋아 보였거든요. 그런데 실제로 써보니 정말 됩니다. 브라우저가 화면 전환 전후 스...

2026년 06월 07일읽는 데 20분
HTMX 4.0 서버 렌더링 패턴: 클라이언트 상태 없이 인터랙티브 웹을 만드는 아키텍처 선택
frontend

HTMX 4.0 서버 렌더링 패턴: 클라이언트 상태 없이 인터랙티브 웹을 만드는 아키텍처 선택

React를 처음 배울 때, 저는 솔직히 이 질문을 머릿속에 묻어뒀습니다. "버튼 클릭 하나에 이렇게 많은 코드가 필요한 게 맞는 건가?" 상태 관리, 빌드 도구, hydration 오류... 분명 웹은 HTML과 폼으로 동작하던 시절이 있었는데, 어느 순간 그 단순함을 잃어버렸다는 느...

2026년 06월 07일읽는 데 22분