toss/useOverlay 동작원리와 주의사항

Yoonlang·2024년 9월 17일
0

useOverlay

목록 보기
1/2
post-custom-banner

이번 글은 @toss/use-overlay의 동작원리와 사용 시 주의해야 할 점에 대해 다룹니다.

주의사항 TL;DR

  • exit()을 호출해야 메모리 누수가 일어나지 않는다. close()만 사용하면 메모리 누수가 발생한다.
  • useOverlay()는 각기 다른 인스턴스를 반환한다. 하나의 overlay에서 여러 개의 컴포넌트를 열면 마지막으로 연 컴포넌트만 적용된다.
  • overlay 순환 구조는 피하자. OverlayProvider의 Map 객체에 열었던 컴포넌트에 대한 객체가 남아있다. (최초 호출한 컴포넌트가 unmount 될 때까지)

@toss/use-overlay

@toss/use-overlay는 useOverlay React hook을 제공하여, 오버레이를 선언적으로 관리할 수 있게 해 줘요.
-slash 라이브러리 문서 발췌-

useOverlay 훅은 오버레이를 선언적으로 코드를 관리할 수 있게 해준다.
선언적인 코드라는 게 무엇인지는 선언적인 코드 작성하기 - 박서진님를 읽어보자.
해당 글에선 선언적인 코드를 “추상화 레벨이 높아진 코드”로 정의한다.

useOverlay. 왜 쓰는데?

아래 간단한 코드로 설명을 하겠다.

// useState + Modal 방식
const Component = () => {
  const [isOpen, setIsOpen] = useState(false);

  return (
    <>
      <button onClick={() => setIsOpen(true)}>Open Modal</button>
      <Modal open={isOpen} onClose={() => setIsOpen(false)}>
        <p>Modal content</p>
      </Modal>
    </>
  );
};

// useOverlay 방식
const Component = () => {
  const overlay = useOverlay();

  return (
    <>
      <button
        onClick={() => {
          overlay.open(({ isOpen, exit }) => (
            <Modal open={isOpen} onClose={exit}>
              <p>Modal content</p>
            </Modal>
          ));
        }}
      >
        Open Modal
      </button>
    </>
  );
};
  1. useOverlay를 사용하면 open과 close에 대해 overlay를 연 컴포넌트가 관리하지 않아도 된다. 따라서 useState와 같이 여닫는 상태 관리를 노출하지 않아도 된다.
  2. 컴포넌트의 직관적인 위치
    기존 Modal의 위치는 컴포넌트 제일 아래에 위치하고 있다. 하지만 overlay.open 메소드는 onClick에서 호출하므로 해당 버튼을 클릭 시 overlay가 열릴 것이란 걸 직관적으로 파악할 수 있다.

이제 본격적으로 동작 원리를 파헤쳐 보자.

코드 분석

소스 코드
핵심이 되는 3개의 파일이 있다.
useOverlay.tsx  OverlayProvider.tsx  OverlayController.tsx

들어가기에 앞서 간략히 요약하자면

OverlayProvider
OverlayController로 감싼 컴포넌트를 관리하고 실제 react element의 렌더링을 담당한다.
mount와 unmount를 정의하고 이를 통해 useOverlay가 오버레이를 제어할 수 있게 해준다.

OverlayController
컴포넌트의 여닫기를 관리한다.
useOverlay에서 close를 사용할 수 있게 close를 외부로 노출한다.

useOverlay
하나의 오버레이 인스턴스를 생성해서 이를 관리한다.
OverlayController로 elem을 감싸고, OverlayProvider에 보내는 역할을 한다.


이 글에서는 각 파일 별로 보는 게 아니라 코드의 흐름대로 보고자 한다.

