키오스쿨 서비스 관련한 사용자 의견 인터뷰 중 갑작스런 버그를 발견했다.(가장 민망한 순간…) 다행히 새로고침을 하니 해당 버그는 사라진 듯 보여서 어찌어찌 넘어갔다.
어떤 버그였냐면, 실시간 주문 조회에서 주문 상세보기 모달을 열고, 주문의 상태를 변화(ex: 주문완료 → 결제완료) 버튼을 클릭하면 화면 스크롤이 막히는 버그였다.
모달이 닫힌 후에도 body에
overflow: hidden속성이 적용되어 스크롤이 안되고 있었다.왜 이런 일이 발생했을까?
우선 modal을 렌더링하는 OrderDetailModalButton 컴포넌트를 살펴보자.
function OrderDetailModalButton({ order }: Props) { const { isModalOpen, openModal, closeModal, modalKey } = useModal(); if (!isModalOpen) { return <RightIcon onClick={openModal} />; } return createPortal( <> <ModalOverlay onClick={closeModal} /> <ModalContainer> <OrderModalHeaderContents onClose={closeModal} order={order} /> <OrderModalMainContents order={order} /> <OrderModalFooterContents orderStatus={order.status} id={order.id} /> </ModalContainer> </>, document.getElementById(modalKey) as HTMLElement, ); } export default OrderDetailModalButton;
useModal 커스텀 훅을 사용하여 상태에 따라 모달을 보여주고 있다. 이제 useModal 훅을 들여다보자.
function useModal() { const modalKey = 'modal-root'; const [isModalOpen, setModalOpen] = useState(false); const openModal = () => { setModalOpen(true); // 모달창 open 시 배경 스크롤 금지 코드 document.body.style.overflow = 'hidden'; }; const closeModal = () => { setModalOpen(false); // 모달창 open 시 배경 스크롤 금지 해제 코드 document.body.style.overflow = 'auto'; }; return { isModalOpen, openModal, closeModal, modalKey }; } export default useModal;
잡았다 요놈! openModal 핸들러에서 body에 overflow: hidden을 통해 스크롤을 금지하고, closeModal에서는 overflow: auto로 스크롤 금지를 해제하고 있다.
즉, overflow: hidden이 여전히 남아있다는 건, closeModal이 정상적으로 호출되지 않고 있다는 이야기이다.
그래서 모달에서 주문의 상태를 바꾸는 버튼을 확인해보았더니, 아뿔싸! closeModal을 호출하는 걸 빼먹고 있었다. . . closeModal을 추가하여 해당 버그를 고칠 수 있었다.
이슈가 여기서 잘 해결되었다면, 이 글을 쓰지 않았을 것이다…
모달을 트리거하는 범위를 확장시켜 주세요
추가적으로, 모달이 열리도록 하는 클릭하는 범위를 ‘>’ 아이콘에서 OrderCard컴포넌트 주요 컨텐츠로 넓혀달라는 요구사항이 들어왔다.


그래서 나는 useModal 훅을 OrderDetailModalButton의 부모 컴포넌트인 OrderCard에서 호출하였고, OrderDetailModalButton에는 props로 모달 상태 및 closeModal 함수들을 넘겨주는 방식으로 바꿨다.
function OrderCard({ order }: OrderCardProps) { // 주문 카드 컴포넌트에서 useModal을 통한 모달 상태관리 const { isModalOpen, openModal, closeModal } = useModal(); // OrderInfoContainer(확장된 영역) 클릭 시 모달 오픈 const orderInfoClickHandler = () => { openModal(); }; return ( <> <CardContainer> <OrderInfoContainer onClick={orderInfoClickHandler}> {. . .} {/* OrderDetailModalButton -> OrderDetailModal로 이름 변경 */} <OrderDetailModal order={order} isModalOpen={isModalOpen} closeModal={closeModal} /> {. . .} </OrderInfoContainer> {. . .} </CardContainer> </> ); }
그러나 한번 열린 모달이 닫히질 않았다…
버블링이 원인?!
분명히 OrderDetailModal 컴포넌트에서 closeModal을 올바르게 호출하고 있음을 확인했다. 그러나 모달은 닫히지 않은 체 그대로였다. 의심이 가는 곳은 orderInfoClickHandler 였다. 이벤트 버블링이 발생하고 있다면, 해당 핸들러로 인해 openModal이 호출되었을 것이라고 추측했다. orderInfoClickHandler에 콘솔 출력문을 추가해서 버블링이 일어나는지 검사 해보았다.
아니 이럴수가!!!!! 버블링이 발생하여 모달을 닫았음에도 불구하고 orderInfoClickHandler로 인해 openModal이 호출되어 모달이 계속 열리는 것을 확인할 수 있었다.
createPortal로 버블링이 안생기는 구조가 아닌가??
createPortal을 통해 root노드와 자매의 위치에 모달 루트를 생성하여 모달을 보여주고 있었다.

function OrderDetailModal({ order, isModalOpen, closeModal }: Props) { // . . . return createPortal( <> <ModalOverlay onClick={closeModal} /> <ModalContainer> { . . . } </ModalContainer> </>, document.getElementById(modalKey) as HTMLElement, ); } export default OrderDetailModal;

DOM Node 역시 OrderCard 컴포넌트와는 영향을 전혀 받지 않는 위치이기에, 별도의 버블링에 대한 처리를 안해줘도 된다고 생각했다.
React에서는 컴포넌트 트리 기준으로 생각해야한다.

비록 실제 DOM상에서는 OrderCard와 OrderDetailModal이 서로 다른 위치에 렌더링되더라도, React의 Synthetic Event 시스템은 루트에 단일 네이티브 이벤트 리스너를 걸고, 컴포넌트 트리(가상 DOM 트리)를 기준으로 이벤트 버블링을 시뮬레이션한다.
따라서 createPortal로 실제 DOM 위치가 바뀌어도, 컴포넌트 트리 상의 부모, 자식 관계에 따라 이벤트가 버블링된다.
해결!
총 3가지의 방법을 생각해보았다.
방법1
OrderCard 컴포넌트의 orderInfoClickHandler를 다음과 같이 고친다.
// 모달이 닫혀있는 상태에만 모달 오픈 const orderInfoClickHandler = () => { if(!isModalOpen) openModal(); };
OrderDetailModal에서 closeModal이 호출되어 내부적으로 setIsModalOpen(false)가 스케쥴링 되고, 아직 isModalOpen은 true인 상태로 orderInfoClickHandler가 호출이 된다. 현재 isModalOpen은 true이므로 openModal이 실행되지 않는다. 이후 스케쥴링이 되었던 setIsModalOpen(false)가 실행되고, 모달이 닫히게 된다. 위 방법은 버블링 문제를 근본적으로 해결하고 있지 않다.
방법2
OrderDetailModal 컴포넌트의 위치를 OrderInfoContainer와 동등하거나 그 이상인 위치로 이동
function OrderCard({ order }: OrderCardProps) { return ( <> <CardContainer> <OrderInfoContainer onClick={orderInfoClickHandler}> {. . .} </OrderInfoContainer> {/* 컴포넌트 구조 상 더이상 자식이 아니므로 OrderInfoContainer에 대한 버블링 안생김 */} <OrderDetailModal order={order} isModalOpen={isModalOpen} closeModal={closeModal} /> {. . .} </CardContainer> </> ); }
방법3
OrderDetailModal에서 e.stopPropagation 명시적으로 호출
function OrderDetailModal({ order, isModalOpen, closeModal }: Props) { const { modalKey } = useModal(); if (!isModalOpen) { return null; } const closeModalHandler = (e: React.MouseEvent<HTMLDivElement, MouseEvent>) => { e.stopPropagation(); closeModal(); }; return createPortal( <> <ModalOverlay onClick={closeModalHandler} /> <ModalContainer> <OrderModalHeaderContents onClose={closeModalHandler} order={order} /> <OrderModalMainContents order={order} /> <OrderModalFooterContents orderStatus={order.status} id={order.id} closeModal={closeModal} /> </ModalContainer> </>, document.getElementById(modalKey) as HTMLElement, ); }
기존 코드를 크게 바꿀 필요가 없으며, 버블링 문제도 해결해줄 수 있다고 판단했기 때문에 ‘방법2’로 적용했다.
참고 블로그

hmos.dev
React의 이벤트 전파는 Javascript와 다르다
🌐 한눈에 이해하는 이벤트 흐름 제어 (버블링 & 캡처링)
HTML 이벤트의 흐름 HTML 문서의 각 엘리먼트들은 아래와 같이 태그 안의 태그가 위치하는 식으로 계층적으로 이루어짐을 볼 수 있다. 이러한 계층적 구조 특징 때문에 만일 HTML 요소에 이벤트가 발생할 경우 연쇄적 이벤트 흐름이 일어나게 된다. 예를들어 아래 3개가 중첩된 박스 영역에서 가장 자신 엘리먼트인 p 박스를 클릭하면 onclick 이벤트 스크립트가 p 뿐만 아니라 그의 부모인 div와 form 엘리먼트도 발생함을 볼 수 있다. FORM DIV P See the Pen event bubbleing 1 by barzz12 (@inpaSkyrim) on CodePen. 이러한 현상을 이벤트 전파(Event Propagation)라 부르며, 전파 방향에 따라 버블링과 캡처링으로 구분한다. 버블링..