Modal을 클린하게 관리해보자!

aken·2024년 6월 12일
0
post-thumbnail

기존 코드의 문제점

먼저 createPortal을 활용하여 Modal 컴포넌트를 만들었습니다. 그리고 모달의 열림/닫힘 상태를 커스텀 훅으로 관리하였습니다.

// 커스텀 훅
const useModal = (initIsOpen) => {
  // isOpen: 모달 열림 여부
  const [isOpen, setIsOpen] = useState(initIsOpen || false);

  const open = () => setIsOpen(true);
  const close = () => setIsOpen(false);

  return { isOpen, open, close };
};

// 모달 컴포넌트
const Modal = ({
  isOpen,
  onClose,
  children,
}) => {
  const modalRef = useRef(null);

  if (!isOpen) return null;

  return createPortal(
    <BackDrop
      onClick={(e) => {
    	// 모달 외부를 클릭하면 닫히는 기능
        if (modalRef.current && !modalRef.current.contains(e.target)) {
          onClose(e);
        }
      }}
    >
      <ModalLayout ref={modalRef}>
        {children}
      </ModalLayout>
    </BackDrop>,
    document.body
  );
};

// 모달 컴포넌트 사용
const Component = () => {
  const { isOpen, open, close } = useModal();

  return (
  	<div>
    	<button onClick={open}>open</button>
		<Modal isOpen={isOpen} onClose={close}>
        	<div>modal</div>  
        </Modal>
    </div>
  )
}

위와 같은 Modal 컴포넌트를 사용하였으나, 기능이 추가될수록 아래와 같은 불편함을 느꼈습니다.

1. 컴포넌트 내부에 여러 모달이 존재할 경우

하나의 Page 컴포넌트에서 여러 개의 모달이 존재하는 경우가 있었습니다. 이 경우에는 각 모달을 useModal 커스텀 훅으로 열림/닫힘 상태를 관리하였습니다.

여러분은 위 코드에 대한 문제점을 발견하셨나요? 제가 느낀 문제점은 크게 세가지 입니다.

첫 번째로, 모달이 추가될 때마다 useModal 훅을 반복해서 호출해야 합니다. 그래서 모달이 추가되면, 커스텀 훅에서 반환되는 객체 프로퍼티의 이름을 새로 지어줘야했습니다.

두 번째는 Page 컴포넌트에서 모달 관련 코드가 분산되어 있습니다. 열림/닫힘 상태, 모달 관련 핸들러, 모달 컴포넌트가 흩어져 있기 때문에, '이 버튼을 누르면 어떤 모달이 열리는지' 찾기 위해 매번 위로 올라가서 useModal를 확인했습니다.

마지막으로 모달 컴포넌트가 모달을 사용하는 부모 컴포넌트에 결합되어 있다는 점입니다. createPortal을 사용하게 되면 실제로 부모 컴포넌트에서 벗어나 다른 DOM에서 렌더링이 되지만, React 컴포넌트 트리에서는 자식 컴포넌트처럼 작동합니다. 이로 인해 포탈로 만들어진 컴포넌트에서 부모 컴포넌트로 이벤트가 전파됩니다.

또한 react dev tools의 Components에서 Page 컴포넌트의 자식 컴포넌트로 Modal 컴포넌트가 있는 것을 보고 여전히 부모-자식 관계가 유지되고 있음을 확인할 수 있었습니다.

2. modal을 2개 이상 띄워야 하는 경우

다음은 modal(FirstModal) 안에 버튼을 클릭하면 또 다른 modal(SecondModal)이 나오는 코드입니다.

위와 같은 코드로 실행했더니, SecondModalclose second modal 버튼이나 모달 외부를 클릭하면 FirstModal도 같이 닫혔습니다. 왜그럴까요?
(1번 문제점에서 언급한 내용에 힌트가 있습니다!)

모달이 포탈이어도 React 트리에서 자식 컴포넌트처럼 실행되기 때문입니다. 따라서 아래에 나열된 과정처럼 모달에서 이벤트가 발생하면 부모 컴포넌트로 이벤트가 전파됩니다.

  1. close second modal 버튼을 눌렀을 경우
    -> SecondModal 닫힘
    -> FirstModal 내부의 Modal 컴포넌트로 이벤트가 전파되어 이벤트 핸들러 실행
    (Modal 컴포넌트의 클릭 이벤트 핸들러는 모달 외부를 클릭했을 때 닫히도록 하기 위해 걸어두었음)
    -> FirstModal 외부를 클릭했다고 판단하여 모달 닫힘
  1. SecondModal 외부를 클릭할 경우
    -> SecondModal 내부의 Modal 컴포넌트에 설정된 클릭 이벤트 핸들러 실행
    -> 모달 외부를 클릭했다고 판단하여 SecondModal 닫힘
    -> 이벤트가 FirstModal 내부의 Modal 컴포넌트로 전파됨
    -> FirstModal 외부를 클릭했다고 판단하여 모달 닫힘

react dev tools의 Component

