
프론트엔드 개발을 하다 보면 흔하게 부딪히는 이슈이다.
분명 z-index: 9999 를 줬는데도 모달이 헤더 뒤로 숨어버리거나, 드롭다운 목록이 다른 요소에 가려질 때도 있다. 이런 문제의 90%는 쌓임 맥락(Stacking Context) 때문이다.
HTML 요소의 기본 흐름 (Normal Flow)
HTML 요소들은 기본적으로 문서 흐름에 따라 위에서 아래, 왼쪽에서 오른쪽으로 배치된다.
<body>
<div class="box1">첫 번째 박스</div>
<div class="box2">두 번째 박스</div>
</body>
-Normal Flow-
그런데 이때 나중에 오는 요소가 앞선 요소를 덮게 만들 수 있다.
.box1, .box2 {
width: 100px;
height: 100px;
position: absolute;
top: 50px;
left: 50px;
}
.box1 { background: red; }
.box2 {
background: blue;
top: 70px;
left: 70px;
}
/* box2가 box1을 부분적으로 덮습니다 */
.box1 과 .box2 는 dom tree 구조상 형제 관계에 있는데, 첫 번째 박스를 앞쪽에 배치하려면 .box1 에 z-index:1 을 추가하면 된다.
추가적으로 z-index 속성은 position: static 이 아닌 요소에서만 동작함을 잊지 않아야 한다.
"쌓임 맥락(stacking context)은 가상의 Z축을 사용한 HTML 요소의 3차원 개념화입니다. Z축은 사용자 기준이며, 사용자는 뷰포트 혹은 웹페이지를 바라보고 있을 것으로 가정합니다. 각각의 HTML 요소는 자신의 속성에 따른 우선순위를 사용해 3차원 공간을 차지합니다."
- 영문버전의 설명이 더 자세하게 작성되어 있으니 참고바랍니다.
각 층이 하나의 쌓임 맥락이라고 생각해 보자.
3층에는 z-index: 1 이 선언되어 있고,
1층에는 z-index: 999 가 선언되어 있다.
<div class="apartment floor-3" style="position: relative; z-index: 3;">
<div class="label">3층 (z-index: 3)</div>
<div class="room room-301" style="z-index: 1;">301호<br>(z-index: 1)</div>
<div class="room room-302" style="z-index: 2;">302호<br>(z-index: 2)</div>
</div>
<div class="apartment floor-1" style="position: relative; z-index: 1;">
<div class="label">1층 (z-index: 1)</div>
<div class="room room-101" style="z-index: 999;">101호<br>(z-index: 999!)</div>
</div>
그런데 결과를 보면 3층 302호가 1층 101호보다 위에 그려진다. 101호에 z-index: 999 를 선언해도 상위에 그려지지 않는다.
왜 이런 일이 발생할까?
브라우저가 요소들을 화면에 그리는 순서는 다음과 같다:
3층 302호 (z-index: 2, 부모 z-index: 3) → 가장 앞
3층 301호 (z-index: 1, 부모 z-index: 3)
1층 101호 (z-index: 999, 부모 z-index: 1) → 가장 뒤
"각 스태킹 컨텍스트는 독립적으로 존재합니다. 요소의 콘텐츠가 쌓이면 전체 요소는 부모 스태킹 컨텍스트의 스태킹 순서에서 하나의 단위로 간주됩니다."
"스태킹 컨텍스트 내에서 자식 요소는 z-index모든 형제 요소의 값에 따라 쌓입니다. 이러한 중첩된 요소의 스택킹 컨텍스트는 해당 부모 요소에서만 의미를 갖습니다. 스택킹 컨텍스트는 부모 스택킹 컨텍스트에서 단일 단위로 원자적으로 처리됩니다. 스택킹 컨텍스트는 다른 스택킹 컨텍스트에 포함될 수 있으며, 함께 스택킹 컨텍스트의 계층 구조를 형성합니다."
"스태킹 컨텍스트의 계층 구조는 HTML 요소 계층 구조의 하위 집합입니다. 특정 요소만 스태킹 컨텍스트를 생성하기 때문입니다. 자체 스태킹 컨텍스트를 생성하지 않는 요소는 부모 스태킹 컨텍스트에 동화 됩니다."
MDN 공식문서
이것을 통해 알 수 있는 것은
첫 번째로, 부모의 쌓임 맥락이 최우선이다. 자식 요소에 z-index 가 아무리 높아도, 부모 요소의 쌓임맥락이 우선이다.
두 번째로, 자식요소는 부모 요소 안에서만 경쟁한다. 301호와 302호는 3층 안에서만 순서가 결정된다.
| 조건 | 예시 | 비고 |
|---|---|---|
| 루트 요소 | <html> | 최상위 맥락 |
| position + z-index | position: relative; z-index: 1; | auto가 아닌 값 |
| 플렉스/그리드 자식 | .flex-item { z-index: 1; } | 부모가 display: flex/grid |
| opacity < 1 | opacity: 0.99; | 투명도 적용 |
| transform | transform: translateZ(0); | 3D 변환 |
| filter | filter: blur(1px); | 필터 효과 |
| isolation | isolation: isolate; | 명시적 격리 |
| 기타 | clip-path, mask, mix-blend-mode 등 |
크롬 웹 스토어에서 디버깅 툴을 설치하면 html에서 z-index 를 선언한 dom 을 쉽게 파악할 수 있다.
1) CSS Stacking Context inspector
2) DevTools z-index
React에서는 Dom API 로 createPortal 을 제공한다.
컴포넌트를 부모 DOM 계층 구조 밖에서 렌더링할 수 있게 해주는 강력한 기능이다. createPortal 사용한 후 Dom 을 살펴보면, document.body 에 직접 렌더링되어 쌓임 맥락에서 자유롭다는 장점이 있다.
// React 컴포넌트 예시
const ModalPortal = ({ children, className = "" }) => {
const modalRoot = useMemo(() => {
let root = document.getElementById('modal-root');
if (!root) {
root = document.createElement('div');
root.id = 'modal-root';
root.className = 'modal-portal';
document.body.appendChild(root);
}
return root;
}, []);
return ReactDOM.createPortal(
<div className={`modal-wrapper ${className}`}>
{children}
</div>,
modalRoot
);
};
하지만 단점도 있다.
드롭다운 버튼과 메뉴 목록의 위치가 동기화되지 않는 이슈가 있다. 예를 들어 웹 페이지에서 드롭다운 버튼을 클릭하면 메뉴 목록이 렌더링 된다. 그리고 마우스 스크롤링을 해보자. 그러면 메뉴 목록이 원래 위치에 고정되는 문제가 발생한다. 만약, 드롭다운 컴포넌트를 createPortal 을 사용하여 만들었다면, 스크롤 이벤트 리스너를 사용하여 위치를 동적으로 업데이트 해 주어야 한다.
접근성과 관련된 문제이다.
function Modal({ isOpen, children }) {
if (!isOpen) return null;
return createPortal(
<div className="modal">
<h2>제목</h2>
<input type="text" placeholder="이름" />
<button>확인</button>
<button>취소</button>
</div>,
document.body
);
}
모달이 렌더링 되어도 포커스가 원래 위치에 남아있다. 모달이 열렸을 때 Tab 키를 눌러보면 문제가 무엇인지 바로 알 수 있다. 그래서 모달이 닫혔을 때도 마찬가지이다.
function ConfusingDropdown() {
return (
<div>
<label htmlFor="country">국가 선택</label>
<button id="country-trigger">선택하세요</button>
{/* Portal로 인해 label과 실제 옵션들이 DOM에서 분리됨 */}
{createPortal(
<div>
<div>대한민국</div>
<div>미국</div>
<div>일본</div>
</div>,
document.body
)}
</div>
);
}
스크린 리더를 사용하는 사용자는 시각장애인, 고령자, 환경적 제한이 있는 사용자이다. 스크린 리더는 DOM 순서를 따라 콘텐츠를 읽기 때문에, Portal로 분리된 요소는 원래 맥락을 잃어버리게 된다.
이러한 접근성을 고려해야 한다면, 포커스를 위한 기능을 추가로 구현해야 하고, 스크린 리더 탐색을 위해 논리적 관계를 ARIA로 명시적으로 연결하고, 의미론적 구조를 Portal에서도 유지할 수 있어야 한다.
z-index 와 쌓임 맥락을 이해한다면 더 예측 가능한 UI를 개발할 수 있다. React의 createPortal 을 사용하면서 여러가지 문제들을 만난 경험을 공유했는데, 이 글이 비슷한 문제에 닥친 이들에게 도움이 되었으면 좋겠다.
📚 참고 자료
MDN - CSS Stacking Context
CSS-Tricks - What No One Told You About Z-Index
React Portal 공식 문서