export function useOverlay({ exitOnUnmount = true }: Options = {}) {
  const context = useContext(OverlayContext);

먼저 useOverlay를 보면 React의 useContext hook을 써서 context를 받는다.

export const OverlayContext = createContext<{
  mount(id: string, element: ReactNode): void;
  unmount(id: string): void;
} | null>(null);

위의 context가 앞으로 가질 값은 mount 함수와 unmount 함수를 가지는 객체이다.
OverlayContext를 이제 useContext를 통해 접근하게 된다.

export function OverlayProvider({ children }: PropsWithChildren) {
  const [overlayById, setOverlayById] = useState<Map<string, ReactNode>>(new Map());
  
  /*생략*/
  
  return (
    <OverlayContext.Provider value={context}>
      {children}
      {[...overlayById.entries()].map(([id, element]) => (
        <React.Fragment key={id}>{element}</React.Fragment>
      ))}
    </OverlayContext.Provider>
  );
}

OverlayProvider에선 context API를 정의하고, Map 객체로 오버레이 인스턴스별로 React Node를 저장하고 있다. 최상단에서 이를 렌더링을 해준다.

이제 mount와 unmount에 대해 보자.

const mount = useCallback((id: string, element: ReactNode) => {
  setOverlayById(overlayById => {
    const cloned = new Map(overlayById);
    cloned.set(id, element);
    return cloned;
  });
}, []);

const unmount = useCallback((id: string) => {
  setOverlayById(overlayById => {
    const cloned = new Map(overlayById);
    cloned.delete(id);
    return cloned;
  });
}, []);

const context = useMemo(() => ({ mount, unmount }), [mount, unmount]);

return (
    <OverlayContext.Provider value={context}>

mount는 useOverlay 인스턴스 id와 elem을 받아서 Map 객체에 저장한다.
unmount는 id를 받아와서 Map에서 없애는 역할이다.
Provider는 context API를 통해 mount와 unmount 두 함수를 제공한다.

다시 useOverlay로 오면

let elementId = 1;

export function useOverlay({ exitOnUnmount = true }: Options = {}) {
	const context = useContext(OverlayContext);
	/* 생략 */
	
  const { mount, unmount } = context;
  const [id] = useState(() => String(elementId++));

useOverlay 인스턴스별 하나의 ID를 갖기 위해 useState로 id를 구분한다.
이를 통해서 useOverlay 하나가 담당하는 컴포넌트는 하나임을 알 수 있다.

이제 useOverlay의 리턴 값 중 open을 보자.

return useMemo(
  () => ({
    open: (overlayElement: CreateOverlayElement) => {
      mount(
        id,
        <OverlayController
          // NOTE: state should be reset every time we open an overlay
          key={Date.now()}
          ref={overlayRef}
          overlayElement={overlayElement}
          onExit={() => {
            unmount(id);
          }}
        />
      );
    },
    /* 생략 */
  }),
  [id, mount, unmount]
);

open에선 mount를 호출하고, 이 때 파라미터로 OverlayController를 넘겨준다.

해당 OverlayController가 OverlayProvider 내부의 overlayById Map의 value이며 우리가 보는 컴포넌트이다.

그럼 이제 OverlayController 코드를 보자.

export const OverlayController = forwardRef(function OverlayController(
  { overlayElement: OverlayElement, onExit }: Props,
  ref: Ref<OverlayControlRef>
) {
  const [isOpenOverlay, setIsOpenOverlay] = useState(false);

  const handleOverlayClose = useCallback(() => setIsOpenOverlay(false), []);
  
  /* 생략 */

  return <OverlayElement isOpen={isOpenOverlay} close={handleOverlayClose} exit={onExit} />;
});

OverlayController의 역할은 element의 여닫기 관리이다.
실제 elem에 state를 통해 여닫기 처리를 해준다.

OverlayController는 forwardRef로 감싸져있고, 이는 외부에서 ref를 받는다는 의미.
useOverlay와 OverlayController는 ref를 통해서 상호작용을 하고 있다.

export const OverlayController = forwardRef(function OverlayController(
  { overlayElement: OverlayElement, onExit }: Props,
  ref: Ref<OverlayControlRef>
) {
	/* 생략 */
  const handleOverlayClose = useCallback(() => setIsOpenOverlay(false), []);
	
  useImperativeHandle(
    ref,
    () => {
      return { close: handleOverlayClose };
    },
    [handleOverlayClose]
  );
	/* 생략 */
});

OverlayController는 useImperativeHandle 을 통해서 ref에 메소드를 정의한다.
이제 상위 컴포넌트(useOverlay)는 ref.current.close를 통해서 handleOverlayClose를 호출할 수 있다.

export function useOverlay({ exitOnUnmount = true }: Options = {}) {
	/* 생략 */
	
  const overlayRef = useRef<OverlayControlRef | null>(null);

  return useMemo(
    () => ({
      open: (overlayElement: CreateOverlayElement) => {
        mount(
          id,
          <OverlayController
            // NOTE: state should be reset every time we open an overlay
            key={Date.now()}
            ref={overlayRef}
            overlayElement={overlayElement}
            onExit={() => {
              unmount(id);
            }}
          />
        );
      },
      close: () => {
        overlayRef.current?.close();
      },
      exit: () => { unmount(id); },
    }),
    [id, mount, unmount]
  );
}

위 과정을 통해 open에서 전달한 ref는 close 메소드를 가진 객체가 된다.

즉 close를 실행 시 overlayRef.current?.close();를 실행하게 되고, 이는 OverlayController의 handleOverlayClose를 실행하는 것이다.

여기서 주의해야할 점이 있다.
close는 OverlayController에서 컴포넌트를 닫는 역할이지 OverlayProvider의 Map에서 삭제하지 않는다. 즉, exit(unmount)를 하지 않고 close만 호출한다면 메모리엔 아직 OverlayController가 남아있게 된다.

여기까지 useOverlay 코드에 대해 알아보았고, 이제 주의사항들에 대해 마지막으로 알아보자.

주의사항

overlay 중복 사용

let elementId = 1;

export function useOverlay({ exitOnUnmount = true }: Options = {}) {
	const context = useContext(OverlayContext);
	/* 생략 */
	
  const { mount, unmount } = context;
  const [id] = useState(() => String(elementId++));

위에서도 봤지만 각 useOverlay 호출은 독립적이고, 하나의 인스턴스를 생성한다.
이 인스턴스는 한 개의 컴포넌트만을 관리하기 때문에 두 개를 열면 마지막에 호출된 컴포넌트만 열리게 된다.

  <>
    {overlay.open(({ isOpen, close }) => (
      <MessageDialog
        isDialogOpen={isOpen}
        onDialogClose={close}
        messageList={["테스트1"]}
      />
    ))}
    {overlay.open(({ isOpen, close }) => (
      <MessageDialog
        isDialogOpen={isOpen}
        onDialogClose={close}
        messageList={["테스트2"]}
      />
    ))}
  </>

위 코드를 실행하게 되면 "테스트2" MessageDialog만 보이게 된다.

이를 해결하려면 별도의 인스턴스를 생성하자.

// 두 개의 overlay 인스턴스를 생성
const overlay = useOverlay();
const messageOverlay = useOverlay();
 <>
    {overlay.open(({ isOpen, close }) => (
      <MessageDialog
        isDialogOpen={isOpen}
        onDialogClose={close}
        messageList={["테스트1"]}
      />
    ))}
    {messageOverlay.open(({ isOpen, close }) => (
      <MessageDialog
        isDialogOpen={isOpen}
        onDialogClose={close}
        messageList={["테스트2"]}
      />
    ))}
  </>

overlay 순환 구조

const ComponentA = ({ open, onClose }) => {
  const overlay = useOverlay();

  const openComponentB = () => {
    overlay.open(({ isOpen, close }) => (
      <ComponentB open={isOpen} onClose={close} />
    ));
  };

  return (
    <Modal open={open} onClose={onClose}>
      <h2>Component A</h2>
      <button onClick={openComponentB}>Open Component B</button>
    </Modal>
  );
};

const ComponentB = ({ open, onClose }) => {
  const overlay = useOverlay();

  const openComponentA = () => {
    overlay.open(({ isOpen, close }) => (
      <ComponentA open={isOpen} onClose={close} />
    ));
  };

  return (
    <Modal open={open} onClose={onClose}>
      <h2>Component B</h2>
      <button onClick={openComponentA}>Open Component A</button>
    </Modal>
  );
};

위 구조는 A 컴포넌트가 B 컴포넌트를 부르고 B 컴포넌트에서 또 새로운 A 컴포넌트를 부르는 순환 구조이다. 이러한 구조는 부득이한 경우가 아니라면 피하는 것이 좋다.

우선 이를 구현하려면 exit을 사용할 수 없다.
A 컴포넌트에서 B 컴포넌트를 호출 후, A 컴포넌트를 exit (unmount) 하게 된다면 A에 의해 불러온 B 컴포넌트도 바로 unmount되기 때문이다.

결론적으로 close만을 이용해야 하는데 그렇게 될 시 두 가지 문제가 발생한다.
1. 컴포넌트를 호출할 때마다 OverlayProvider의 Map에 계속해서 컴포넌트들이 쌓이게 된다.
2. 처음 호출한 부모 컴포넌트를 unmount하지 않는 이상 계속해서 Map에 남아있게 된다.

만약 순환 구조를 가져야한다면 이에 대해 이해하고 구현하도록 하자.

마무리

이번 글에선 useOverlay의 동작원리와 주의사항에 대해 알아봤다. 실제로 도입해보니 직관적이고 선언적으로 overlay의 상태를 관리할 수 있어서 좋았다.

반대로 useOverlay를 도입하며 고민하게 된 요소들이 있는데, 특히 overlay 중복 사용을 피하기 위해 여러 인스턴스를 생성하는 부분에 대해 많이 고민했다.

다음 글에선 실제 프로젝트에 어떻게 적용했는지, 그리고 여러 고민 요소들을 어떻게 해결했는지에 대해 다룰 것이다.

post-custom-banner

0개의 댓글