빌드노트
z-index가 9999인 드롭다운이 가려지면 부모의 순서를 본다
드롭다운의 큰 z-index가 조상 쌓임 맥락 밖에서는 통하지 않는 이유를 확인한다. 실제 조상 스타일 조회와 같은 좌표의 요소 순서·화면 비교로 부모 순서 변경, 불필요한 경계 제거, 메뉴 렌더링 위치 변경 중 무엇을 고를지 판단한다.
드롭다운에 z-index: 9999를 줬는데도 옆 콘텐츠 뒤에 남는다면, 메뉴를 감싼 조상이 쌓임 맥락(stacking context)을 만드는지 확인한다. 그 조상이 콘텐츠보다 뒤에 놓였다면 자식의 숫자를 더 올려도 바깥 순서는 바뀌지 않는다. 메뉴의 값과 메뉴가 속한 묶음의 값을 나눠 봐야 한다.
쌓임 맥락은 내부 요소들의 겹치는 순서를 정하는 범위다. 바깥에서는 그 안의 내용을 하나의 묶음으로 취급한다. MDN의 쌓임 맥락 설명 (새 탭에서 열림)에 따르면 position: relative 또는 absolute에 auto가 아닌 z-index를 주면 이 경계가 생긴다. 따라서 부모의 1 아래에 있는 자식의 9999와, 부모 바깥 콘텐츠의 2를 직접 비교하면 원인을 놓친다.
메뉴부터 조상까지 실제 값을 읽는다
문제가 보이는 화면에서 메뉴를 연 채 개발자 도구의 Console을 사용한다. 아래 코드는 메뉴 자신부터 가까운 조상 순서로 계산된 스타일을 읽는다. #menu를 자기 메뉴의 선택자로 바꾸며, 해당 메뉴가 있는 문서에서 실행한다.
(() => {
const menu = document.querySelector('#menu');
if (!menu) throw new Error('#menu 선택자를 확인하세요.');
const rows = [];
for (let el = menu; el; el = el.parentElement) {
const s = getComputedStyle(el);
rows.push({ element: el.id || el.tagName, position: s.position,
zIndex: s.zIndex, opacity: s.opacity, isolation: s.isolation,
transform: s.transform, contain: s.contain });
}
return rows;
})()
선택자 오류가 나면 메뉴가 실제로 열려 있고 올바른 문서를 선택했는지 먼저 확인한다. 가리는 콘텐츠에도 선택자만 바꿔 같은 조회를 실행한다. 두 경로가 만나는 공통 쌓임 맥락을 찾고, 그 안에서 각각 어느 쌓임 맥락에 속하는지 비교한다. 중간에 경계가 여러 개라면 가장 가까운 부모 하나만 바꿔서는 더 바깥의 순서가 그대로일 수 있다.
별도 합성 페이지에서 이 조회를 실행하자 메뉴는 absolute / 9999, 그 부모 toolbar는 relative / 1이었다. 메뉴 경로의 BODY와 HTML은 static / auto였고, 문서 루트인 HTML은 기본 쌓임 맥락이다. toolbar와 형제인 content는 absolute / 2였다. 여기서 바깥 비교 대상은 toolbar의 1과 content의 2다. 자식의 9999는 toolbar 안쪽 순서에 쓰인다.
이 조회는 주요 후보를 읽는 도구이며 모든 쌓임 맥락을 자동 판정하지 않는다. 숫자 z-index가 없어도 opacity가 1보다 작거나 isolation이 isolate이면 경계가 생긴다. 표에 원인이 없으면 MDN의 생성 조건과 Computed 스타일을 대조한다. 예를 들어 fixed·sticky, transform, flex·grid 아이템의 z-index에도 별도 조건이 있다. 실제 값을 확인하지 않고 모든 조상의 스타일을 한꺼번에 지우지는 않는다.
같은 좌표에서 자식과 부모를 따로 바꿨다
실험에는 노란 메뉴를 담은 toolbar와 파란 콘텐츠를 겹쳐 놓았다. 실제 서비스의 장애 기록은 아니다. 메뉴의 화면 위치는 왼쪽 180px, 위쪽 100px, 크기는 220×160px였다. 파란 콘텐츠는 왼쪽 200px, 위쪽 140px에서 시작했다. 두 요소가 함께 걸리는 뷰포트 좌표 (260, 210)을 모든 조건에서 그대로 사용했다.
겹친 지점의 요소 순서를 비교하려면 Console에서 다음 식을 실행한다. 자기 화면에서는 260과 210을 실제 겹치는 지점의 좌표로 바꾼다. 단위는 뷰포트 왼쪽 위를 기준으로 한 CSS px이며, 페이지 전체의 스크롤 좌표가 아니다.
document.elementsFromPoint(260, 210).map(el => el.id || el.tagName)
elementsFromPoint 설명 (새 탭에서 열림)에서 반환 순서는 위쪽 상자부터 아래쪽 상자 순서다. 다만 CSSOM View 편집 초안의 알고리즘 (새 탭에서 열림)은 hit testing 대상인 상자를 조회한다. pointer-events: none처럼 조회 대상에서 빠지는 요소가 있으므로 화면도 함께 비교한다. 이번 메뉴와 콘텐츠는 모두 pointer-events: auto였고, 배경은 불투명했다.
Windows 11의 headless Chrome 153.0.8010.36, 960×540 뷰포트, 장치 픽셀 비율 1에서 얻은 결과다. 아래 배열은 실제 반환값의 앞 두 항목이다. 뒤에는 BODY, HTML이 이어졌다.
- 부모 1, 메뉴 9999:
["content", "menu"]. 겹친 지점은 파란색이었다. - 부모 1을 유지하고 메뉴만 999999:
["content", "menu"]. 여전히 파란색이었다. - 메뉴를 9999로 돌리고 부모만 3:
["menu", "content"]. 같은 지점이 노란색으로 바뀌었다.
세 조건에서 메뉴의 위치와 크기는 같았다. 메뉴가 이동해서 드러난 것이 아니라 앞뒤 순서가 바뀐 것이다. CSS 2.1의 z-index 규칙 (새 탭에서 열림)은 쌓임 맥락을 바깥에서 하나의 단위로 다루며, 같은 맥락 안의 쌓임 수준을 비교한다고 설명한다. 자식의 숫자만 바꾼 결과와 부모의 숫자를 바꾼 결과가 이 규칙에 맞았다.
이 구조에서 toolbar 전체가 콘텐츠보다 앞에 와도 된다면, 이미 position: relative인 toolbar의 선언만 다음처럼 바꿀 수 있다.
#toolbar { z-index: 3; }
#toolbar는 확인한 조상의 선택자로 바꾼다. 3은 이 비교에서 콘텐츠의 2보다 높게 정한 값이다. 실제 페이지에서는 확인한 경쟁 영역의 순서와 설계에 맞춰 정한다. 메뉴만 떠야 하는데 toolbar 안의 다른 내용까지 앞에 나오면, 부모 전체를 올리는 수정은 요구에 맞지 않는다.
z-index를 지워도 남는 경계가 있다
부모의 경계가 필요 없는 경우도 비교했다. position: relative는 유지하고 toolbar의 z-index만 auto로 바꾸자 메뉴가 앞에 나왔다. 반면 0으로 바꾸면 다시 콘텐츠 뒤였다. 이 조건에서 auto는 부모가 새 쌓임 맥락을 만들지 않게 하지만, 정수인 0은 계속 경계를 만든다. 0을 경계 제거용 값으로 쓰면 안 된다.
이어 부모를 z-index: auto로 둔 상태에서 다른 속성 하나씩만 바꿨다.
opacity: 1에서isolation: isolate를 주면["content", "menu"],isolation: auto로 돌리면["menu", "content"]였다.isolation: auto에서opacity: 0.99를 주면["content", "menu"],opacity: 1로 돌리면["menu", "content"]였다.
각 비교에서 메뉴는 9999, 콘텐츠는 2였고 메뉴의 위치·크기도 그대로였다. 스크린샷에서도 배열의 첫 항목에 따라 파란색과 노란색이 바뀌었다. Compositing and Blending 1 후보 권고 초안 (새 탭에서 열림)은 isolate가 쌓임 맥락을 만든다고 명시한다. CSS Color 4 후보 권고 초안 (새 탭에서 열림)도 1보다 작은 opacity가 자식을 위한 쌓임 맥락을 만든다고 설명한다. 거의 불투명해 보이는 0.99라도 경계 유무에서는 1과 달랐다.
불필요한 선언이라면 원인이 된 속성 하나를 개발자 도구에서 잠시 해제하고 같은 지점을 다시 확인한다. 다른 경계 생성 조건이 남아 있으면 한 속성을 없애도 해결되지 않을 수 있다. isolation은 혼합 효과를 격리하고 opacity는 부모와 내용을 함께 투명하게 만드는 의도가 있을 수 있으므로, 메뉴가 보인다는 이유만으로 필요한 효과까지 제거하지 않는다.
부모의 효과와 순서를 유지하면서 메뉴만 바깥에 떠야 한다면 렌더링 구조를 바꿀 차례다. 합성 페이지에서도 부모 1을 유지한 채 메뉴를 그 밖의 body 자식으로 옮기고, absolute 위치값을 조정해 같은 화면 좌표를 유지하자 메뉴가 앞에 나왔다. 실제 앱에서는 의도한 공통 쌓임 맥락 아래의 오버레이 영역에 메뉴를 렌더링하도록 템플릿이나 포털 위치를 정한다. 새 조상 경로에도 경계가 있는지 다시 읽고, 위치 기준·상속 스타일·이벤트 위임과 키보드 포커스 동작을 확인해야 한다. body 아래면 항상 해결된다는 규칙은 아니다.
직접 확인한 범위는 새 브라우저 프로필과 loopback의 일반 블록 두 영역이다. 최종 비교를 마친 뒤 부모 1·메뉴 9999로 복원했을 때도 처음처럼 콘텐츠가 앞에 나왔다. Firefox·Safari, 실제 모바일, Shadow DOM·iframe 내부, top layer의 dialog·popover, overflow에 의한 잘림은 실행하지 않았다. 이 글의 판단은 위치와 크기가 겹치는 두 영역의 페인팅 순서에 관한 것이다. 조회 목록에 메뉴가 없거나 부모 순서를 바꿔도 화면이 그대로라면 숫자를 더 올리기 전에 좌표·표시 상태·잘림 등 다른 원인을 확인한다.