← 전체 글로 돌아가기

Next.js

React 모달을 포털로 옮기고 스크롤 잠금을 다시 설계한 회고

z-index만 올리던 모달 문제를 포털과 body 스크롤 잠금으로 정리한 프로젝트 회고.

z-index 9999가 해결해 주지 않은 것

상품 상세 화면 위에 모달을 띄웠는데, 특정 화면에서는 헤더 아래로 숨어 버렸다. 숫자를 계속 키우다가 부모 요소의 transform이 새 stacking context를 만든다는 사실을 뒤늦게 확인했다. 동시에 모달을 열고 배경을 스크롤하면 화면이 덜컥 움직이는 문제도 있었다.

화면 밖의 부모에서 렌더링했다

모달은 페이지 컴포넌트 안에 두지 않고 document.body 아래의 전용 노드로 보냈다. SSR에서는 document가 없으므로 마운트 뒤에만 포털을 만든 점이 가장 헷갈렸다.

import { createPortal } from 'react-dom';
import { useEffect, useState } from 'react';

export function Modal({ children }: { children: React.ReactNode }) {
  const [mounted, setMounted] = useState(false);
  useEffect(() => setMounted(true), []);
  if (!mounted) return null;

  return createPortal(
    <div className="modal-backdrop" role="presentation">{children}</div>,
    document.body
  );
}

스크롤바 폭까지 고려한 잠금

처음에는 body.style.overflow = 'hidden'만 넣었다. 그런데 데스크톱에서 스크롤바가 사라진 만큼 콘텐츠가 옆으로 밀렸다. 모달을 여는 순간의 스크롤바 폭을 padding에 더하고, 닫을 때 원래 스타일을 복원했다.

const width = window.innerWidth - document.documentElement.clientWidth;
document.body.style.overflow = 'hidden';
document.body.style.paddingRight = `${width}px`;

// 닫을 때에는 저장해 둔 overflow와 paddingRight를 되돌린다.

실제 서비스에서는 열려 있는 모달 수를 세지 않아, 확인 모달 위에 또 모달을 띄웠을 때 먼저 닫힌 쪽이 스크롤 잠금을 풀어 버린 적도 있었다. 이제는 잠금 함수를 참조 수로 관리한다.

회고

포털은 단순히 레이어를 맨 위에 올리는 도구가 아니었다. DOM 배치, 접근성 포커스, 배경 스크롤까지 함께 설계해야 했다. 다음 작업에서는 Escape 닫기와 포커스 트랩을 같은 PR의 완료 조건에 넣을 생각이다.