리팩토링 해보자!

이번 리팩토링의 핵심 목표는 모달을 사용하는 부모 컴포넌트가 모달의 상태 관리와 위치 설정에 신경 쓰지 않고, 오직 모달 렌더링에만 집중하는 것이었습니다.

export default function Page() {
  const { open, close } = useModals();

  return (
    <>
      <button
        onClick={() => {
          // open의 두번째 인수: FirstModal props
          open(FirstModal, { onClose: () => close(FirstModal) });
        }}
      >
        open modal
      </button>
    </>
  );
}

이렇게 하면 Page 컴포넌트는 모달을 열고 싶을 때 useModals 커스텀 훅의 반환값인 open 함수에 모달에 관한 정보(변수명, props)만 전달하면 되기 때문에, 모달 렌더링에만 집중할 수 있게 됩니다.

저는 모달 컴포넌트들을 전역 상태로 관리하고, 이 모달들을 root 요소 바로 밑에 렌더링하기로 했습니다. 이렇게 관리하기 위해 크게 2가지를 구현하면 됩니다.

  1. 모달 컴포넌트들을 저장할 전역 상태 생성
  2. 모달을 여는 함수와 닫는 함수를 반환하는 커스텀 훅

1. 모달 컴포넌트들을 관리할 전역 상태 생성

const ModalContext = createContext(null);

export const useModalContext = () => {
  const context = useContext(ModalContext);

  if (!context) {
    throw new Error("Cannot find ModalProvider");
  }

  return context;
};

const ModalProvider = ({ children }) => {
  const [modals, setModals] = useState([]);

  const push = (Component, props, key) => {
    document.body.style.overflow = "hidden";

    setModals((prev) => [
      ...prev,
      { Component, props: { ...props, isOpen: true }, key },
    ]);
  };

  const remove = (Component) => {
    const hasModal = modals.length > 0;

    if (!hasModal) {
      document.body.style.overflow = "auto";
    }

    setModals((prev) => prev.filter((C) => C.Component !== Component));
  };

  return (
    <ModalContext.Provider value={{ push, remove }}>
      {children}
      {modals.map(({ Component: Modal, props, key }) => (
        <Modal key={key} {...props} />
      ))}
    </ModalContext.Provider>
  );
};

push

push는 컴포넌트 함수 / 해당 함수의 props / key 값을 인자로 설정하였습니다.
아래 코드처럼 프로젝트에서 모든 모달의 props에 isOpen이 있었습니다. 최대한 Modal을 사용한 컴포넌트를 그대로 유지하면서 리팩토링하고 싶었습니다.

const Modal = ({
  isOpen,
  // ...
}) => {
  const modalRef = useRef(null);

  if (!isOpen) return null;

  // ...
};

// Modal 컴포넌트 사용
const SecondModal = ({ isOpen, onClose }) => {
  return (
    <Modal isOpen={isOpen} onClose={onClose}>
      <span>Second Modal</span>
      <button onClick={onClose}>close second modal</button>
    </Modal>
  );
};

따라서 push에게 isOpen을 뺀 나머지 props를 넘겨주고, push 내부에서 isOpen: true를 추가하여 기존 모달 컴포넌트들을 건들이지 않고 리팩토링할 수 있었습니다.

여기서 key를 인자로 받는 이유는 modals를 렌더링할 때 key 값으로 사용하기 위해서 설정해두었습니다.

// push의 props에 isOpen을 뺀 나머지 props
const push = (Component, props, key) => {
    // 모달이 렌더링되어 있다면 body 스크롤 x
    document.body.style.overflow = "hidden";

    // 모달 추가
    setModals((prev) => [
      ...prev,
      { Component, props: { ...props, isOpen: true }, key },
    ]);
};

// ...
return (
    <ModalContext.Provider value={{ push, remove }}>
      {children}
      {modals.map(({ Component: Modal, props, key }) => (
        // key 값은 push 호출할 때 넘겨준 인수
        <Modal key={key} {...props} />
      ))}
    </ModalContext.Provider>
);

remove

remove에 모달 컴포넌트 함수를 넘겨서, 모달들을 저장하고 있는 리스트에 해당 모달을 삭제할 수 있었습니다.

const remove = (Component) => {
    const hasModal = modals.length > 0;
	
  	// 모달이 존재하지 않으면 body 스크롤 가능
    if (!hasModal) {
      document.body.style.overflow = "auto";
    }

    setModals((prev) => prev.filter((C) => C.Component !== Component));
};

2. 모달을 추가하고 삭제하는 로직이 담긴 커스텀 훅

useModals의 내부 구현 로직은 아래와 같이 하였습니다.

  • open: 모달 함수를 전역에서 관리하고 있는 모달 상태에 추가
  • close: 전역에서 관리하고 있는 모달 상태에서 해당 모달을 제거
