z-index: 9999인데 왜 안 올라올까
드롭다운이나 모달을 만들다 보면 한 번쯤 겪는 일이 있습니다. 분명 z-index를 9999까지 올렸는데도 요소가 다른 것 아래에 깔려 있는 상황입니다. 저도 처음엔 숫자를 더 키우면 되겠지 싶어 99999, 999999까지 올려봤지만 소용없었습니다.
원인은 z-index 값이 작아서가 아니었습니다. 그 요소가 비교 자체가 안 되는 다른 세계에 갇혀 있었기 때문입니다. 이 "세계"가 바로 쌓임 맥락(stacking context)입니다. 이 글에서는 눈으로 보이는 렌더링 결과부터 시작해, 왜 이런 일이 벌어지고 어떻게 다스리는지 정리합니다.
같은 맥락에서는 z-index가 잘 동작한다
먼저 정상적인 경우입니다. 회색 상자와 빨강 상자를 겹쳐두고, 회색에만 z-index: 10을 줬습니다.
<div class="stage">
<div class="wrap-gray">
<div class="box gray">회색 (z-index: 10)</div>
</div>
<div class="wrap-red">
<div class="box red">빨강 (z-index: auto)</div>
</div>
</div>.stage {
position: relative;
}
.box {
position: absolute;
}
.gray {
z-index: 10;
}
/* .red 에는 z-index를 주지 않았다 (auto) */
"회색 상자와 빨강 상자가 겹친 화면. 회색 상자에 z-index 10이 주어져 빨강 상자보다 위에 그려진다. 부모에는 특별한 속성이 없다."
기대한 그대로입니다. z-index가 큰 회색이 빨강 위에 놓입니다. 두 상자가 같은 쌓임 맥락에 있어서 z-index 숫자를 곧바로 비교할 수 있기 때문입니다. 여기까지는 아무 문제가 없습니다.
z-index가 큰데도 아래에 깔리는 순간
이제 딱 한 가지만 바꿉니다. 회색 상자를 감싼 부모(.wrap-gray)에 opacity: 0.6을 줍니다. 상자 자체가 아니라 부모에 준 것이고, z-index는 건드리지 않았습니다.
.wrap-gray {
opacity: 0.6; /* 이 한 줄이 전부다 */
}
"부모에 opacity 0.6이 적용된 화면. 회색 상자에 z-index 10이 있는데도 z-index가 없는 빨강 상자가 위에 그려지고, 회색 상자는 반투명하게 흐려져 있다."
결과가 뒤집혔습니다. z-index: 10을 가진 회색이 z-index도 없는 빨강 아래로 내려가 버렸습니다. 회색이 살짝 흐려진 것 말고는 바꾼 게 없는데 말입니다.
opacity: 0.6이 회색의 부모를 새로운 쌓임 맥락의 루트로 만들었기 때문입니다. 그 순간 회색의 z-index: 10은 부모가 만든 맥락 안에 갇힙니다. 바깥의 빨강과는 더 이상 숫자로 겨루지 못합니다. 앞에서 z-index를 아무리 키워도 소용없던 이유가 바로 이것입니다.
쌓임 맥락이란 무엇인가
쌓임 맥락은 z축(화면에서 나를 향해 튀어나오는 방향)으로 요소를 쌓을 때 쓰는 독립된 그룹입니다. 페이지 전체는 최상위 맥락(루트, <html>) 하나로 시작하고, 특정 조건이 갖춰지면 그 안에 자식 맥락이 새로 생깁니다.
핵심 규칙은 두 가지입니다.
z-index는 같은 쌓임 맥락에 속한 형제끼리만 비교된다.- 서로 다른 맥락에 속한 요소는, 각자의 맥락 루트가 상위 맥락에서 어떤 순서인지에 따라 통째로 결정된다. 자식의
z-index가 아무리 커도 맥락 밖으로 나가지 못한다.
앞의 예제가 정확히 이 규칙입니다. 회색의 z-index: 10은 부모 맥락 안에서만 유효합니다. 부모(.wrap-gray)와 빨강 쪽(.wrap-red)은 둘 다 별도의 z-index가 없으니, 상위 맥락에서는 DOM에 나온 순서로 정해집니다. 나중에 나온 빨강이 위로 올라오는 것입니다.
비유하자면 회사 조직도와 같습니다. 다른 팀의 팀원과 내 직급을 직접 비교할 수 없습니다. 먼저 두 팀(맥락 루트)의 서열이 정해지고, 그 안에서만 팀원끼리 순서를 다툽니다. 회색은 아무리 팀 내 1등이어도, 팀 자체가 뒤에 있으면 앞으로 나올 수 없습니다.
무엇이 새 쌓임 맥락을 만드는가
문제의 절반은 "내가 언제 맥락을 만들었는지 모른다"는 데 있습니다. opacity처럼 z-index와 상관없어 보이는 속성도 맥락을 만들기 때문입니다. 자주 마주치는 조건을 모아두면 다음과 같습니다.
- 루트 요소
<html>(기본으로 존재하는 최상위 맥락) position: relative또는absolute이면서z-index가auto가 아닐 때position: fixed또는sticky(이 둘은z-index없이도 항상 만든다)opacity가 1보다 작을 때transform,filter,backdrop-filter,perspective,clip-path,mask가none이 아닐 때mix-blend-mode가normal이 아닐 때isolation: isolatewill-change에 위 속성 중 하나를 지정했을 때contain: layout,paint,strict,contentcontainer-type이size또는inline-size일 때 (컨테이너 쿼리)- flex/grid 컨테이너의 자식이면서
z-index가auto가 아닐 때
목록이 길지만 요점은 하나입니다. transform으로 애니메이션을 걸거나, opacity로 페이드를 주거나, filter로 그림자를 넣는 평범한 스타일이 조용히 맥락을 만든다는 것입니다. 그래서 "분명 z-index만 만졌는데 안 된다" 싶을 때는, 대체로 조상 어딘가에 이런 속성이 하나 껴 있습니다.
isolation: isolate로 맥락을 의도적으로 만들기
맥락은 골칫거리이기만 한 게 아닙니다. 필요할 때 깔끔하게 만들 수도 있습니다. 이때 쓰는 속성이 isolation: isolate입니다.
앞의 opacity: 0.6 대신 부모에 isolation: isolate를 줘봅니다.
.wrap-gray {
isolation: isolate; /* 렌더링은 그대로, 맥락만 새로 만든다 */
}
"부모에 isolation isolate가 적용된 화면. 예제 2와 동일하게 빨강 상자가 위에 그려지지만, 회색 상자는 흐려지지 않고 또렷하게 유지된다."
쌓임 결과는 opacity 예제와 똑같습니다. 회색의 z-index: 10이 부모 맥락에 갇혀 빨강이 위로 올라옵니다. 하지만 이번엔 회색이 흐려지지 않고 또렷하게 남아 있습니다. isolation: isolate는 오직 "여기에 새 쌓임 맥락을 만든다"는 일만 하고, 색이나 투명도 같은 시각적 결과는 건드리지 않기 때문입니다.
이 속성이 유용한 진짜 상황은 반대 방향입니다. 어떤 컴포넌트 내부의 z-index들이 바깥으로 새어 나가 다른 요소와 충돌하는 걸 막고 싶을 때, 그 컴포넌트의 루트에 isolation: isolate를 한 줄 주면 됩니다. 그러면 내부 z-index는 그 안에서만 통용되어, 바깥 세계와 깔끔하게 분리됩니다. opacity: 0.999나 transform: translateZ(0) 같은 편법으로 맥락을 만드는 트릭을 종종 보는데, isolation: isolate가 그 의도를 그대로 드러내는 정공법입니다.
실전: 버그를 어떻게 추적하나
"내 요소가 왜 안 올라오지?"를 만나면 순서는 이렇습니다.
- 문제의 요소에서 시작해 부모를 타고 위로 올라가며, 앞 목록의 속성(
opacity,transform,filter,position: fixed등)을 가진 조상을 찾는다. - 그 조상이 새 맥락을 만든 범인이다. 내 요소의
z-index는 거기 갇혀 있다. - 해결은 둘 중 하나다. 그 조상 밖으로 요소를 빼내(예:
portal로body아래로 옮기기) 같은 맥락에서 겨루게 하거나, 맥락을 만든 조상 자체의 순서를 올린다.
브라우저 개발자 도구에서 요소를 선택하고 Computed 탭에서 opacity, transform 등을 훑어보면 범인을 빠르게 좁힐 수 있습니다. 숫자를 더 키우는 대신, 어느 맥락에 갇혔는지를 먼저 확인하는 습관이 시간을 아껴줍니다.
정리
| 상황 | 무슨 일이 벌어지나 | 대응 |
|---|---|---|
| 같은 맥락 안 | z-index 숫자가 그대로 비교된다 | 값만 조정하면 된다 |
| 조상이 새 맥락을 만듦 | 내 z-index가 그 맥락 안에 갇힌다 | 범인 조상을 찾아 밖으로 빼거나 조상을 올림 |
| 맥락을 의도적으로 만들고 싶음 | 내부 z-index를 바깥과 분리하고 싶다 | isolation: isolate 한 줄 |
z-index는 같은 쌓임 맥락 안의 형제끼리만 비교된다. 다른 맥락과는 숫자로 겨루지 못한다.opacity < 1,transform,filter,position: fixed/sticky같은 평범한 속성이 조용히 새 맥락을 만든다.- 자식의
z-index는 맥락 밖으로 나가지 못한다. 그래서9999도 소용없을 때가 있다. - 맥락을 일부러 만들고 싶다면 부작용 없는
isolation: isolate가 정공법이다.
마치며
z-index가 안 먹힐 때 숫자를 키우는 건 대부분 헛수고입니다. 진짜 질문은 "이 요소가 어느 쌓임 맥락에 있는가"입니다. 이 관점을 한 번 잡아두면, 모달이 사이드바 뒤로 숨거나 드롭다운이 헤더에 가려지는 문제를 훨씬 빠르게 풀 수 있습니다. 다음에 요소가 이유 없이 아래로 깔린다면, 숫자 대신 조상의 스타일부터 들여다보시길 권합니다. 긴 글 읽어주셔서 감사합니다.