본문으로 건너뛰기

프론트엔드

z-index를 9999로 줬는데 왜 안 올라올까: 쌓임 맥락 완전 정복

2026. 07. 06. 월요일 오후 10시 0분

z-index: 9999인데 왜 안 올라올까

드롭다운이나 모달을 만들다 보면 한 번쯤 겪는 일이 있습니다. 분명 z-index9999까지 올렸는데도 요소가 다른 것 아래에 깔려 있는 상황입니다. 저도 처음엔 숫자를 더 키우면 되겠지 싶어 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 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가 없는 빨강 상자가 위에 그려지고, 회색 상자는 반투명하게 흐려져 있다."부모에 opacity 0.6이 적용된 화면. 회색 상자에 z-index 10이 있는데도 z-index가 없는 빨강 상자가 위에 그려지고, 회색 상자는 반투명하게 흐려져 있다."

결과가 뒤집혔습니다. z-index: 10을 가진 회색이 z-index도 없는 빨강 아래로 내려가 버렸습니다. 회색이 살짝 흐려진 것 말고는 바꾼 게 없는데 말입니다.

opacity: 0.6이 회색의 부모를 새로운 쌓임 맥락의 루트로 만들었기 때문입니다. 그 순간 회색의 z-index: 10은 부모가 만든 맥락 안에 갇힙니다. 바깥의 빨강과는 더 이상 숫자로 겨루지 못합니다. 앞에서 z-index를 아무리 키워도 소용없던 이유가 바로 이것입니다.

쌓임 맥락이란 무엇인가

쌓임 맥락은 z축(화면에서 나를 향해 튀어나오는 방향)으로 요소를 쌓을 때 쓰는 독립된 그룹입니다. 페이지 전체는 최상위 맥락(루트, <html>) 하나로 시작하고, 특정 조건이 갖춰지면 그 안에 자식 맥락이 새로 생깁니다.

핵심 규칙은 두 가지입니다.

  1. z-index같은 쌓임 맥락에 속한 형제끼리만 비교된다.
  2. 서로 다른 맥락에 속한 요소는, 각자의 맥락 루트가 상위 맥락에서 어떤 순서인지에 따라 통째로 결정된다. 자식의 z-index가 아무리 커도 맥락 밖으로 나가지 못한다.

앞의 예제가 정확히 이 규칙입니다. 회색의 z-index: 10은 부모 맥락 안에서만 유효합니다. 부모(.wrap-gray)와 빨강 쪽(.wrap-red)은 둘 다 별도의 z-index가 없으니, 상위 맥락에서는 DOM에 나온 순서로 정해집니다. 나중에 나온 빨강이 위로 올라오는 것입니다.

비유하자면 회사 조직도와 같습니다. 다른 팀의 팀원과 내 직급을 직접 비교할 수 없습니다. 먼저 두 팀(맥락 루트)의 서열이 정해지고, 그 안에서만 팀원끼리 순서를 다툽니다. 회색은 아무리 팀 내 1등이어도, 팀 자체가 뒤에 있으면 앞으로 나올 수 없습니다.

무엇이 새 쌓임 맥락을 만드는가

문제의 절반은 "내가 언제 맥락을 만들었는지 모른다"는 데 있습니다. opacity처럼 z-index와 상관없어 보이는 속성도 맥락을 만들기 때문입니다. 자주 마주치는 조건을 모아두면 다음과 같습니다.

  • 루트 요소 <html> (기본으로 존재하는 최상위 맥락)
  • position: relative 또는 absolute이면서 z-indexauto가 아닐 때
  • position: fixed 또는 sticky (이 둘은 z-index 없이도 항상 만든다)
  • opacity가 1보다 작을 때
  • transform, filter, backdrop-filter, perspective, clip-path, masknone이 아닐 때
  • mix-blend-modenormal이 아닐 때
  • isolation: isolate
  • will-change에 위 속성 중 하나를 지정했을 때
  • contain: layout, paint, strict, content
  • container-typesize 또는 inline-size일 때 (컨테이너 쿼리)
  • flex/grid 컨테이너의 자식이면서 z-indexauto가 아닐 때