export const useModals = () => {
  const { push, remove } = useModalContext();

  const open = (Component, props = {}) => {
    // 모달 컴포넌트의 key 값 설정하기 위해 uuid 사용
    const key = uuidv4();
    push(Component, props, key);
  };

  const close = (Component) => {
    remove(Component);
  };

  return { open, close };
};

3. 커스텀 훅 사용

const FirstModal = ({ isOpen, onClose }) => {
  const { open, close } = useModals();

  return (
    <Modal isOpen={isOpen} onClose={onClose}>
      <span>First Title</span>
      <button onClick={onClose}>close first modal</button>
      <button
        onClick={() => {
          open(SecondModal, { onClose: () => close(SecondModal) });
        }}
      >
        next modal
      </button>
    </Modal>
  );
};

const SecondModal = ({ isOpen, onClose }) => {
  return (
    <Modal isOpen={isOpen} onClose={onClose}>
      <span>Second Modal</span>
      <button onClick={onClose}>close second modal</button>
    </Modal>
  );
};

export default function Page() {
  const { open, close } = useModals();

  return (
    <>
      <button
        onClick={() => {
          open(FirstModal, { onClose: () => close(FirstModal) });
        }}
      >
        open modal
      </button>
    </>
  );
}

모달을 사용하는 컴포넌트들(Page, FirstModal)은 모달 관리에 대한 복잡한 내부 구현 사항을 신경 쓰지 않고 간단히 모달을 열고 닫을 수 있게 되었습니다!

이를 통해 모달 관련 코드의 재사용성과 유지보수성을 크게 향상시킬 수 있었습니다.

추가 리팩토링

근데 만약 open 함수의 첫 번째 인수로 모달 컴포넌트 변수명 대신 함수가 전달된다면 어떻게 해야 할까요?

const { open, close } = useModals();

open(({ isOpen, onClose }) => {
	return (
    	<Modal isOpen={isOpen} onClose={onClose}>
        	<span>modal</span>
            <button onClick={onClose}>close</button>
        </Modal>
    );
}, { onClose: () => close(?) })

// 기존에는 모달을 컴포넌트로 만들어서 사용
open(FirstModal, { onClose: () => close(FirstModal) });

이 경우 close 함수에 어떤 값을 전달해야 할지 고민이었습니다. 기존에는 모달 컴포넌트 변수명을 전달했지만, 함수를 전달하는 경우에는 함수 자체를 전달할 수 없었습니다.

그래서 close의 인수를 open 함수를 실행할 때 생기는 모달 식별자(key)로 변경하여, 식별자에 해당하는 모달이 닫힐 수 있도록 하였습니다.

const ModalProvider = ({ children }) => {
  // ...

  // 인수가 컴포넌트 함수에서 key로 변경
  const remove = (key) => {
    const hasModal = modals.length > 0;

    if (!hasModal) {
      document.body.style.overflow = "auto";
    }
	
    // key 값으로 모달을 찾아 삭제
    setModals((prev) => prev.filter((C) => C.key !== key));
  };

  // ...
};


export const useModals = () => {
  const { push, remove } = useModalContext();

  const open = (Component, props = {}) => {
    const key = uuidv4();
    push(Component, props, key);
    
    // close의 인자로 사용하기 위해 key 반환
    return key;
  };

  // 인수가 컴포넌트 함수에서 key로 변경
  const close = (key) => {
    remove(key);
  };

  return { open, close };
};

인수를 컴포넌트 함수 자체에서 식별자로 변경하고 나니, open에 첫 번째 인수로 함수 자체를 넘겨줘도 사용할 수 있게 되었습니다!

export default function Page() {
  const { open, close } = useModals();

  return (
    <>
      {/* open에게 첫 번째 인수로 함수를 전달할 경우 */}
      <button
        onClick={() => {
          const key = open(
            ({ isOpen, onClose }) => {
              return (
                <Modal isOpen={isOpen} onClose={onClose}>
                  <span>modal</span>
                  <button onClick={onClose}>close</button>
                </Modal>
              );
            },
            { onClose: () => close(key) }
          );
        }}
      >
        open modal
      </button>

      {/* open에게 첫 번째 인수로 컴포넌트 함수명을 전달할 경우 */}
      <button
        onClick={() => {
          const key = open(FirstModal, { onClose: () => close(key) });
        }}
      >
        open first modal
      </button>
    </>
  );
}

끝으로

사실 현재 프로젝트에서 모달을 모두 컴포넌트로 관리하고 있습니다. 마지막 추가 리팩토링 하기 전 코드로도 충분히 모달을 관리할 수 있었어요. (오히려 더 깔끔해보이네요🥲)
하지만 함수 자체가 인자로 전달됐을 경우도 있을 것 같아서 모달이 닫히는 함수(close, remove)의 인수를 바꿨습니다.
실제로 모달을 사용하면서 불편했던 점을 개선하기 위해, 머리 쥐어짜가면서 설계도 해보고 다른 분들 코드도 많이 참고하였습니다. 완벽하게 클린한 코드는 아니지만, 그래도 어떻게든 해결해서 뿌듯했습니다.

0개의 댓글