CSS 쌓임 맥락의 모든 것: Z-Index가 작동하지 않는 이유
웹 개발을 하다 보면 z-index: 9999
를 설정했는데도 요소가 원하는 위치에 쌓이지 않아 당황한 경험이 있을 것입니다. 이는 대부분 쌓임 맥락(Stacking Context)을 제대로 이해하지 못해서 발생하는 문제입니다. 오늘은 CSS에서 가장 헷갈리는 개념 중 하나인 쌓임 맥락을 완전히 정복해보겠습니다.
쌓임 맥락이란? 3차원 공간의 이해
기본 개념
쌓임 맥락(Stacking Context)은 HTML 요소들이 가상의 Z축 상에서 어떻게 배치되는지를 결정하는 3차원 렌더링 개념입니다. 마치 투명한 유리판 여러 개를 겹쳐 놓은 것처럼, 각 요소가 어느 층에 위치할지를 정하는 규칙이라고 생각하면 됩니다.
현실 세계 비유
쌓임 맥락을 이해하기 위해 책상 위의 서류 더미를 생각해보세요:
📄 최상위 서류 (Z-index가 가장 높음)
📄 중간 서류
📄 바닥 서류 (Z-index가 가장 낮음)
🖥️ 책상 (HTML 문서)
- 일반적인 쌓임: 나중에 올려놓은 서류가 위에 위치
- z-index: 서류에 붙인 번호표 (높은 번호가 위로)
- 쌓임 맥락: 각 서류 더미가 만드는 독립적인 공간
기본 쌓임 규칙: Z-Index 없는 세상
먼저 z-index
가 없을 때의 기본 쌓임 규칙을 알아보겠습니다.
1. 문서 흐름 순서
<div class="box1">첫 번째 박스</div>
<div class="box2">두 번째 박스</div>
<div class="box3">세 번째 박스</div>
.box1, .box2, .box3 {
width: 100px;
height: 100px;
position: absolute;
}
.box1 { background: red; top: 0; left: 0; }
.box2 { background: green; top: 20px; left: 20px; }
.box3 { background: blue; top: 40px; left: 40px; }
결과: box3(파란색) → box2(초록색) → box1(빨간색) 순으로 쌓임 (나중에 온 것이 위에)
2. Position 속성의 영향
요소 타입 | 쌓임 순서 | 설명 |
---|---|---|
position: static |
가장 아래 | 기본값, z-index 적용 불가 |
position: relative/absolute/fixed |
위쪽 | z-index 적용 가능 |
float 요소 |
중간 | static보다 위, positioned보다 아래 |
/* 쌓임 순서 예시 */
.static-box { position: static; } /* 맨 아래 */
.float-box { float: left; } /* 중간 */
.positioned-box { position: relative; } /* 맨 위 */
쌓임 맥락이 생성되는 조건
쌓임 맥락은 특정 CSS 속성이 적용될 때 자동으로 생성됩니다. 이는 마치 새로운 방을 만드는 것과 같습니다.
주요 생성 조건
/* 1. Position + Z-index 조합 */
.stacking-context-1 {
position: relative; /* 또는 absolute */
z-index: 1; /* auto가 아닌 값 */
}
/* 2. Fixed/Sticky Position */
.stacking-context-2 {
position: fixed; /* 또는 sticky */
/* z-index 없어도 쌓임 맥락 생성 */
}
/* 3. Flexbox/Grid 컨테이너 */
.stacking-context-3 {
display: flex; /* 또는 grid */
z-index: 1; /* z-index가 설정된 경우만 */
}
/* 4. 투명도 */
.stacking-context-4 {
opacity: 0.99; /* 1 미만의 값 */
}
/* 5. Transform 속성 */
.stacking-context-5 {
transform: translateZ(0); /* 모든 transform 값 */
}
/* 6. 필터 효과 */
.stacking-context-6 {
filter: blur(5px); /* 모든 filter 값 */
}
/* 7. CSS 마스크 */
.stacking-context-7 {
mask: url(mask.svg); /* 모든 mask 값 */
}
/* 8. 클립 패스 */
.stacking-context-8 {
clip-path: circle(50%); /* 모든 clip-path 값 */
}
/* 9. 믹스 블렌드 모드 */
.stacking-context-9 {
mix-blend-mode: multiply; /* normal이 아닌 값 */
}
/* 10. CSS 컨테인먼트 */
.stacking-context-10 {
contain: layout; /* layout 또는 paint 포함 */
}
생성 조건 체크리스트
// 쌓임 맥락 생성 여부를 확인하는 함수
function createsStackingContext(element) {
const style = getComputedStyle(element);
return (
// Position + z-index
(style.position !== 'static' && style.zIndex !== 'auto') ||
// Fixed/Sticky
['fixed', 'sticky'].includes(style.position) ||
// Opacity
parseFloat(style.opacity) < 1 ||
// Transform
style.transform !== 'none' ||
// Filter
style.filter !== 'none' ||
// 기타 여러 조건들...
);
}
실제 문제 상황과 해결 방법
문제 상황 1: 높은 Z-Index가 작동하지 않는 경우
<div class="parent-a">
<div class="child-high-z">높은 Z-Index (999)</div>
</div>
<div class="parent-b">
<div class="child-low-z">낮은 Z-Index (1)</div>
</div>
/* ❌ 문제가 되는 CSS */
.parent-a {
position: relative;
z-index: 1; /* 쌓임 맥락 생성 */
}
.parent-b {
position: relative;
z-index: 2; /* 더 높은 쌓임 맥락 */
}
.child-high-z {
position: absolute;
z-index: 999; /* 부모 맥락 내에서만 유효 */
}
.child-low-z {
position: absolute;
z-index: 1; /* 더 높은 부모 맥락 안에 있음 */
}
결과: child-low-z
가 child-high-z
보다 위에 표시됨!
왜 이런 일이 발생할까요?
최상위 쌓임 맥락
├── parent-a (z-index: 1)
│ └── child-high-z (z-index: 999) ← 부모 맥락 내에서만 유효
└── parent-b (z-index: 2) ← 더 높은 맥락
└── child-low-z (z-index: 1)
해결책들
해결책 1: 부모 요소의 Z-Index 조정
/* ✅ 해결책 1 */
.parent-a {
position: relative;
z-index: 3; /* parent-b보다 높게 설정 */
}
.parent-b {
position: relative;
z-index: 2;
}
해결책 2: 쌓임 맥락 제거
/* ✅ 해결책 2 */
.parent-a {
position: relative;
/* z-index 제거 → 쌓임 맥락 생성하지 않음 */
}
.parent-b {
position: relative;
/* z-index 제거 */
}
.child-high-z {
position: absolute;
z-index: 999; /* 이제 최상위 맥락에서 비교됨 */
}
해결책 3: 포탈(Portal) 패턴 사용
// React Portal 예시
function HighZIndexComponent() {
return createPortal(
<div className="modal">높은 우선순위 컴포넌트</div>,
document.body // 다른 쌓임 맥락으로 이동
);
}
모달과 드롭다운에서의 실무 적용
문제: 모달이 다른 요소에 가려지는 경우
<div class="header">
<div class="dropdown">드롭다운</div>
</div>
<div class="main-content">
<div class="modal">모달</div>
</div>
/* ❌ 문제가 되는 CSS */
.header {
position: relative;
z-index: 1000; /* 높은 z-index */
}
.modal {
position: fixed;
z-index: 999; /* 더 낮은 z-index지만 다른 맥락 */
}
해결책: 전역 Z-Index 관리 시스템
/* ✅ Z-Index 계층 시스템 */
:root {
--z-dropdown: 1000;
--z-sticky: 1020;
--z-fixed: 1030;
--z-modal-backdrop: 1040;
--z-modal: 1050;
--z-popover: 1060;
--z-tooltip: 1070;
--z-toast: 1080;
}
.dropdown { z-index: var(--z-dropdown); }
.modal { z-index: var(--z-modal); }
.tooltip { z-index: var(--z-tooltip); }
SCSS/SASS를 활용한 Z-Index 관리
// _z-index.scss
$z-indexes: (
'dropdown': 1000,
'sticky': 1020,
'fixed': 1030,
'modal-backdrop': 1040,
'modal': 1050,
'popover': 1060,
'tooltip': 1070,
'toast': 1080,
);
@function z($name) {
@return map-get($z-indexes, $name);
}
// 사용법
.modal {
z-index: z('modal'); // 1050
}
JavaScript로 동적 Z-Index 관리
class ZIndexManager {
constructor() {
this.baseIndex = 1000;
this.currentMaxIndex = this.baseIndex;
}
getNextIndex(layer = 'default') {
const layerOffsets = {
'dropdown': 0,
'modal': 50,
'tooltip': 70,
'toast': 80
};
const offset = layerOffsets[layer] || 0;
return this.baseIndex + offset + (++this.currentMaxIndex);
}
// 사용법
applyModalIndex(element) {
element.style.zIndex = this.getNextIndex('modal');
}
}
const zManager = new ZIndexManager();
Transform과 쌓임 맥락의 함정
의도치 않은 쌓임 맥락 생성
/* ❌ 의도하지 않은 쌓임 맥락 생성 */
.card {
transform: translateZ(0); /* GPU 가속을 위해 추가 */
/* → 의도치 않게 쌓임 맥락 생성! */
}
.card .tooltip {
position: absolute;
z-index: 9999; /* 카드 맥락 내에서만 유효 */
}
해결책: will-change 속성 활용
/* ✅ 더 나은 GPU 가속 방법 */
.card {
will-change: transform; /* 필요시에만 쌓임 맥락 생성 */
}
.card:hover {
transform: scale(1.05); /* 호버시에만 transform 적용 */
}
/* 애니메이션 완료 후 will-change 제거 */
.card.animation-complete {
will-change: auto;
}
React/Vue에서의 쌓임 맥락 관리
React Portal을 활용한 해결책
// 쌓임 맥락 문제를 해결하는 Portal 컴포넌트
function Portal({ children, container = document.body }) {
return createPortal(children, container);
}
// 모달 컴포넌트
function Modal({ isOpen, children }) {
if (!isOpen) return null;
return (
<Portal>
<div className="modal-backdrop" style={{ zIndex: 1040 }}>
<div className="modal-content" style={{ zIndex: 1050 }}>
{children}
</div>
</div>
</Portal>
);
}
// 사용법
function App() {
const [showModal, setShowModal] = useState(false);
return (
<div style={{ position: 'relative', zIndex: 999 }}>
{/* 높은 z-index를 가진 컨테이너 */}
<button onClick={() => setShowModal(true)}>모달 열기</button>
<Modal isOpen={showModal}>
<p>이 모달은 항상 최상위에 표시됩니다!</p>
</Modal>
</div>
);
}
Vue Teleport 활용
<template>
<div class="app">
<div class="high-z-container" style="z-index: 999;">
<button @click="showModal = true">모달 열기</button>
</div>
<!-- Teleport로 body에 직접 렌더링 -->
<Teleport to="body">
<div v-if="showModal" class="modal" style="z-index: 1050;">
<div class="modal-content">
<p>항상 최상위에 표시되는 모달</p>
<button @click="showModal = false">닫기</button>
</div>
</div>
</Teleport>
</div>
</template>
디버깅 도구와 팁
1. 브라우저 개발자 도구 활용
// 쌓임 맥락 생성 요소 찾기
function findStackingContexts() {
const elements = document.querySelectorAll('*');
const stackingContexts = [];
elements.forEach(el => {
const style = getComputedStyle(el);
if (
(style.position !== 'static' && style.zIndex !== 'auto') ||
['fixed', 'sticky'].includes(style.position) ||
parseFloat(style.opacity) < 1 ||
style.transform !== 'none' ||
style.filter !== 'none'
) {
stackingContexts.push({
element: el,
zIndex: style.zIndex,
position: style.position,
opacity: style.opacity,
transform: style.transform
});
}
});
return stackingContexts;
}
// 콘솔에서 실행
console.table(findStackingContexts());
2. Z-Index 시각화 도구
/* 개발용: 모든 positioned 요소에 z-index 표시 */
[style*="position: relative"]::before,
[style*="position: absolute"]::before,
[style*="position: fixed"]::before {
content: "z:" attr(style);
position: absolute;
top: 0;
left: 0;
background: rgba(255, 0, 0, 0.8);
color: white;
padding: 2px 4px;
font-size: 10px;
pointer-events: none;
}
3. 쌓임 순서 디버깅 함수
// 특정 요소의 쌓임 순서 계산
function calculateStackingOrder(element) {
const path = [];
let current = element;
while (current && current !== document.documentElement) {
const style = getComputedStyle(current);
const stackingContext = (
(style.position !== 'static' && style.zIndex !== 'auto') ||
['fixed', 'sticky'].includes(style.position) ||
parseFloat(style.opacity) < 1 ||
style.transform !== 'none'
);
path.unshift({
element: current.tagName + (current.className ? '.' + current.className : ''),
zIndex: style.zIndex,
position: style.position,
createsStackingContext: stackingContext
});
current = current.parentElement;
}
return path;
}
// 사용법
const element = document.querySelector('.problematic-element');
console.table(calculateStackingOrder(element));
성능 최적화와 쌓임 맥락
GPU 레이어와 쌓임 맥락
/* ✅ 효율적인 GPU 레이어 생성 */
.optimized-animation {
/* 애니메이션 전에 미리 레이어 생성 */
will-change: transform;
/* 또는 transform: translateZ(0); */
}
.optimized-animation:hover {
transform: scale(1.1);
transition: transform 0.3s ease;
}
/* ❌ 과도한 레이어 생성 */
.every-element {
transform: translateZ(0); /* 모든 요소에 적용하면 메모리 낭비 */
}
메모리 효율적인 Z-Index 관리
// 메모리 효율적인 Z-Index 풀 관리
class ZIndexPool {
constructor() {
this.pools = {
dropdown: { base: 1000, current: 1000, max: 1010 },
modal: { base: 1050, current: 1050, max: 1060 },
tooltip: { base: 1070, current: 1070, max: 1080 }
};
}
allocate(type) {
const pool = this.pools[type];
if (pool.current >= pool.max) {
console.warn(`Z-Index pool for ${type} is exhausted`);
return pool.max;
}
return ++pool.current;
}
deallocate(type, index) {
const pool = this.pools[type];
if (index === pool.current) {
pool.current--;
}
}
}
베스트 프랙티스 가이드
DO (권장사항)
/* ✅ 명확한 계층 구조 정의 */
:root {
--z-base: 0;
--z-dropdown: 1000;
--z-modal: 1050;
--z-tooltip: 1070;
}
/* ✅ 의미 있는 네이밍 */
.modal-backdrop { z-index: var(--z-modal); }
.tooltip { z-index: var(--z-tooltip); }
/* ✅ 필요한 경우에만 쌓임 맥락 생성 */
.card:hover {
transform: translateY(-4px); /* 호버시에만 */
}
/* ✅ 문서화된 Z-Index 사용 */
.header {
z-index: var(--z-dropdown); /* 드롭다운을 위한 레이어 */
}
DON'T (피해야 할 것들)
/* ❌ 임의의 큰 숫자 사용 */
.element {
z-index: 999999; /* 의미 없는 큰 숫자 */
}
/* ❌ 불필요한 쌓임 맥락 생성 */
.every-div {
transform: translateZ(0); /* 모든 요소에 적용 */
}
/* ❌ 문서화되지 않은 Z-Index */
.mystery-element {
z-index: 47; /* 왜 47인지 알 수 없음 */
}
/* ❌ 중첩된 높은 Z-Index */
.parent { z-index: 1000; }
.child { z-index: 9999; } /* 부모 맥락 내에서만 유효 */
실무 체크리스트
새로운 컴포넌트 개발 시
- Z-Index가 필요한가? 다른 방법은 없는가?
- 전역 Z-Index 시스템을 따르고 있는가?
- 의도치 않은 쌓임 맥락을 생성하지 않는가?
- 모바일에서도 올바르게 동작하는가?
- 접근성 고려사항은 충족되는가?
버그 수정 시
- 쌓임 맥락 구조를 파악했는가?
- 부모 요소들의 Z-Index를 확인했는가?
- CSS 속성이 의도치 않은 쌓임 맥락을 만들지 않는가?
- 다른 컴포넌트에 영향을 주지 않는가?
미래의 CSS: layer()
함수
CSS의 미래에는 쌓임 맥락을 더 직관적으로 관리할 수 있는 방법들이 제안되고 있습니다.
/* 미래의 CSS (제안 단계) */
.modal {
z-index: layer(modal); /* 명시적 레이어 지정 */
}
.tooltip {
z-index: layer(tooltip); /* 더 높은 레이어 */
}
결론
쌓임 맥락은 CSS에서 가장 복잡하면서도 중요한 개념 중 하나입니다. 올바른 이해 없이는 예측하기 어려운 렌더링 버그를 만들어낼 수 있습니다.
핵심 포인트 요약:
- 쌓임 맥락은 독립적인 3차원 공간을 만든다
- Z-Index는 같은 쌓임 맥락 내에서만 비교된다
- 다양한 CSS 속성이 의도치 않게 쌓임 맥락을 생성할 수 있다
- 전역 Z-Index 관리 시스템을 구축하는 것이 중요하다
- Portal/Teleport 패턴으로 대부분의 문제를 해결할 수 있다
마치 건물을 지을 때 각 층의 높이와 구조를 미리 계획하는 것처럼, 웹 개발에서도 쌓임 순서를 체계적으로 관리하는 것이 중요합니다. 오늘 배운 내용을 바탕으로 더 이상 z-index: 999999
로 문제를 해결하려 하지 마시고, 근본적인 원인을 파악하여 깔끔하게 해결하시기 바랍니다.