/logo.png

더 이상 isOpen으로 모달을 관리하지 마세요.

더 이상 isOpen으로 모달을 관리하지 마세요.
2024. 12. 12.

모달과 토스트같은 오버레이 UI는 프론트엔드에서 빠질 수 없는 단짝 친구입니다.

UI(사용자 인터페이스) 요소의 맥락에서 오버레이는 애플리케이션의 기본 UI 위에 임시 레이어로 나타나는 그래픽 제어 요소를 말하며, 추가 상황 정보를 제공하거나 현재 화면이나 보기에서 벗어나지 않고도 사용자 상호 작용을 활성화합니다. . 오버레이는 일반적으로 기존 UI 요소 및 레이아웃의 제약 조건 내에서 제공할 수 있는 것보다 더 눈에 띄는 UI 존재가 필요할 수 있는 모달 대화 상자, 도구 설명, 메뉴 및 기타 집중된 상호 작용을 표시하는 데 사용됩니다. 현대 소프트웨어 개발의 중요한 구성 요소인 오버레이는 사용자 경험과 효율성을 향상시키는 동시에 개발자가 응집력 있고 쉽게 유지 관리할 수 있는 애플리케이션 설계를 달성하도록 돕습니다.

https://appmaster.io/ko/glossary/obeorei

오버레이는 사용자에게 가장 중요한 정보를 효과적으로 제공할 수 있습니다. 사용자에게 관련 컨텍스트만 표시하여 인터페이스를 정리하는 데 도움이 될 수 있습니다.

오버레이에는 대표적으로 모달, 토스트, 도구 팁, 사이드 메뉴가 있습니다.

그렇다면, React에서 어떤 방식으로 오버레이를 구현할 수 있을까요?

쉽게 접할 수 있는 React Overlay

React Modal 구현하기와 같은 키워드로 검색을 해보면, 대표적으로 두 가지 방법을 확인할 수 있습니다.

isOpen 상태를 통한 모달 관리

React의 상태를 통해 모달을 사용하는 컴포넌트에서 모달의 렌더링을 제어하는 상태를 선언해 관리하고 모달을 구현합니다.

isOpen과 같은 네이밍으로 모달 on/off를 boolean state로 관리하며, 컴포넌트의 JSX에서 모달의 트리거와 모달의 콘텐츠를 선언합니다.

export const ModalOpenButton = () => {
  const [isOpen, setIsOpen] = useState(false);
 
  return (
    <>
      <div className={'btn-wrapper'}>
        <button className={'modal-trigger'} onClick={() => setIsOpen(true)}>
          모달 열기
        </button>
      </div>
      {isOpen && (
        <div className={'modal-container'}>
          <div className={'modal-content'}>
            <p>리액트로 모달 구현하기</p>
            <button className={'modal-close'} onClick={() => setIsOpen(false)}>
              모달 닫기
            </button>
          </div>
        </div>
      )}
    </>
  );
};
  • shadcn/ui의 Dialog Headless 라이브러리를 사용하다보면 같은 방식으로 모달을 구현하는 것을 알 수 있습니다.
    <Dialog>
      <DialogTrigger>Open</DialogTrigger>
      <DialogContent>
        <DialogHeader>
          <DialogTitle>Are you absolutely sure?</DialogTitle>
          <DialogDescription>
            This action cannot be undone. This will permanently delete your account and remove your data from our servers.
          </DialogDescription>
        </DialogHeader>
      </DialogContent>
    </Dialog>
    Dialog(Context Provider)내부에서 mount 상태를 관리하고 이를 Trigger와 Content에 전달합니다. 이후 Trigger는 모달의 마운트를 제어하고 Content는 Dialog의 구성을 결정합니다.

이 방식은 가장 쉽고 빠르게 모달을 구현할 수 있습니다. 하지만, 사용하는 부모 컴포넌트에 매우 의존적이고 부모 태그 스타일에 영향을 받아 이를 css로 별도 처리해 주어야 합니다.

createPortal을 사용한 모달 관리

리액트 포털을 활용한 모달 구현 방법 - F-LAB

