복잡한 z-index 문제 해결하기
2025년 6월 26일 · #에세이
개요
안녕하세요. 월급쟁이부자들 커뮤니티 스쿼드에서 프론트엔드 개발을 담당하고 있는 로틴(김재환)입니다. 2024년에 월급쟁이부자들에 입사한 후, "쌓임 맥락"과 관련된 여러 문제들을 경험하고 해결했던 과정을 이 글에서 소개하고자 합니다.
모달 UI
월급쟁이부자들 서비스인 월부닷컴에서는 다양한 상황에서 여러 종류의 모달 UI(이하 모달)를 사용하고 있습니다. 중요한 정보를 사용자에게 전달하거나 행동을 유도할 때 모달은 매우 유용합니다.
용어 설명: 모달이란?
모달은 브라우저 내에서 사용자의 주의를 끌기 위한 대화 상자를 의미합니다. 콘텐츠에 집중할 수 있도록 외부와의 상호작용을 차단하는 것이 특징입니다. 모달의 형태로는 다이얼로그, 드로어, 사이드픽 등이 있으며, 각 회사마다 명칭은 조금씩 다를 수 있습니다.
쌓임 맥락
웹에서 모달을 구현하려면 보통 position
과 같은 CSS 속성을 사용합니다. 이 속성들은 화면의 깊이, 즉 z축을 설정할 수 있게 해주며, 이를 통해 요소들이 위아래로 겹치는 3차원적인 표현이 가능합니다. 이 때 형성되는 레이어 구조를 "쌓임 맥락(Stacking Context)"이라고 합니다.
쌓임 맥락은 사용자의 눈에 가까울수록 더 높은 레이어에 있다고 볼 수 있으며, 마치 화면을 위에서 내려다보는 것처럼 생각할 수 있습니다.
쌓임 맥락은 어떻게 만들어질까요?
쌓임 맥락은
position
속성이absolute
,relative
,fixed
,sticky
중 하나일 때 생성됩니다. 하지만 이 외에도 여러 조건들이 존재하므로, 쌓임 맥락에 대해 더 깊이 알고 싶다면 MDN 문서를 참고하는 것이 좋습니다.
실제 웹 페이지는 수많은 HTML 요소들이 계층 구조로 얽혀 있어, 쌓임 맥락이 어디서 시작되는지 또는 어떤 구조로 형성되어 있는지를 파악하기 어려운 경우가 많습니다. 이러한 구조가 복잡해질수록 의도하지 않은 레이어 순서 문제로 인해 UI 이슈가 발생할 수 있습니다.
쌓임 맥락 레이어 높이 값은 어떻게 결정되나요?
쌓임 맥락의 순서는 HTML 문서에서 요소가 등장하는 위치와
z-index
값에 따라 결정됩니다. 일반적으로 문서 내에서 더 뒤에 정의된 요소일수록 위에 보이고,z-index
값이 높을수록 상위 레이어로 표시됩니다.
모달 뿐 아니라, 화면 상에는 쌓임 맥락이 필요한 요소들이 많이 있습니다. 예를 들면 화면이 스크롤 되어도 최상단에 항상 고정되어 있는 헤더, 하단에 고정되는 액션 바, 사용자 프로필 아바타 옆에 따라다니는 연필(수정) 버튼 등.
혼돈의 쌓임 맥락을 만나다.
월부닷컴에서는 쌓임 맥락이 어떻게 구현되어 있었을까요? 사실 제가 처음 입사했을 당시 레거시 코드 베이스에는 쌓임 맥락 문제가 많았습니다. 코드베이스 내에 수백개의 z-index
를 가진 요소들이 있었고 각 개발자들이 생각하는 적정 z-index의 값은 서로 차이가 있었습니다. 말 그대로 제각각의 z-index
를 가지고 있었는데, 어떤 것은 1001
이고 어떤 것은 9999
, 또 어떤 것은 20
이었습니다.
그뿐만이 아니었습니다. z-index
가 필요하지 않은 요소들에도 z-index
속성이 들어가 있기도 했습니다. 그리고 position
속성이 필요 없는 요소들에도 position
속성이 들어가 있기도 했습니다. 그렇다면 이러한 코드 베이스의 문제들은 실제로 화면에 어떤 문제를 만들었을까요?
여러 모달이 겹쳐지는 가상의 상황 하나를 예시로 소개합니다.
A 스쿼드의 프론트엔드 엔지니어인 Lux 씨는 내가 작성한 글의 리스트를 보여주는 사이드픽을 개발했습니다. 사이드픽 내에 있는 리스트 아이템 중 하나를 누르면 내용을 수정할 수 있는 다이얼로그가 열립니다. 다이얼로그 내에서 수정 후 '제출' 버튼을 누르면 정말 수정하겠냐는 컨펌 창이 열립니다.
여기서 등장한 모달의 종류는 사이드픽, 다이얼로그, 컨펌창 이렇게 3가지 입니다. 각각 모달은 얼만큼의 z-index 를 가져야할까요? 위 상황에서는 사이드픽이 10, 다이얼로그가 20, 컨펌창이 30 정도면 충분할 것 같군요.
A 스쿼드의 Lux 씨가 받은 요구사항
며칠 뒤, B 스쿼드에서는 다이얼로그에 유저 리스트를 뿌려주고 '상세보기' 버튼을 눌렀을 때 사이드픽에 유저 정보를 보여달라는 요구사항을 받게 됩니다.
B 스쿼드의 프론트엔드 엔지니어인 Tyranno 씨는 사이드픽이 10
, 다이얼로그가 20
으로 되어있는 z-index
를 보고 깊은 생각에 잠기게 됩니다. Tyranno 씨가 구현해야 하는 사이드픽의 순서는 이미 구현되어있는 z-index
를 정반대로 사용해야 했기 때문입니다. 결국 고민 끝에 z-index: 30
짜리 사이드픽을 새로 만들게 됩니다.
그리고 며칠 뒤, Tyranno 씨는 다이얼로그(z-index: 30
)에 가려져 컨펌창(z-index: 30
)이 노출되지 않는다는 버그 리포트를 받게 됩니다.
B 스쿼드의 Tyranno 씨가 받은 요구사항
위 사례는 크게 세 가지 문제를 가지고 있습니다.
- 서로 다른
z-index
를 가진 같은 형태의 모달이 계속 추가돼야 한다. - 코드베이스 내에서 서로 거리와 깊이가 먼 두 개 이상의 모달간의
z-index
관계에 대한 예측이 어렵다. - 너무 다양해진
z-index
값으로 인해 관리가 어렵다.
심지어, 기존의 사이드픽의 z-index
가 10
이라는 것을 Tyranno 씨는 알고 있었지만, 서비스가 복잡해지면 각 모달의 z-index 를 모두 파악하고 있는 것은 불가능에 가깝습니다.
이러한 상황은 어떻게 해결될 수 있을까요? z-index: 40
을 가진 컨펌 창 하나를 또 추가 해야 할까요? 그것 보다는 사용처마다 z-index
를 직접 넣어주면 해결이 될까요? 그것 마저도 관리하기가 쉽지는 않아 보이는군요.
사실 문제는 모달 뿐이 아닙니다. 어떤 페이지에서는 헤더가 모달의 Dim 영역보다 상위 레이어로 올라가는 경우도 있었고, 드롭다운 내의 텍스트들이 모달 레이어보다 상위 레이어로 올라간다던가 하는 예상치 못한 이슈가 자주 발생했었습니다.
이대로 가다가는 정말 관리가 불가능한 수준까지 가게 될까봐 걱정이 되었습니다. 아니, 사실 이미 그 상황에 놓여 있었습니다! 이미 혼돈의 쌓임 맥락이 많이 쌓여버린 레거시 코드를 어떻게 해결할 수 있을지 고민하던 중, 희망의 순간이 오게 되었습니다.
희망을 보다.
제가 처음 입사할 때에는 프로젝트가 vue 로 개발되어 있었고, 리액트 전환에 대한 이야기는 계속 있었습니다. 빠르게 제품을 개발하고 있는 현재 상황에서 리액트 전환 작업을 언제 시작할 수 있을까 하는 생각은 항상 가지고 있었지만, 입사 후 1년이 되기 전 마침내 리액트 전환 작업을 추진하게 되었습니다.
저는 리액트 전환 시점이 월부닷컴의 쌓임 맥락을 정리할 수 있는 절호의 기회라고 생각했습니다. 리액트 전환을 시작하기 전, 쌓임 맥락과 관련하여 앞으로 어떻게 관리하는게 좋을지에 대해 사내 테크톡 발표를 준비했고, 동료들과 지식을 나누었습니다.
결론적으로 월급쟁이부자들 프론트엔드 챕터는 성공적으로 리액트 전환 작업을 마쳤고, 덕분에 성공적으로 쌓임 맥락을 정리할 수 있었습니다. 본격적으로 쌓임 맥락 문제를 어떻게 해결했는지에 대해서 이야기해보겠습니다.
월부닷컴의 새로운 쌓임 맥락 전략
너무 다양한 제각각의 z-index
를 해결하기 위해 새로운 쌓임 맥락 전략으로 두 가지 원칙을 세웠습니다.
- z축 레이어는
z-index
의 수치가 아닌 HTML 문서에 등장하는 순서의 영향을 받아 결정되어야 한다. - 쌓임 맥락을 확실하게 격리하여 자식들 간의
z-index
영향이 없게 한다.
위 원칙을 진행하기 위해 Radix UI 라이브러리(이하 Radix) 도움을 받을 수 있었습니다. Radix의 Portal 이란 컴포넌트는 기본적으로 모달이 열리는 시점에 모달 요소를 body 요소의 가장 하단에 추가(append) 해줍니다. 이러한 로직은 쌓임 맥락을 정리하는데 매우 중요한 역할을 해줍니다.
위에서 언급한 두 가지 원칙을 차례대로 알아보겠습니다.
첫 번째 원칙을 지키려면 한 가지 알아야 될 게 있습니다. 일반적인 상황에서 모달은 사용자 인터렉션의 시간적 순서에 따라 쌓이게 된다는 사실입니다.
위에서 들었던 A 스쿼드의 Lux 씨와 B 스쿼드의 Tyrano 씨의 예시를 다시 한 번 생각해보겠습니다. 사이드픽, 다이얼로그, 컨펌창이 z-index 숫자가 아닌 사용자가 인터렉션한 순서로 쌓이게 된다면 어떨까요?
'사이드픽이 더 높냐 다이얼로그가 더 높냐' 가 아니라, '사용자가 어떤 모달을 먼저 열었느냐'에 따라 레이어 높이가 결정되기 때문에 사이드픽을 먼저 열고 다이얼로그를 열었다면 다이얼로그가 상위 레이어로 보여질 것이고, 반대로는 사이드픽이 더 상위 레이어에 위치하게 됩니다.
이게 어떻게 가능할 지 예상이 가시나요? 맞습니다. 위에 나왔던 내용들에서 정답을 찾을 수 있습니다. 높이는 z-index
숫자 뿐 아니라 HTML 문서에 등장하는 순서에 영향을 받게 되는데요, 위에서 설명했듯이 Radix 의 Portal을 사용한 모달 요소들은 기본적으로 <body>
요소 내 가장 하단에 순서대로 쌓이게 됩니다. 그리고 HTML 문서 상 뒤에 위치한 쌓임 맥락을 가진 요소들은 더 높은 레이어를 가지게 됩니다. 이 때 모달들은 모두 같은 z-index
값을 가지고 있어야 합니다. 그래서 우리는 모달에서는 z-index
를 아예 사용하지 않기로 했습니다. (z-index
를 아예 사용하지 않는 방법은 또 다른 케이스의 문제를 마주하게 했는데, 뒤에서 소개하겠습니다)
이 방법을 이용하게 되면 자연스럽게 사용자가 인터렉션 한 순서대로 <body>
요소에 append 되고 자연스럽게 시간적으로 나중에 띄워진 모달이 더 높은 레이어를 가지게 됩니다.
월부닷컴을 vue로 개발하던 당시 우리는 prime-vue 라는 UI 라이브러리를 사용하고 있었습니다. prime-vue 는 애석하게도 모달 요소가 처음 렌더링 되는 시점에 body 요소의 하위 요소로 추가되어 그 위치가 세션이 끝날 때까지 바뀌지 않았습니다. 그래서 사용자의 인터렉션 시간적 순서에 따라 쌓임 맥락을 제어하기가 어려웠습니다.
그리고 두 번째 원칙인 "쌓임 맥락을 확실하게 격리하여 자식들 간의 z-index
영향이 없게 한다." 에 대해서 이야기 해보겠습니다. 쌓임 맥락을 격리(isolate)한다는게 어떤 말일까요? 자식 요소들 간에 서로 z-index 영향이 없게 한다는 말입니다. 자식들간 z-index
차이로 인해 예상치 못한 쌓임 맥락 이슈를 마주하게 되는 경우가 종종 있습니다
사실 쌓임 맥락이 격리되는 조건은 간단합니다. 쌓임 맥락은 생성되는 순간 격리됩니다. 하위에서 만들어지는 쌓임 맥락은 가장 상위 쌓임 맥락을 따릅니다.
보통은 쌓임 맥락이 어디서 어떻게 생성되고 있는지 관리가 잘 되지 않아 생기는 문제들이 많습니다. 특히 서비스가 커지면서 DOM 트리가 복잡해지고, 쌓임 맥락 구조도 복잡하게 형성이 되면 해결하기 어려워질 수 있습니다. 아래 간단한 예시를 준비했습니다.
위 그림은 아래와 같은 특징을 가지고 있습니다.
- 모든 모달 요소들 (Dialog, SidePeek, Overlay)은 header 를 가지고 있다.
- 모든 header 의
z-index
는20
이다. - 모든 모달 요소들 (Dialog, SidePeek, Overlay)의
z-index
는10
이다.
위 그림 예시를 보면, app 엘리먼트 내에 있는 header(빨간 박스로 표시)가, Dialog와 SidePeek보다 상위 레이어에 위치하여 Dialog와 SidePeek 요소를 침범하고 있습니다. 아마도 디자이너의 요구사항은 아니었을것입니다. 그리고 Dialog 내에 있는 header 는 SidePeek 요소들을 침범하지 않고 있습니다. 왜 이런 현상이 발생한 것일까요?
Dialog, SidePeek, Overlay 컴포넌트는 position: fixed
속성을 가지고 있기 때문에 각각 쌓임 맥락을 생성하고 있습니다. 쌓임 맥락은 생성되는 순간 격리되므로 하위 요소들은 가장 최상위의 쌓임 맥락의 레이어를 따릅니다.
즉, Dialog 내에 있는 header 의 z-index
가 SidePeek 또는 Overlay 의 z-index
보다 높다고 하더라도 가장 최상위 쌓임 맥락인 Dialog 의 레이어를 따르기 때문에 SidePeek 또는 Overlay 레이어를 침범하지 않습니다.
반면, app 엘리먼트 내에 있는 header 는 어떨까요? 상위 요소들 중 쌓임 맥락을 형성하고 있는 요소가 없고 header 요소로부터 쌓임 맥락이 생성됩니다. 따라서 z-index: 20
을 가진 header 요소는, z-index: 10
을 가진 Dialog, SidePeek, Overlay 요소들보다 높은 레이어에 위치하게 된 것입니다.
이 것을 해결하는 방법은, app 엘리먼트에도 쌓임 맥락을 생성해주는 것입니다.
스타일 변화 없이 쌓임 맥락이 생성하는 방법 중 하나는 isolation: isolate
속성을 추가하는 것입니다.
position: relative
속성을 추가하는 것도 스타일 변화 없이 쌓임 맥락을 생성하는 좋은 방법입니다. 단,position: relative
속성과는 다르게,isolation: isolate
속성만을 가진 요소는z-index
속성을 추가할 수 없습니다. 단순히 쌓임 맥락을 생성하기 위한 목적이라면isolation: isolate
속성을 사용하는게 어울리는 것 같습니다.
위 그림을 보면 app 엘리먼트의 header 가 본래 의도대로 가장 하단에 위치하게 되었습니다. app 엘리먼트는 가장 최상위 쌓임 맥락이 되었고 하위 요소들은 app 엘리먼트의 쌓임 맥락을 따르게 되었습니다.
이제 app 엘리먼트 내에서는 z-index
값을 100으로 설정하든, 9999 로 설정하든 관계 없이 다른 Dialog, SidePeek, Overlay 요소들을 침범하지 못합니다.
저희가 정의한 두 가지 원칙을 기반으로, 최상위 쌓임 맥락만 잘 트래킹한다면 큰 문제 없이 서비스를 운영할 수 있을겁니다. 최상위 쌓임 맥락이란 즉 body 의 바로 한 단계 하위에 존재하는 app 엘리먼트, 각종 모달 요소들에서 생성되는 쌓임 맥락을 말합니다. 가장 윗 단에서 잘 분리시켜놔야 하위 요소들끼리 침범하는 일이 없습니다.
마치며
이렇게 두 가지 원칙에 따라 쌓임 맥락 개선 작업은 성공적으로 마무리 되었습니다. 이 후, 쌓임 맥락 관련 이슈는 눈에 띄게 줄어들었고, 개발자들이 더 이상 z-index
속성에 얼만큼의 값을 넣어야 하는지에 대한 스트레스도 받지 않게 되었습니다. 아주 깊은 DOM 트리에 존재하는 하위 요소들이 아무리 제멋대로의 z-index 를 가지고 있어도 안심할 수 있게 되었습니다.
물론 이슈가 아예 없었던 것은 아닙니다. 쌓임 맥락 전략이 정리된 후에, 타이밍에 따라 레이어 순서가 꼬이는 이슈를 마주하게 되었었는데요. 이 이슈는 하단에 부록으로 준비해봤습니다. 재미있게 읽어주세요!
월급쟁이부자들 개발 챕터에서는 다양한 기술적 문제들을 마주하고 있고 신뢰와 존중이 담긴 커뮤니케이션을 통해 힘을 합쳐 문제를 해결해나가고 있습니다. 열정적으로 일하고 배우고 성장하는 월급쟁이부자들 개발 챕터에서 여정을 함께 할 동료를 찾고 있습니다. 많은 관심 부탁드려요! 긴 글 읽어주셔서 감사합니다.
(부록) 타이밍 이슈
모든게 완벽할 줄 알았던 그 때, 우리는 또 다른 문제를 마주하게 되었습니다. 모든 모달이 사용자 인터렉션에 따라서만 열리지는 않는다는 것이었습니다.
페이지 최초 진입시 한 페이지 내에서 곧바로 띄워져야 하는 툴팁과 다이얼로그가 있었습니다. 렌더 타이밍이 항상 일정하지는 않은 문제가 있어서, 어떤 때는 제대로 나왔지만 어떤 때는 다이얼로그보다 툴팁이 더 상위 레이어에 배치가 되어 UI 가 어색해지는 이슈가 있었습니다.
툴팁보다 다이얼로그가 더 상위 레이어에 보여져야 한다.
그 둘은 서로 코드베이스상 멀리 존재하고 있었기 때문에 타이밍을 원하는 대로 조절하는게 쉽지 않았습니다. setTimeout()
등을 사용할 수도 있었겠지만, 근본적인 해결 방법은 아니었습니다.
이러한 타이밍 이슈를 해결하는 방법은 여러 가지가 있을텐데, 우리는 아래와 같은 두 가지 방법을 고민했습니다.
- 항상 띄워져 있는 툴팁(또는 모달 요소)에서는 Portal 을 사용하지 않는다.
- 사용처에서
z-index
를 오버라이드한다.
첫 번째 방법인 특정 사용처에서 Portal 요소를 사용하지 않도록 하는 방법을 결정하지 않은 이유는, 우리는 모달 요소들에는 모두 Portal 을 사용하기로 했었고 Portal 을 사용하지 않은 모달 요소가 또 다른 모달 요소와의 레이어 충돌이 발생한다면 똑같은 문제가 또 발생할 수 있을거라고 판단했습니다.
사용처에서 z-index
를 오버라이드 하는 방법도 바로 적용하기엔 문제가 있었습니다. 위에서 잠깐 이야기했는데, 모든 모달의 z-index
를 사용하지 않기로 했기 때문에 z-index
는 기본값(auto)이었고, 툴팁을 모달보다 하위 레이어에 위치 시키려면 z-index
는 음수로 설정해야 했습니다. z-index
를 음수로 설정하게 되면 app 엘리먼트보다 하위 레이어로 내려가게 되므로, 좋은 방법이 아니라고 판단했고 결국 모든 모달 요소의 z-index
를 설정하기로 했습니다. 단, 레이어 순서가 HTML 문서에 등장하는 순서의 영향을 받아야 한다는 원칙을 지키기 위해 모든 모달 요소는 같은 값의 z-index
를 가집니다.
그리고 위 사례와 같은 특별한 상황에서만 다른 모달들보다 낮은 z-index
값으로 설정하여 문제를 해결했습니다.