목록이 길지만 요점은 하나입니다. transform으로 애니메이션을 걸거나, opacity로 페이드를 주거나, filter로 그림자를 넣는 평범한 스타일이 조용히 맥락을 만든다는 것입니다. 그래서 "분명 z-index만 만졌는데 안 된다" 싶을 때는, 대체로 조상 어딘가에 이런 속성이 하나 껴 있습니다.

isolation: isolate로 맥락을 의도적으로 만들기

맥락은 골칫거리이기만 한 게 아닙니다. 필요할 때 깔끔하게 만들 수도 있습니다. 이때 쓰는 속성이 isolation: isolate입니다.

앞의 opacity: 0.6 대신 부모에 isolation: isolate를 줘봅니다.

.wrap-gray {
  isolation: isolate; /* 렌더링은 그대로, 맥락만 새로 만든다 */
}

부모에 isolation isolate가 적용된 화면. 예제 2와 동일하게 빨강 상자가 위에 그려지지만, 회색 상자는 흐려지지 않고 또렷하게 유지된다."부모에 isolation isolate가 적용된 화면. 예제 2와 동일하게 빨강 상자가 위에 그려지지만, 회색 상자는 흐려지지 않고 또렷하게 유지된다."

쌓임 결과는 opacity 예제와 똑같습니다. 회색의 z-index: 10이 부모 맥락에 갇혀 빨강이 위로 올라옵니다. 하지만 이번엔 회색이 흐려지지 않고 또렷하게 남아 있습니다. isolation: isolate는 오직 "여기에 새 쌓임 맥락을 만든다"는 일만 하고, 색이나 투명도 같은 시각적 결과는 건드리지 않기 때문입니다.

이 속성이 유용한 진짜 상황은 반대 방향입니다. 어떤 컴포넌트 내부의 z-index들이 바깥으로 새어 나가 다른 요소와 충돌하는 걸 막고 싶을 때, 그 컴포넌트의 루트에 isolation: isolate를 한 줄 주면 됩니다. 그러면 내부 z-index는 그 안에서만 통용되어, 바깥 세계와 깔끔하게 분리됩니다. opacity: 0.999transform: translateZ(0) 같은 편법으로 맥락을 만드는 트릭을 종종 보는데, isolation: isolate가 그 의도를 그대로 드러내는 정공법입니다.

실전: 버그를 어떻게 추적하나

"내 요소가 왜 안 올라오지?"를 만나면 순서는 이렇습니다.

  1. 문제의 요소에서 시작해 부모를 타고 위로 올라가며, 앞 목록의 속성(opacity, transform, filter, position: fixed 등)을 가진 조상을 찾는다.
  2. 그 조상이 새 맥락을 만든 범인이다. 내 요소의 z-index는 거기 갇혀 있다.
  3. 해결은 둘 중 하나다. 그 조상 밖으로 요소를 빼내(예: portalbody 아래로 옮기기) 같은 맥락에서 겨루게 하거나, 맥락을 만든 조상 자체의 순서를 올린다.

브라우저 개발자 도구에서 요소를 선택하고 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가 안 먹힐 때 숫자를 키우는 건 대부분 헛수고입니다. 진짜 질문은 "이 요소가 어느 쌓임 맥락에 있는가"입니다. 이 관점을 한 번 잡아두면, 모달이 사이드바 뒤로 숨거나 드롭다운이 헤더에 가려지는 문제를 훨씬 빠르게 풀 수 있습니다. 다음에 요소가 이유 없이 아래로 깔린다면, 숫자 대신 조상의 스타일부터 들여다보시길 권합니다. 긴 글 읽어주셔서 감사합니다.