부모 태그의 스타일 영향에서 벗어나기 위해서 createPortal을 사용할 수 있습니다.

이제 컴포넌트의 돔 요소의 스타일과 관계없이 모달은 포탈을 통해 별도의 공간에 렌더링 되므로 모달의 스타일을 독립적으로 관리할 수 있습니다.

import React, { useState } from 'react';
import ReactDOM from 'react-dom';
import './Modal.css'; // 모달 스타일 시트
 
const Modal = ({ isOpen, children }) => {
  if (!isOpen) return null;
 
  return ReactDOM.createPortal(<div className="modal">{children}</div>, document.getElementById('modal-root'));
};
 
const App = () => {
  const [isOpen, setIsOpen] = useState(false);
 
  return (
    <div>
      <button onClick={() => setIsOpen(true)}>Open Modal</button>
      <Modal isOpen={isOpen} onClose={() => setIsOpen(false)}>
        <h1>Modal Title</h1>
        <p>This is a modal content.</p>
        <button onClick={() => setIsOpen(false)}>Close</button>
      </Modal>
    </div>
  );
};
 
export default App;

하지만, 여전히 부모 컴포넌트에서 모달의 상태를 관리하고 모달은 이를 의존할 수 밖에 없습니다.

기존 방식들의 문제점

앞선 방식들에 공통적인 문제점은 두 가지가 있습니다.

  • 모달을 사용하는 사용처에서 모달에 상태(mount, unmount)를 관리해야 합니다. 따라서 부모의 마운트 상태와 오버레이는 동기화됩니다.
  • 모달과 관련된 코드가 사용하는 컴포넌트 곳곳에 존재하게 됩니다. 따라서 우리는 컴포넌트의 주요 비즈니스 로직과, UI를 위한 부가적인 로직을 공통 공간에 선언합니다.

모달 상태의 의존성

기존의 코드를 살펴보면, 모달의 상태를 부모에서 선언하고 있음을 확인할 수 있습니다.

따라서 부모의 마운트 상태는 자식으로 선언된 오버레이의 마운트 상태에 영향을 줍니다.

만일 오버레이 렌더링과 함께 내비게이션과 같은 페이지 이동이나, 컴포넌트 unmount 같은 시나리오가 존재한다면, 실제로 오버레이를 사용하는 컴포넌트와 오버레이가 존재해야 하는 컴포넌트의 위치가 달라져 이를 관리하기 어려워집니다.

이는 컴포넌트 간의 의존성을 늘리게 되거나 종속적인 컴포넌트들을 설계하게 될 가능성이 존재합니다.

응집성이 낮아짐

“몸이 멀어지면 마음도 멀어진다.”

코드도 마찬가지입니다.

어떠한 동작을 위해 구현한 코드가 코드 베이스에 흩뿌려져 있으면 코드는 가독성을 잃고 이는 리팩터링을 어렵게 합니다.

기존의 오버레이 코드들은 오버레이를 관리하는 로직과 컴포넌트가 행해야 하는 비즈니스 로직이 서로 섞여 디버깅을 어렵게 합니다.

개발자는 컴포넌트 내부를 보고 서로 멀어진 코드 조각들을 맞춰가야 합니다.

export const ModalOpenButton = () => {
  const [isOpen, setIsOpen] = useState(false);
 
  // 엄청 긴 비즈니스 로직들
  //
  //
  //
  //
 
  return (
    <>
      {/* 여러가지 컴포넌트들 */}
      <div className={'btn-wrapper'}>
        <button className={'modal-trigger'} onClick={() => setIsOpen(true)}>
          모달 열기
        </button>
      </div>
      {/* 여러가지 컴포넌트들 */}
 
      {/* 여러가지 컴포넌트들 */}
 
      {/* 여러가지 컴포넌트들 */}
 
      {isOpen && (
        <div className={'modal-container'}>
          <div className={'modal-content'}>
            <p>리액트로 모달 구현하기</p>
            <button className={'modal-close'} onClick={() => setIsOpen(false)}>
              모달 닫기
            </button>
          </div>
        </div>
      )}
    </>
  );
};

