z-index: -1 배경 레이어가 안 보인다 — body 배경색이 캔버스로 승격될 때

position:fixed + z-index:-1 로 깐 배경이 Chromium 에서 body 배경색에 덮인다. getComputedStyle 로는 전부 정상으로 보여서 스크린샷 없이는 못 잡는다.

전체화면 배경 레이어를 흔한 방식으로 만들었다. 콘텐츠 로드 전 흰 화면이 깜빡이는 걸 막으려고 fallback 색을 깔고,

html, body { background: #8fd8ea; }

그 위에 실제 배경을 얹었다.

<div style={{ position: 'fixed', inset: 0, zIndex: -1, background: 'url(bg.png) center/cover' }} />

배경 이미지가 전혀 안 보였다. <body> 의 단색만 화면을 덮고 있었다.

스타일은 다 정상이라고 나온다

가장 먼저 의심한 건 이미지 로드 실패였는데 아니었다. getComputedStyle 로 확인하면 전부 멀쩡하다.

  • background-image 설정됨
  • background-position / size 정상
  • getBoundingClientRect() 가 뷰포트 전체 크기
  • visibility: visible, opacity: 1

스타일 계산은 정상인데 페인트만 안 된다. 그래서 스타일을 assert 하는 테스트로는 절대 안 잡힌다.

여기에 함정이 하나 더 있었다. 처음에는 배경이 CSS 그라데이션이었는데, 색이 <body> fallback 색과 비슷해서 잘 나오는 줄 알았다. 팔레트가 확연히 다른 실제 이미지로 바꾸고 나서야 “통째로 안 보인다”가 드러났다.

원인 — 캔버스 배경

<html> 에 배경이 없고 <body> 에만 배경색이 있으면, 그 색이 캔버스(canvas) 배경으로 승격되어 뷰포트 전체를 채운다. 이건 스펙에 정의된 동작이다.

문제는 Chromium 구현상 이 캔버스 배경 페인트가 음수 z-index 를 가진 포지션 엘리먼트보다 위에 그려지는 경우가 있다는 것이다. z-index: -1 이 “루트 스택 컨텍스트의 배경보다도 뒤”로 밀려버린다.

최소 재현

await page.setContent(`
  <html><head><style>html,body{margin:0;background:#8fd8ea;}</style></head>
  <body>
    <div style="position:fixed;inset:0;z-index:-1;background:linear-gradient(red,blue);"></div>
  </body></html>
`);
// 스크린샷: 그라데이션 없이 #8fd8ea 단색

<body> 의 배경색만 빼면 같은 코드가 정상 동작한다. 트리거 조건은 정확히 “body 배경색 + 자식의 음수 z-index” 조합이다.

해결 — 음수 z-index 를 쓰지 않는다

배경을 DOM 순서상 첫 번째로 두고 z-index 를 아예 안 준다.

<>
  <div style={{ position: 'fixed', inset: 0, background: 'url(bg.png) center/cover' }} />
  <MainContent />
</>

position: fixed + z-index: auto 인 엘리먼트끼리는 DOM 순서로 페인트 순서가 정해진다. 나중에 오는 게 위다. 배경을 맨 앞에 두면 캔버스 배경 위에는 정상적으로 그려지고, 뒤에 오는 콘텐츠보다는 아래에 깔린다. 음수 z-index 없이 원하는 “맨 뒤”가 나온다.

이것만으로는 부족한 경우

MainContentposition 을 안 쓰는 일반 static 콘텐츠라면 아직 안 끝났다.

CSS 페인트 순서 규칙(CSS 2.1 Appendix E)상, 같은 스택 컨텍스트 안에서 positioned 엘리먼트는 position 없는 in-flow 콘텐츠보다 항상 나중에 그려진다. z-index: auto0 이어도 그렇고, DOM 순서와 무관하다.

즉 배경 레이어가 DOM 상 먼저 와도, 콘텐츠가 plain <div> 들이면 여전히 배경이 그 위에 얹힌다.

이 상태가 얼마나 안 보이냐면

시각적으로는 멀쩡해 보일 수 있다. 배경이 투명한 부분이 많으면 콘텐츠가 그대로 보인다. 그런데 클릭만 배경 레이어가 가로챈다.

실제로 겪은 증상은 이랬다.

  • elementFromPoint 가 버튼이 아니라 배경 <div> 를 반환
  • locator.click() 이 “element is visible, enabled and stable” 인데도 계속 타임아웃

스크린샷 검증으로는 못 잡고 클릭 상호작용 테스트에서만 드러난다.

해결

콘텐츠 래퍼를 같은 “positioned + 명시적 z-index” 그룹으로 올린다.

<>
  <div style={{ position: 'fixed', inset: 0, background: 'url(bg.png) center/cover' }} />
  <div style={{ position: 'relative', zIndex: 1 }}>  {/* 이 한 줄이 핵심 */}
    <MainContent />
  </div>
</>

그러면 그룹 안에서 DOM 순서가 타이브레이커로 작동해서 나중에 오는 콘텐츠가 이긴다.

배경 레이어 패턴의 완성형

두 가지를 둘 다 챙겨야 한다.

  1. 배경 레이어에는 z-index 를 쓰지 않는다 — 캔버스 배경 문제 회피
  2. 그 위 콘텐츠 래퍼에는 position: relative + zIndex 를 명시한다 — static vs positioned 문제 회피

하나만 하면 증상이 다른 형태로 남는다.

디버깅 요령

getComputedStylegetBoundingClientRect() 로는 이 버그가 안 잡힌다. 스타일 계산과 페인트 결과가 분리돼 있기 때문이다. 실제 스크린샷을 눈으로 봐야 발견된다. 헤드리스 브라우저로 스타일만 assert 하는 테스트는 이런 페인트 레이어링 버그를 그냥 통과시킨다.

의심되면 page.setContent() 로 body 배경색 유무만 바꿔가며 최소 재현을 만드는 게 가장 빠르다. 실제 앱에서 뺄셈으로 좁히려 들면 컴포넌트 트리가 방해한다.