🫡

주문 상세 모달 스크롤 버그와 이벤트 버블링

Kioschool

2025-04-17

키오스쿨 서비스 관련한 사용자 의견 인터뷰 중 갑작스런 버그를 발견했다.(가장 민망한 순간…) 다행히 새로고침을 하니 해당 버그는 사라진 듯 보여서 어찌어찌 넘어갔다. 어떤 버그였냐면, 실시간 주문 조회에서 주문 상세보기 모달을 열고, 주문의 상태를 변화(ex: 주문완료 → 결제완료) 버튼을 클릭하면 화면 스크롤이 막히는 버그였다.

모달이 닫힌 후에도 body에 overflow: hidden 속성이 적용되어 스크롤이 안되고 있었다.

왜 이런 일이 발생했을까?

우선 modal을 렌더링하는 OrderDetailModalButton 컴포넌트를 살펴보자. useModal훅에서 모달 관련 함수 및 변수들을 받아와서 사용하고 있다.

OrderDetailModalButton
1function OrderDetailModalButton({ order }: Props) {
2 const { isModalOpen, openModal, closeModal, modalKey } = useModal();
3
4 if (!isModalOpen) {
5 return <RightIcon onClick={openModal} />;
6 }
7
8 return createPortal(
9 <>
10 <ModalOverlay onClick={closeModal} />
11 <ModalContainer>
12 <OrderModalHeaderContents onClose={closeModal} order={order} />
13 <OrderModalMainContents order={order} />
14 <OrderModalFooterContents orderStatus={order.status} id={order.id} />
15 </ModalContainer>
16 </>,
17 document.getElementById(modalKey) as HTMLElement
18 );
19}
20
21export default OrderDetailModalButton;

이제 useModal 훅을 살펴보자. openModal에서 overflow: hidden을 주고, closeModal에서 overflow: auto를 주고 있다. 스크롤이 잠긴다는 건, closeModal이 호출되지 않았다는 의미다. 확인해보니, 주문 상태 변경 버튼에 closeModal 호출이 빠져 있었다. 이를 추가하니 버그는 해결이 됐다!

useModalHook
1function useModal() {
2 const modalKey = "modal-root";
3 const [isModalOpen, setModalOpen] = useState(false);
4
5 const openModal = () => {
6 setModalOpen(true);
7 // 모달창 open 시 배경 스크롤 금지 코드
8 document.body.style.overflow = "hidden";
9 };
10
11 const closeModal = () => {
12 setModalOpen(false);
13 // 모달창 open 시 배경 스크롤 금지 해제 코드
14 document.body.style.overflow = "auto";
15 };
16
17 return { isModalOpen, openModal, closeModal, modalKey };
18}
19
20export default useModal;

모달을 트리거하는 범위를 확장해 달라는 요청

그러나 여기서 끝이 아니었다. 추가적으로 ‘>’ 아이콘에서 OrderCard 컴포넌트 주요 영역 전체로 클릭 범위를 넓여달라는 요구가 있었다.

ASIS,TOBE주문카드

OrderDetailModalButton 대신 부모 컴포넌트인 OrderCard에서 useModal을 호출하고, props로 state와 closeModal을 넘겨주는 구조로 변경했다.

OrderCardComponent
1function OrderCard({ order }: OrderCardProps) {
2 const { isModalOpen, openModal, closeModal } = useModal();
3
4 const orderInfoClickHandler = () => {
5 openModal();
6 };
7
8 return (
9 <CardContainer>
10 <OrderInfoContainer onClick={orderInfoClickHandler}>
11 { /* 주문 카드 주요 내용 */ }
12 <OrderDetailModal
13 order={order}
14 isModalOpen={isModalOpen}
15 closeModal={closeModal}
16 />
17 </OrderInfoContainer>
18 </CardContainer>
19 );
20}

하지만, 모달을 닫아도 다시 열리는 현상이 발생했다.

버블링이 원인?!

OrderDetailModal에서 closeModal이 정상 호출됨에도, 부모의 orderInfoClickHandler 이벤트가 버블링되어 다시 openModal이 호출되고 있었다.

createPortal은 버블링을 막아주지 않는다

나는 createPortal로 다음과 같이 OrderCardOrderDetailModal이 분리되어 렌더링 되기 때문에, 버블링이 발생하지 않을 것이라 생각했다.

createPortal구조

그러나 React의 Synthetic Event 시스템은 컴포넌트 트리를 기준으로 버블링을 시뮬레이션한다. 따라서 createPortal로 실제 위치가 분리돼도, 트리 상 부모-자식 관계에 따라 이벤트는 버블링된다.

DOM구조

3가지 해결 방법

방법1. 조건부 열기

OrderInfoClickHandler
1const orderInfoClickHandler = () => {
2 // 모달이 닫혀 있을 때만 열기
3 if (!isModalOpen) openModal();
4};

OrderDetailModal에서 closeModal이 호출되어 내부적으로 setIsModalOpen(false)가 스케쥴링 되고, 아직 isModalOpentrue인 상태로 orderInfoClickHandler가 호출이 된다. 현재 isModalOpentrue이므로 openModal이 실행되지 않는다. 이후 스케쥴링이 되었던 setIsModalOpen(false)가 실행되고, 모달이 닫히게 된다. 해당 방법은 모달이 닫히지 않는 문제는 해결하지만, 버블링 문제를 근본적으로 해결하고 있지 않다.

방법2. 컴포넌트 구조 변경 ✅

OrderDetailModal
1// OrderDetailModal을 OrderInfoContainer 바깥에 렌더링
2<CardContainer>
3 <OrderInfoContainer onClick={orderInfoClickHandler}></OrderInfoContainer>
4 <OrderDetailModal … />
5</CardContainer>

OrderDetailModal 컴포넌트의 위치를 OrderInfoContainer와 동등하거나 그 이상인 위치로 이동시킴으로써 버블링이 일어나지 않도록 한다.

방법3. stopPropagation

StopPropagation
1function OrderDetailModal({ order, isModalOpen, closeModal }: Props) {
2 if (!isModalOpen) return null;
3
4 const closeModalHandler = (e: React.MouseEvent) => {
5 e.stopPropagation();
6 closeModal();
7 };
8
9 return createPortal(
10 <>
11 <ModalOverlay onClick={closeModalHandler} />
12 <ModalContainer></ModalContainer>
13 </>,
14 document.getElementById("modal-root")!
15 );
16}

OrderDetailModal에서 e.stopPropagation을 명시적으로 호출한다.

위 3가지 중 방법2를 적용해 간결하게 문제를 해결했다.

참고 블로그:

© castle_bell · All rights reserved