컴포넌트 내부에서 오버레이를 관리하는 상태 선언, 상태 트리거, 마운트시 보여지는 UI에 대한 코드가 멀리 떨어져 있습니다.

그렇다면 어떻게 효과적으로 이를 응집할 수 있을까요?

해결 방법

사실 우리는 그 방법을 이미 알고 사용하고 있습니다.

토스트 라이브러리를 서칭하다 보면, 꽤 많이 사용하고 있는 라이브러리가 있습니다. 두 가지만 소개해 보겠습니다.

  • react-toastify

    import React from 'react';
     
    import { ToastContainer, toast } from 'react-toastify';
    import 'react-toastify/dist/ReactToastify.css';
     
    function App() {
      const notify = () => toast('Wow so easy!');
     
      return (
        <div>
          <button onClick={notify}>Notify!</button>
          <ToastContainer />
        </div>
      );
    }
  • shadcn/ui Toast

    import { useToast } from '@/components/hooks/use-toast';
    import { Button } from '@/components/ui/button';
    import { ToastAction } from '@/components/ui/toast';
     
    export function ToastDemo() {
      const { toast } = useToast();
     
      return (
        <Button
          variant="outline"
          onClick={() => {
            toast({
              title: 'Scheduled: Catch up ',
              description: 'Friday, February 10, 2023 at 5:57 PM',
              action: <ToastAction altText="Goto schedule to undo">Undo</ToastAction>
            });
          }}
        >
          Add to calendar
        </Button>
      );
    }

두 라이브러리의 공통점은 toast라는 별도의 함수를 통해서 효과적으로 오버레이에 대한 모든 로직을 격리하고 선언해 사용합니다.

덕분에 개발자는 비즈니스 로직과는 별개로 선언적인 UI 로직을 얻게 되었습니다.

그렇다면, toast가 아닌 다른 모든 오버레이들을 이렇게 함수로 관리할 수 있으면 어떨까요?

open(<Component />)와 같이 컴포넌트를 여는 행위를 함수를 통해 정의하고 이를 닫는 close 함수를 정의할 수 있다면 기존의 문제를 해결할 수 있을 것처럼 보입니다.

이는 마치 setTimeout, setInterval함수를 사용하는 플로우와 유사합니다.

const id = setTimeout(() => {
  // doing
}, 1000);
 
clearTimeout(id);
 
const id = setInterval(() => {
  // doing
}, 1000);
 
clearInterval(id);

useOverlay

overlay-kit

이미 이러한 고민을 해본 팀이 존재합니다. 토스 팀은 응집력 있고 선언적인 오버레이를 위해 overlay-kit을 개발했습니다.

덕분의 타입스크립트 환경에서라면, 효과적으로 오버레이를 선언할 수 있게 되었습니다.

image

공식문서에서 제공하는 코드의 응집력 변화를 봐도 얼마나 효과적으로 개선되었는지 확인할 수 있습니다.

useOverlay v1

이번에 프로젝트를 진행하면서 저는 기존 오버레이의 방식들을 사용하지 않고 응집력 있는 오버레이 구현을 위해 overlay-kit을 사용하기 이전에 토스 팀이 구현한 useOverlay를 참고해 구현했습니다.

저의 초기 오버레이는 복잡한 애니메이션이나 비동기 제어가 필요하지 않다고 판단해, 핵심적인 로직만 추출해 냈습니다.

import { createContext, useContext, useEffect, useMemo, useRef } from 'react';
 
interface OverlayContextValue {
  id?: string;
  mount?: (id: string, overlay: React.ReactNode) => void;
  unmount?: (id: string) => void;
}
 
export const OverlayContext = createContext<OverlayContextValue>({});
 
// Context를 통해 상위에서 마운트 언마운트 함수를 제공한다.
const useOverlayContext = () => {
  const { mount, unmount } = useContext(OverlayContext);
 
  const InvalidContext = !(mount && unmount);
 
  if (InvalidContext) {
    throw new Error('useOverlay는 OverlayProvider 내에서만 사용할 수 있습니다.');
  }
 
  return useMemo(() => ({ mount, unmount }), [mount, unmount]);
};
 
