분명 z-index: 9999를 줬는데, 왜 이 요소는 다른 요소 뒤에 숨어 있는 걸까?

포지셔닝 심화 — position 속성과 Stacking Context의 원리

CSS에서 요소를 원하는 위치에 배치하는 건 생각보다 까다롭습니다. position 속성 하나만 바꿔도 요소가 문서 흐름에서 빠지거나, 예상치 못한 곳에 자리를 잡기도 하죠. 특히 z-index가 왜 안 먹히는지 몰라서 삽질한 경험, 한 번쯤은 있지 않으신가요? 이 글에서는 position 5가지 속성의 동작 원리부터 Stacking Context까지 한 번에 정리해 보겠습니다.


1. position 속성 5가지

static — 기본값

모든 요소는 기본적으로 position: static입니다. 문서의 일반적인 흐름(normal flow)에 따라 배치되고, top, left 같은 오프셋 속성이 적용되지 않습니다.

CSS
.box {
  position: static;
  top: 20px; /* 무시됨 — static에서는 오프셋이 동작하지 않습니다 */
}

relative — 자기 자신 기준 이동

원래 위치를 기준으로 오프셋만큼 이동합니다. 핵심은 원래 자리가 보존된다 는 점입니다. 다른 요소들은 이 요소가 안 움직인 것처럼 배치됩니다.

CSS
.box {
  position: relative;
  top: 10px;   /* 원래 위치에서 아래로 10px */
  left: 20px;  /* 원래 위치에서 오른쪽으로 20px */
}

주로 자기 자신을 이동시키기보다는, absolute 자식의 기준점을 만들기 위해 사용하는 경우가 많습니다.

absolute — positioned 조상 기준

문서 흐름에서 완전히 빠집니다. 가장 가까운 positioned 조상(position이 static이 아닌 조상)을 기준으로 배치됩니다.

CSS
.parent {
  position: relative;  /* absolute 자식의 기준점 역할 */
}

.child {
  position: absolute;
  top: 0;
  right: 0;  /* 부모의 오른쪽 상단에 배치 */
}

positioned 조상이 없으면 초기 containing block(보통 <html> 요소, 즉 뷰포트 크기)을 기준으로 합니다.

fixed — 뷰포트 기준

뷰포트를 기준으로 고정됩니다. 스크롤해도 위치가 변하지 않죠. 고정 헤더나 플로팅 버튼에 자주 사용합니다.

CSS
.header {
  position: fixed;
  top: 0;
  left: 0;
  width: 100%;  /* 뷰포트 상단에 고정 */
}

주의할 점이 하나 있습니다. 조상 요소에 transform, filter, perspective 속성이 있으면, fixed가 뷰포트가 아닌 그 조상을 기준으로 배치됩니다. 디버깅할 때 꽤 자주 겪는 문제입니다.

sticky — 스크롤 임계점 기준

relativefixed의 하이브리드입니다. 평소에는 일반 흐름에 따르다가, 스크롤이 지정한 임계점에 도달하면 고정됩니다.

CSS
.sidebar-title {
  position: sticky;
  top: 0;  /* 뷰포트 상단에 닿으면 고정 */
}

2. top / right / bottom / left — 오프셋 동작

오프셋 속성은 positionstatic이 아닌 요소에서만 동작합니다. 어떤 position 값이냐에 따라 기준이 달라집니다.

position오프셋 기준
relative자기 자신의 원래 위치
absolutecontaining block (positioned 조상)
fixed뷰포트
sticky스크롤 임계점

topbottom을 동시에 지정하면? absolute 요소에서는 요소의 높이가 늘어납니다. relative에서는 top이 우선하고 bottom은 무시됩니다.

CSS
.stretch {
  position: absolute;
  top: 0;
  bottom: 0;  /* 부모의 높이 전체를 차지 */
}

3. Containing Block — absolute의 기준점

absolute 요소가 어디를 기준으로 배치되느냐는 containing block 이 결정합니다.

containing block을 형성하는 조건을 정리하면 이렇습니다.

  • positionrelative, absolute, fixed, sticky인 요소
  • transform, perspective, filter 속성이 none이 아닌 요소
  • will-changetransform이나 perspective인 요소
  • containpaint인 요소
HTML
<div class="grandparent" style="position: relative;">
  <div class="parent">
    <!-- parent는 position: static이므로 건너뜀 -->
    <div class="child" style="position: absolute; top: 0;">
      <!-- grandparent를 기준으로 배치 -->
    </div>
  </div>
</div>

공부하다 보니 이 부분에서 많이 헷갈렸습니다. absolute 요소의 부모가 기준이 아니라, 가장 가까운 positioned 조상 이 기준이라는 것을 확실히 기억해야 합니다.


4. sticky의 동작 원리와 주의점

sticky는 편리하지만 예상대로 동작하지 않는 경우가 꽤 있습니다.

반드시 임계값을 지정해야 합니다

top, bottom, left, right 중 하나 이상을 지정해야 동작합니다. 값이 없으면 브라우저가 언제 고정해야 하는지 알 수 없어서 static처럼 동작합니다.

overflow 속성의 영향

조상 요소에 overflow: hidden, overflow: auto, overflow: scroll이 있으면 sticky가 동작하지 않을 수 있습니다. sticky 요소는 가장 가까운 스크롤 가능한 조상 내에서만 고정되기 때문입니다.

CSS
/* sticky가 동작하지 않는 경우 */
.container {
  overflow: hidden;  /* 이 속성 때문에 sticky가 안 먹힙니다 */
}

.container .title {
  position: sticky;
  top: 0;
}

sticky의 고정 범위

