Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added apps/blog/public/stacking-context-ex1.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/blog/public/stacking-context-ex2.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/blog/public/stacking-context-ex3.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
141 changes: 141 additions & 0 deletions apps/blog/src/app/(main)/posts/frontend/css-stacking-context/page.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
import { frontmatter } from '@libs/frontmatter';

export const metadata = frontmatter({
title: 'z-index를 9999로 줬는데 왜 안 올라올까: 쌓임 맥락 완전 정복',
description:
'z-index가 큰데도 요소가 아래에 깔리는 현상을 쌓임 맥락(stacking context)으로 설명합니다. 부모의 opacity가 만드는 함정, 새 맥락을 만드는 속성들, 그리고 isolation: isolate로 깔끔하게 다스리는 법을 실제 렌더링 결과로 정리합니다.',
seriesId: 'frontend',
postId: 'css-stacking-context',
tags: ['CSS', 'z-index', 'stacking context', '레이아웃'],
date: '2026-07-06 22:00',
});

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

드롭다운이나 모달을 만들다 보면 한 번쯤 겪는 일이 있습니다. 분명 `z-index`를 `9999`까지 올렸는데도 요소가 다른 것 아래에 깔려 있는 상황입니다. 저도 처음엔 숫자를 더 키우면 되겠지 싶어 `99999`, `999999`까지 올려봤지만 소용없었습니다.

원인은 `z-index` 값이 작아서가 아니었습니다. 그 요소가 **비교 자체가 안 되는 다른 세계**에 갇혀 있었기 때문입니다. 이 "세계"가 바로 쌓임 맥락(stacking context)입니다. 이 글에서는 눈으로 보이는 렌더링 결과부터 시작해, 왜 이런 일이 벌어지고 어떻게 다스리는지 정리합니다.

## 같은 맥락에서는 z-index가 잘 동작한다

먼저 정상적인 경우입니다. 회색 상자와 빨강 상자를 겹쳐두고, 회색에만 `z-index: 10`을 줬습니다.

```html
<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>
```

```css
.stage {
position: relative;
}
.box {
position: absolute;
}
.gray {
z-index: 10;
}
/* .red 에는 z-index를 주지 않았다 (auto) */
```

![회색 상자와 빨강 상자가 겹친 화면. 회색 상자에 z-index 10이 주어져 빨강 상자보다 위에 그려진다. 부모에는 특별한 속성이 없다.](/stacking-context-ex1.png)

기대한 그대로입니다. `z-index`가 큰 회색이 빨강 위에 놓입니다. 두 상자가 **같은 쌓임 맥락**에 있어서 `z-index` 숫자를 곧바로 비교할 수 있기 때문입니다. 여기까지는 아무 문제가 없습니다.

## z-index가 큰데도 아래에 깔리는 순간

이제 딱 한 가지만 바꿉니다. 회색 상자를 감싼 부모(`.wrap-gray`)에 `opacity: 0.6`을 줍니다. 상자 자체가 아니라 **부모**에 준 것이고, `z-index`는 건드리지 않았습니다.

```css
.wrap-gray {
opacity: 0.6; /* 이 한 줄이 전부다 */
}
```

![부모에 opacity 0.6이 적용된 화면. 회색 상자에 z-index 10이 있는데도 z-index가 없는 빨강 상자가 위에 그려지고, 회색 상자는 반투명하게 흐려져 있다.](/stacking-context-ex2.png)

결과가 뒤집혔습니다. `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-index`가 `auto`가 아닐 때
- `position: fixed` 또는 `sticky` (이 둘은 `z-index` 없이도 항상 만든다)
- `opacity`가 1보다 작을 때
- `transform`, `filter`, `backdrop-filter`, `perspective`, `clip-path`, `mask`가 `none`이 아닐 때
- `mix-blend-mode`가 `normal`이 아닐 때
- `isolation: isolate`
- `will-change`에 위 속성 중 하나를 지정했을 때
- `contain: layout`, `paint`, `strict`, `content`
- `container-type`이 `size` 또는 `inline-size`일 때 (컨테이너 쿼리)
- flex/grid 컨테이너의 자식이면서 `z-index`가 `auto`가 아닐 때

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

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

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

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

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

![부모에 isolation isolate가 적용된 화면. 예제 2와 동일하게 빨강 상자가 위에 그려지지만, 회색 상자는 흐려지지 않고 또렷하게 유지된다.](/stacking-context-ex3.png)

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

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

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

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

1. 문제의 요소에서 시작해 **부모를 타고 위로** 올라가며, 앞 목록의 속성(`opacity`, `transform`, `filter`, `position: fixed` 등)을 가진 조상을 찾는다.
2. 그 조상이 새 맥락을 만든 범인이다. 내 요소의 `z-index`는 거기 갇혀 있다.
3. 해결은 둘 중 하나다. 그 조상 밖으로 요소를 빼내(예: `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`가 안 먹힐 때 숫자를 키우는 건 대부분 헛수고입니다. 진짜 질문은 "이 요소가 어느 쌓임 맥락에 있는가"입니다. 이 관점을 한 번 잡아두면, 모달이 사이드바 뒤로 숨거나 드롭다운이 헤더에 가려지는 문제를 훨씬 빠르게 풀 수 있습니다. 다음에 요소가 이유 없이 아래로 깔린다면, 숫자 대신 조상의 스타일부터 들여다보시길 권합니다. 긴 글 읽어주셔서 감사합니다.
Loading