const uniqueId = () => Date.now().toString() + Math.random().toString();
 
const useUniqueIdRef = () => {
  const id = useRef(uniqueId()).current;
  return id;
};
 
// 마운트 언마운트 로직을 오버레이에서 제공한다.
export const useOverlay = ({ isCloseOnUnmount = true }: { isCloseOnUnmount?: boolean } = {}) => {
  const { mount, unmount } = useOverlayContext();
  const id = useUniqueIdRef();
 
  // 부모 컴포넌트와 마운트 상태를 동기화한다면 옵션을 켜두고, 별도로 관리한다면 끈다.
  useEffect(() => {
    return () => {
      if (isCloseOnUnmount) unmount(id);
    };
  }, [id, unmount, isCloseOnUnmount]);
 
  return useMemo(
    () => ({ open: (element: React.ReactNode) => mount(id, element), close: () => unmount(id) }),
    [id, mount, unmount]
  );
};

useOverlay 훅은 Provider에서 제공한 mountunmount 함수를 openclose 함수를 통해 제공합니다.

open 함수는 ReactNode를 인자로 받아 id와 함께 mount 합니다.

import { Fragment, useCallback, useMemo, useState } from 'react';
import { OverlayContext } from './useOverlay';
 
export const OverlayProvider = ({ children }: { children: React.ReactNode }) => {
  const [overlays, setOverlays] = useState<Map<string, React.ReactNode>>(new Map());
 
  const mount = useCallback((id: string, overlay: React.ReactNode) => {
    setOverlays((prev) => {
      const cloned = new Map(prev);
      cloned.set(id, overlay);
      return cloned;
    });
  }, []);
 
  const unmount = useCallback((id: string) => {
    setOverlays((prev) => {
      const newMap = new Map(prev);
      newMap.delete(id);
      return newMap;
    });
  }, []);
 
  const value = useMemo(() => ({ mount, unmount }), [mount, unmount]);
 
  return (
    <OverlayContext.Provider value={value}>
      {children}
      {[...overlays.entries()].map(([id, overlay]) => {
        return <Fragment key={id}>{overlay}</Fragment>;
      })}
    </OverlayContext.Provider>
  );
};

Overlay 훅에서 사용할 mount, unmount 함수는 Provider를 통해 제공합니다.

Provider에서는 각각의 오버레이를 Map을 통해 관리하며 이를 렌더링 합니다.

useOverlay를 통해 삭제하기 버튼 구현

저희 서비스에서는 데이터를 수정하기 위해서 폼을 모달로 띄워야 하는 요구사항이 존재합니다.

export function LotusUpdateButton({ lotusId }: { lotusId: string }) {
  const { open, close } = useOverlay();
 
  const onSubmit = () => {
    // 불필요한 코드는 생략합니다.
  };
 
  const handleOpenUpdateModal = () => {
    open(
      <ModalBox onClose={close}>
        <div className="w-1/2 rounded-lg bg-white p-6">
          <LotusUpdateForm lotusId={lotusId} onSubmit={onSubmit} onCancel={close} />
        </div>
      </ModalBox>
    );
  };
 
  return (
    <Button variant={'default'} onClick={handleOpenUpdateModal}>
      <IoSettingsSharp />
      <Text size="sm">수정하기</Text>
    </Button>
  );
}

useOverlay 덕분에 LotusUpdateButton 컴포넌트는 두 가지 분리된 공간에 분리된 UI를 선언할 수 있습니다.

  • LotusUpdateButton에서 반환된 컴포넌트는 실제 컴포넌트가 보여줄 Button을 구성하는 컴포넌트입니다.
  • open 내부에 선언된 컴포넌트는 open 함수가 호출되었을 때 마운트 할 Overlay를 구성하는 컴포넌트입니다.
스크린샷 2024-11-30 오전 10 51 33스크린샷 2024-11-30 오전 10 51 40

이제 우리는 실제 오버레이를 띄워야 하는 곳에 오버레이를 선언하면서, 실제 컴포넌트의 동작과는 격리할 수 있게 되었습니다.