sticky 요소는 부모 요소의 범위 안에서만 고정됩니다. 부모 요소가 뷰포트를 벗어나면 sticky 요소도 함께 사라집니다.


5. z-index와 Stacking Context

z-index는 요소의 쌓임 순서를 결정합니다. 숫자가 클수록 위에 표시되죠. 하지만 z-index만으로는 설명이 안 되는 상황이 있습니다.

Stacking Context란?

Stacking Context는 z축 방향의 독립적인 레이어 그룹입니다. 같은 stacking context 안의 요소들끼리만 z-index로 순서를 비교할 수 있습니다.

새로운 Stacking Context를 생성하는 조건

다음 중 하나라도 해당하면 새로운 stacking context가 만들어집니다.

  • positionrelative/absolute/fixed/sticky이면서 z-indexauto가 아닌 값
  • positionfixed 또는 sticky (항상 새 context 생성)
  • opacity가 1보다 작은 값
  • transformnone이 아닌 값
  • filternone이 아닌 값
  • perspectivenone이 아닌 값
  • isolation: isolate
  • will-change에 위 속성이 명시된 경우
  • contain: layout 또는 contain: paint

Stacking Context 안의 쌓임 순서

하나의 stacking context 내에서 요소는 다음 순서로 쌓입니다 (아래부터 위로).

  1. 배경과 테두리 (stacking context를 형성하는 요소 자체)
  2. 음수 z-index를 가진 자식
  3. non-positioned, non-floated 블록 요소
  4. non-positioned float 요소
  5. non-positioned inline 요소
  6. z-index: 0 또는 z-index: auto인 positioned 요소
  7. 양수 z-index를 가진 자식

6. z-index가 안 먹히는 이유

이 부분이 가장 중요합니다. z-index: 9999를 줬는데 왜 안 되는 걸까요?

원인 1: position이 static

z-index는 positioned 요소(static이 아닌 요소)에서만 동작합니다.

CSS
/* z-index가 무시됩니다 */
.box {
  z-index: 100;  /* position이 static이라 적용 안 됨 */
}

/* 이렇게 해야 동작합니다 */
.box {
  position: relative;
  z-index: 100;
}

원인 2: 부모의 Stacking Context

부모가 낮은 z-index로 stacking context를 형성하면, 자식은 그 context 안에 갇힙니다.

CSS
.parent-a {
  position: relative;
  z-index: 1;  /* 새로운 stacking context 생성 */
}

.parent-a .child {
  position: relative;
  z-index: 9999;  /* 부모 context 밖으로 나갈 수 없음 */
}

.parent-b {
  position: relative;
  z-index: 2;  /* parent-a보다 위에 표시됨 */
}
/* parent-a의 child(9999)는 parent-b(2) 아래에 표시됩니다 */

이건 비유하면 이렇습니다. stacking context는 봉투 같은 것이고, 봉투 안의 카드(z-index)를 아무리 높여도 봉투 자체의 순서를 바꿀 수는 없습니다.

원인 3: 의도치 않은 Stacking Context 생성

opacity: 0.99transform: translateZ(0) 같은 속성을 성능 최적화 목적으로 추가했는데, 이것이 새로운 stacking context를 만들어서 z-index 순서가 꼬이는 경우가 있습니다.


7. 실전 예제

드롭다운 메뉴

CSS
.nav-item {
  position: relative;  /* 드롭다운의 기준점 */
}

.dropdown {
  position: absolute;
  top: 100%;           /* 네비게이션 아이템 바로 아래 */
  left: 0;
  z-index: 100;        /* 다른 콘텐츠 위에 표시 */
  display: none;       /* 기본 숨김 */
}

.nav-item:hover .dropdown {
  display: block;      /* 호버 시 표시 */
}

고정 헤더

CSS
.header {
  position: fixed;
  top: 0;
  left: 0;
  width: 100%;
  z-index: 1000;       /* 대부분의 콘텐츠보다 위에 */
}

.main-content {
  margin-top: 60px;    /* 헤더 높이만큼 여백 확보 */
}

fixed 헤더를 사용하면 그 아래 콘텐츠가 헤더에 가려질 수 있습니다. margin-top이나 padding-top으로 여백을 확보하는 것을 잊지 마세요.

모달 오버레이

CSS
.modal-overlay {
  position: fixed;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  background: rgba(0, 0, 0, 0.5);  /* 반투명 배경 */
  z-index: 9000;
  display: flex;
  justify-content: center;
  align-items: center;
}

.modal-content {
  /* 오버레이 안에 있으므로 별도 z-index 불필요 */
  background: white;
  padding: 2rem;
  border-radius: 8px;
  max-width: 500px;
  width: 90%;
}

모달은 isolation: isolate를 사용해 독립적인 stacking context를 만들어 관리하면 z-index 충돌을 줄일 수 있습니다.

CSS
.app {
  isolation: isolate;  /* 앱 전체를 하나의 stacking context로 */
}

정리

속성기준문서 흐름스크롤
static일반 흐름유지따라감
relative자기 원래 위치유지따라감
absolutepositioned 조상이탈따라감
fixed뷰포트이탈고정
sticky스크롤 임계점유지조건부 고정

z-index가 안 먹히면 세 가지를 체크하세요.

  1. positionstatic이 아닌지
  2. 부모가 낮은 z-index의 stacking context를 만들고 있지는 않은지
  3. opacity, transform, filter 같은 속성이 의도치 않게 stacking context를 생성하고 있지는 않은지

Stacking context 개념만 제대로 이해하면 z-index 디버깅 시간을 크게 줄일 수 있습니다.

댓글 로딩 중...