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는 이 캐스케이드에 명시적인 레이어 구조를 추가합니다. 레이어 선언 순서 자체가 우선순위가 되기 때문에, 어떤 파일이 먼저 로드됐든 어떤 선택자가 더 구체적이든 상관없이 결과를 예측할 수 있습니다.
/* 셸(host) 앱 — 전체 레이어 순서를 여기서 한 번만 선언 */
@layer reset, tokens, vendor, shell, mfe-a, mfe-b, overrides;이 한 줄이 전부입니다. 뒤에 선언된 레이어일수록 높은 우선순위를 가지므로, mfe-b의 스타일은 동일 선택자 기준으로 mfe-a보다 항상 이깁니다. 팀 간 "내 CSS가 당신 CSS보다 우선해야 한다"는 협약을 코드로 명문화할 수 있는 거죠.
Cascade Layers — CSS 캐스케이드에 이름 붙은 레이어를 추가하는 기능. 레이어 내부에서는 여전히 기존 명시도 규칙이 적용되지만, 레이어 간 충돌에서는 명시도보다 레이어 선언 순서가 우선합니다.
레이어 선언과 스타일 정의는 반드시 분리해야 합니다
처음 @layer를 쓰다 보면 선언과 정의를 한 블록에 섞어 쓰게 되는데, 이건 유지보수할 때 꽤 헷갈립니다.
/* ✅ 올바른 패턴: 순서 선언 따로, 스타일 정의 따로 */
@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) 스타일의 특성
저도 처음에 이걸 몰라서 꽤 당황스러운 상황을 겪었는데, 어떤 레이어에도 속하지 않는 스타일은 모든 레이어보다 높은 우선순위를 갖습니다.
@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) 앱에서 한 번만, 가장 먼저 이루어져야 합니다. 이 선언이 전체 스타일 우선순위의 헌법 역할을 합니다.
/* 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 라이브러리를 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 플러그인으로 이 작업을 자동화할 수 있습니다.
// 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')]
};/* 빌드 전 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는 전역 우선순위를 제어합니다. 두 방식을 겹치면 서로의 약점을 보완합니다.
/* 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는 이런 형태입니다.
/* 빌드 결과물 */
@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 라이브러리의 삽입 지점을 레이어 선언 이후로 고정하는 것입니다.
// Emotion: insertionPoint를 레이어 선언 이후로 지정
import createCache from '@emotion/cache';
const cache = createCache({
key: 'mfe-a',
insertionPoint: document.querySelector('#emotion-insertion-point'), // shell.css 이후 위치
});// 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에서는 이 점을 별도로 고려해야 합니다.
실무에서 가장 흔한 실수
-
MFE 하나가 레이어 없이 CSS를 주입하는 경우 — 레이어 격리를 아무리 열심히 해도 레이어 밖의 CSS 하나가 전체를 깨트릴 수 있습니다. PostCSS 자동 래핑을 빌드 파이프라인에 추가하면 이 문제를 구조적으로 차단할 수 있습니다. 개발자가 실수로 레이어를 빠뜨려도 빌드 단계에서 자동으로 감싸줍니다.
-
셸의 레이어 선언보다 MFE 스타일이 먼저 로드되는 경우 — 셸의
@layer선언 전에 MFE 스타일이 이미 브라우저에 파싱됐다면 레이어 순서가 의도와 다르게 적용될 수 있습니다. 셸 CSS를 HTML<head>최상단에 위치시키는 것이 안전합니다. -
vendor레이어 안!important동작을 오해하는 경우 —vendor레이어 안의!important는 레이어 외부의 일반 스타일보다 낮은 우선순위를 갖습니다. 단, 레이어 외부에도!important가 있다면 그쪽이 최우선입니다.@layer가!important자체를 없애주는 게 아니라,!important의 역전 규칙도 레이어 구조를 따르게 됩니다. 이 미묘한 차이를 모르면 예상치 못한 상황에서 헷갈릴 수 있습니다.
마치며
CSS @layer는 MFE 환경의 스타일 충돌 문제를 !important 없이, 선언 순서만으로 풀 수 있게 해주는 현대적인 도구입니다.
지금 바로 시작해볼 수 있는 3단계:
-
외부 CSS 라이브러리 격리부터 시작해보시면 좋습니다. 현재 프로젝트에서 Bootstrap, Tailwind 등 외부 라이브러리 import 구문을
@import url("library.css") layer(vendor);형태로 바꿔보시면 좋습니다. 작은 변경이지만 서드파티 스타일 오버라이드가 얼마나 편해지는지 바로 느낄 수 있습니다. -
셸 앱에 레이어 선언 파일을 추가해보시면 됩니다.
shell.css파일 최상단에@layer reset, tokens, vendor, shell;한 줄을 추가하고, 기존 스타일을 해당 레이어로 이동시켜 보시면 됩니다. 브라우저 DevTools의 Styles 패널에서 레이어별로 스타일이 구분되는 모습을 확인해볼 수 있습니다. -
새 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