useToast

useOverlay를 래핑 해 새로운 커스텀 훅을 구현하면, useOverlay의 도움을 받아 여러가지 오버레이 UI에 대해서 일관된 훅을 구현할 수 있습니다.

새로 사용자의 액션에 따라 토스트를 띄워야 하는 요구사항이 생겼습니다. 이를 위해 useOverlay를 활용해 useToast를 구현했습니다.

export const useToast = ({ isCloseOnUnmount = true }: { isCloseOnUnmount?: boolean } = {}) => {
  const { open, close, exit } = useOverlay({ isCloseOnUnmount });
 
  const toast = ({ ...props }: Partial<ToastProps>) => {
    open(({ isOpen }) => <Toast isOpen={isOpen} close={close} {...props} />);
  };
 
  return { toast, close, exit };
};

토스트와 관련된 로직(애니메이션, 타이머, UI)은 Toast 컴포넌트에서 관리하고, 실제 Toast를 mount 하고 unmount 하는 로직은 useOverlay에 위임합니다.

function Component() {
  const { toast } = useToast();
 
  toast({
    title: '안녕하세요!',
    description: 'Gist clone과정을 폴짝! 건너뛰고테스트 ⭐️',
    duration: 2000,
    action: <Button>닫기</Button>
  });
 
  return <></>;
}

토스트

덕분에 이뿐 토스트를 만들었어요!

+트러블 슈팅

사용하기 편한 toast 훅을 만들었지만 마운트 될 땐 애니메이션이 잘 나왔지만, 언마운트 시 애니메이션 없이 토스트가 사라지는 현상이 발생했습니다.

이는 언마운트 함수를 호출하면 overlay가 애니메이션을 기다리지 않고 바로 새로운 Map 객체로 상태를 변경해 발생하는 현상이었습니다.

이러한 이슈를 또 저만 겪은 건 아니었습니다.

export interface OverlayControlRef {
  close: () => void;
}
 
export const OverlayController = forwardRef(function OverlayController(
  { overlayElement: OverlayElement, onExit }: Props,
  ref: Ref<OverlayControlRef>
) {
  const [isOpenOverlay, setIsOpenOverlay] = useState(false);
 
  const handleOverlayClose = useCallback(() => setIsOpenOverlay(false), []);
 
  useImperativeHandle(ref, () => {
    return { close: handleOverlayClose };
  }, [handleOverlayClose]);
 
  useEffect(() => {
    // NOTE: requestAnimationFrame이 없으면 가끔 Open 애니메이션이 실행되지 않는다.
    requestAnimationFrame(() => {
      setIsOpenOverlay(true);
    });
  }, []);
 
  return <OverlayElement isOpen={isOpenOverlay} close={handleOverlayClose} exit={onExit} />;
});

토스의 useOverlay에서는 언마운트 애니메이션을 위해서 OverlayElementOverlayController로 한번 감싸서 렌더링 했습니다.

실제로 언마운트하는 exit 함수와 오버레이를 닫는 close 함수를 구분해 먼저 close를 통해 오버레이를 애니메이션과 함께 닫고 이후 exit를 통해 언마운트하는 방식을 사용했습니다.

저의 코드는 이제 거의 98% 토스 팀의 코드와 같아졌지만, 다음에는 이러한 문제를 인지하고 쉽게 디버깅할 수 있을 것 같습니다.

결론

useOverlay 덕분에 선언적이고 응집력 높은 overlay UI 로직을 구성할 수 있었습니다.

앞으로도 선언적인 리액트 환경에서 자주 사용할 좋은 코드를 알아가서 너무 뜻깊었습니다!

토스팀은 더 나아가 react 생태계가 아닌 Javascript를 사용할 수 있는 모든 환경에서 오버레이를 선언적으로 관리하기 위해 useOverlay를 별도의 라이브러리인 overlay-kit으로 관리하고 있습니다.

React에 종속적인 라이브러리들이 점점 벗어나는 모습을 보니 저도 특정 라이브러리에 종속적이지 않은 로직을 구현하고 싶다는 생각이 드네요.