본문 바로가기
Programming/React

React에서 Modal을 만드는 다양한 방법(feat. Promise)

by mingule 2022. 8. 16.

서비스를 만들다보면 다양한 상황에서 모달(Modal/Dialog)을 활용하게 된다.

단순한 UI이지만, 구현 방법은 꽤나 다양하다.

 

나는 원래 provider를 이용해 open/close만을 담당하는 모달을 많이 만들고 사용해왔다.

(사실 대부분의 경우, 이런 단순한 모달이 가장 많이 사용되는 것 같다. 아마두?!)

 

그런데 최근, 모달이 열릴 때 작업을 잠시 stop했다가 모달이 닫히면 이후의 작업을 이어서 진행해야 하는 flow가 생겼다. 

(우리 서비스의 경우는 아니지만 모달에서 Form을 사용해서 API를 통해 검증하고, 검증이 안되면 모달이 닫히지 않도록 해야하는 등의 작업이 예시가 될 수 있을 것 같다!)

물론 다른 방법으로도 이런 flow를 구현할 수 있기는 했지만, 어차피 모달이 열리면 사용자는 모달 영역을 제외한 바깥 영역(Dimmed 영역)을 사용할 수 없기 때문에 모달 자체에 요 역할을 부여하면 좋겠다는 생각이 들었다. 

또, 쇠뿔도 단김에 빼랬다고 하는 김에 기존에 산재되어있던 모달들을 어느정도 표준화하고, 한 화면에 여러 개의 모달을 띄울 수 있도록 Stack형태로 모달을 구현했다.

 

요 기존의 모달을 리팩터링을 하면서, 그리고 지금까지 모달을 만들면서 고민했던 내용들을 공유해보고자 한다. 

한 번에 리팩터링한 내용을 공유하기 보다는, 단계별로 정리하는게 좋을 것 같아서 단계 별로 내용을 적어보려 한다.

가장 단순한 형태의 모달로 시작해 모달 표준화 및 Promise를 적용한 형태, 최종적으로는 Stack과 Promise를 모두 적용한 형태의 모달로 만드는 과정을 적어보겠다.

 

그럼 꼬우꼬우!

 

기존 모달

기존 모달에 대해 정리해보자면, 아래와 같았다. 

- 단순한 형태

- 하나의 화면에 하나의 모달만 띄울 수 있음

- 사용자가 필요한 Modal을 만들어서 그때 그때 넣어줘야 함 (모달 컴포넌트를 매번 새로 만들어야 함)

 

지금 와서 생각해보면, 좀 너무 Broad한 형태의 모달이지 않았나 싶다. 

하지만 우리 서비스에서 사용되는 모달은 딱히 표준화되어있기 보다는 제각기 다양한 형태를 가지고 있는 경우가 대부분이라 

처음부터 모달을 표준화해야겠다! 라는 생각이 들지 않기도 했고, 모바일 뷰가 나오지 않았던 상태였어서 굳이 한 화면에 Modal이 2개 이상 들어갈 일이 없기도 했다. 

 

그래서 아래와 같은 형태로 구현했다. 

openModal 함수의 인수로 사용자로부터 Modal을 받아 그 내용을 띄워주는 방식이다.

딱히 설명이 필요없을만큼 코드가 간단하다.

const ModalProvider = ({ children }: { children: ReactNode }) => {
  const [isModalOpen, setIsModalOpen] = useState(false);
  const [modalContents, setModalContents] = useState(<></>);

  const openModal = (children: ReactNode) => {
    setIsModalOpen(true);
    setModalContents(children);
  };

  const closeModal = () => setIsModalOpen(false)

  const onDimmerClick = (event: MouseEvent) => {
    if (event.currentTarget !== event.target) return;

    closeModal();
  };

  return (
    <ModalContext.Provider value={{ isModalOpen, openModal, closeModal }}>
      {children}

      {isModalOpen && (
        <Dimmed onClick={onDimmerClick}>
          {modalContents}
        </Dimmed>
      )}
    </ModalContext.Provider>
  );
};

openModal, closeModal을 통해 모달의 상태를 변경해주고, 모달 바깥 영역을 클릭하면 모달이 닫힌다는 내용이다.

 

표준화 + Promise를 사용한 형태의 모달

기존의 모달도 충분히 잘 사용하고 있었지만, 서비스가 확장되면서 어느정도 규격화된 모달들이 속속들이 등장했고 그렇기 때문에 매번 비슷한 형태의 모달을 만들어 사용하는 것에 대한 불편함이 잇따랐다. 이러한 불편함 때문에 모달을 표준화해야겠다는 생각이 들었다. 

 

모달을 표준화하기 이전에, 우리 서비스 내에서 어떤 모달들이 쓰이는지에 대한 정리가 먼저 필요했다.

Figma에 들어가 우리가 어떤 모달들을 사용하는 지 먼저 살펴보고, 어느정도 규격화해 사용될 수 있는 모달들로 추려내었다. 

 

아직 모달이 많지 않아, 세 가지 타입이 나왔다. Alert와 Confirm, 그리고 Delete이다. 그리고 각 타입은 아래와 같이 생겼다. 

들어가는 내용은 모달 제목, 설명, 그리고 버튼이다.

우리 서비스는 버튼에 들어가는 텍스트도 좀 다양해서, 요 부분도 사용자가 넣을 수 있도록 했다.

 

그렇게 해서 추려낸 Modal Type은 아래와 같다. 

export type StandardizedModalType = {
  id: string; // 모달 식별자
  type?: 'confirm' | 'delete' | 'alert'; // 모달 타입
  title?: ReactNode; // 단순히 텍스트만 들어갈 수도 있지만, 우리는 가끔 아이콘 넣는 일이 있어서 ReactNode 타입으로 지정했다.
  description: ReactNode; // title과 같다.
  buttonText?: { // 우리는 버튼에 들어가는 텍스트도 왔다갔다 하는 편이라, 필요하면 유저가 넣어줄 수 있도록 했다.
    confirm?: string;
    alert?: string;
    delete?: string;
  };
  onSuccess?: () => void; // 확인/삭제 등을 눌렀을 때 동작할 함수
  onClose: () => void;  // 닫기를 눌렀을 때 동작할 함수
};

 

우리 서비스는 이런 표준화된 모달 뿐만아니라 아예 규격을 벗어하는 Custom한 모달도 많이 사용되기 때문에, CustomModal에 대해서도 정의해줬다. 

type CustomModalStackType = {
  id: string;
  type?: 'custom';
  customModal: ReactNode;
};

 

그리고 이 타입들에 따른 Modal Component를 만들었다. 우리는 사실 타입에 따라 다른 컴포넌트를 만들 만큼 모달이 다르지 않기 때문에 그냥 <DefaultModal /> 이라는 컴포넌트를 만들어서 type prop에 따라 다른 형태를 보여주도록 했다. 

 

그러면 이제 Provider에 원래 아래와 같이 작성되어 있던 부분을

 {isModalOpen && (
    <Dimmed onClick={onDimmerClick}>
      {modalContents}
    </Dimmed>
  )}

요렇게 변경하면, 타입에 맞게 모달이 렌더링된다. (처음에 if문으로 썼다가 switch가 더 깔끔하길래 요걸루)

const modalContent = () => {
    if (!modal.id) return null;

    switch (modal.type) {
      case 'confirm':
      case 'delete':
      case 'alert': {
        const { id, onSuccess, onClose } = modal;
        return (
          <ModalDimmedContainer
            key={id}
            onClick={(e) => handleDimmerClick(e, id)}
          >
            <DefaultModal
              {...modal}
              onSuccess={() => onSuccess?.()}
              onClose={onClose}
            />
          </ModalDimmedContainer>
        );
      }

      case 'custom': {
        const { id, customModal } = modal;
        return (
          <ModalDimmedContainer
            key={id}
            onClick={(e) => handleDimmerClick(e, id)}
          >
            {customModal}
          </ModalDimmedContainer>
        );
      }

      default: {
        return null;
      }
    }
  };

 

그러면 어떻게 사용해야하는가! 아래와 같이 사용하면 된다.

export const usePromiseModal = ({
  type = 'confirm',
  title,
  description,
  customModal,
}: usePromistModalParams) => {
  const { addModal, removeModal } = useContext<ModalProps>(ModalContext);
  const resolveRef = useRef<(value?: unknown) => void>(() => {});

  const id = useMemo(nanoid, []);

  const showModal = () => {
    // showModal을 할 때에 Promise를 만들어서, resolve되기 전까지는 다른 작업들이 멈춰있도록 한다.    
    return new Promise((resolve) => {
      // hideModal을 할 때에는 resolve를 해야하기 때문에 resolve를 ref에 담아둔다.
      resolveRef.current = resolve;

      if (type !== 'custom') {
        addModal({
          id,
          type,
          title,
          description,
          onClose: hideModal,
          onSuccess:() => resolve(true)
        });
      }

      if (type === 'custom' && customModal) {
        addModal({
          id,
          type,
          customModal: customModal || <></>,
        });
      }
    });
  };

  const hideModal = () => {
    // 만약 hideModal을 누르면 바로 resolve되어 일시정지된 작업을 다시 시작한다.
    // 나는 false를 뱉으면서 promise가 resolve되도록 만들었다.
    resolveRef.current(false);
    removeModal();
  };

  return { showModal, hideModal };
};

 

이렇게 만들면 끝이다! 생각보다 간단!

어느정도 모달들을 규격화해두어서 사용자가 타입, 타이틀, 설명 등만 넣으면 모달을 바로 사용할 수 있도록 했고, 

showModal을 할 때에 Promise를 반환하도록 만들어 작업을 일시정지 할 수 있도록 만들었다.

 

 

Stack을 함께 적용 사용한 형태의 모달

사실 하나의 화면에 모달이 2개 이상 뜨는 경우는 거의 없다고 봐도 무방하다. 

그런데 서비스가 확장됨에 따라 한 화면에 모달이 2개 이상 떠야하는 경우를 고려해 조금 더 모달을 업그레이드 해보자고 생각했다. 

(왜냐면 예전에 본딩 웹뷰 개발할 때에 모달이 2개 이상 뜨는 경우가 종종 있었기 때문^^..)

 

기존에 사용하던 modal State를 List형태로 만들어, 모달들을 차례대로 저장할 수 있도록 했다.

그리고 그에 맞게 모달을 추가하고 삭제하는 함수들을 변경해줬다.

const [modalStacks, setModalStacks] = useState<ModalType[]>([]);

const handleAddModal = (modalNode: ModalType) => {
  setModalStacks((prevStates) => [...prevStates, modalNode]);
};

const handleRemoveModal = (targetId: string) => {
  setModalStacks((prevStates) =>
    prevStates.filter(({ id }) => id !== targetId)
  );
};

 

그리고 보여주는 부분에, modalStack들을 풀어서 보여준다. 

customModal과 나머지 모달들은 보여주어야 하는 내용이 다르기 때문에, 아래와 같이 만들었다.

 {modalStacks.map((modal) => {
    if (!('customModal' in modal)) {
      const { id, onSuccess, onClose, ...rest } = modal;

      return (
        <ModalDimmedContainer
          key={id}
          onClick={(e) => handleDimmerClick(e, id)}
        >
          <DefaultModal
            onSuccess={() => onSuccess?.()}
            onClose={onClose}
            {...rest}
          />
        </ModalDimmedContainer>
      );
    }

    const { id, customModal } = modal;
    return (
      <ModalDimmedContainer
        key={id}
        onClick={(e) => handleDimmerClick(e, id)}
      >
        {customModal}
      </ModalDimmedContainer>
    );
})}

 

그러면 이제 아래와 같이 2개의 모달을 차례대로 띄울 수 있게 된다.

const { showModal: showModal1, hideModal: hideModal1 } = usePromiseModal({
  type: 'alert',
  description: <div>첫번째 모달</div>,
});

const { showModal: showModal2, hideModal: hideModal2 } = usePromiseModal({
  type: 'confirm',
  description: <div>두번째 모달</div>,
});


const onClickButton = async () => {
    await showModal1();
    await showModal2();

    hideModal2();
    hideModal1();
  };
}

 

번외) 검증하는 로직 넣기

위에 만든 모달은 "확인" 버튼을 누를 때에 무언가 따로 작업을 실행한다거나 하지는 못한다. 바로 resolve를 해주고 있기 때문!

만약 Modal에 Form 형태가 들어가야 하는 일이 생긴다면, Form 검증이 필요할 수도 있다.

예를 들어 중복된 아이디를 검사하는 폼을 모달에 넣어 적용하고 싶다면?!

Form검증을 하는 API를 붙이고, 만약 검증이 되지 않았을 경우에는 "확인"을 눌러도 모달이 꺼지게 하고싶지 않을 것이다. 

 

요때에는 onSuccess를 바로 받아서 Promise resolve를 하지 않고, 우리가 원하는 validate가 제대로 동작했을 경우에만 resolve하도록 만들어주면 된다. 

변경사항은 아래와 같다. 

 

먼저 알맞게 타입을 변경해주고, 

type StandardizedModalStackType = {
  id: string;
  ...
  callback: (isConfirmed: boolean) => void; 
  // onSuccess를 없애고, 새로 추가해준다. 
  // 만약 사용자가 '취소'를 눌렀을 경우에는 false를, '확인'을 눌렀을 경우에는 true를 반환해주기 위해 isConfirmed라는 params를 받는다.
};

모달을 만들 때에 callback을 만들어서 함께 넣어준다. 이 때, 사용자로부터 받은 validation 함수가 실행되도록 한다.

export const usePromiseModal = ({
  type = 'confirm',
  title,
  description,
  customModal,
}: usePromistModalParams) => {
  const { addModal, removeModal } = useContext<ModalProps>(ModalContext);
  const resolveRef = useRef<(value?: unknown) => void>(() => {});

  const id = useMemo(nanoid, []);

  const showModal = (checkValid?: (data: any) => Promise<boolean>) => {
    // 사용하는 측으로부터 validate를 하는 함수를 받는다.
    return new Promise((resolve) => {
      resolveRef.current = resolve;

      if (type !== 'custom') {
        addModal({
          id,
          type,
          title,
          description,
          onClose: hideModal,
          callback: async (isConfirmed) => {
            // validate함수를 실행해 유효한지 확인한다.
            const isValidated = await checkValid?.(isConfirmed);

            // 해당 내용이 유효하거나, 사용자로부터 validate함수를 받지 않았을 때, resolve한다.
            if (isValidated || !checkValid) resolve(isConfirmed);
          },
        });
      }
      ...
};

마지막으로 모달에서도 onSuccess, onClose시에 callback이 동작할 수 있도록 변경해준다.

 {modalStacks.map((modal) => {
    if (!('customModal' in modal)) {
      const { id, callback, onClose, ...rest } = modal;

      return (
        <ModalDimmedContainer
          key={id}
          onClick={(e) => handleDimmerClick(e, id)}
        >
          <DefaultModal
            onSuccess={() => callback(true)}
            onClose={() => {
              callback(false);
              onClose();
            }}
            {...rest}
          />
        </ModalDimmedContainer>
      );
    }

    const { id, customModal } = modal;
    return (
      <ModalDimmedContainer
        key={id}
        onClick={(e) => handleDimmerClick(e, id)}
      >
        {customModal}
      </ModalDimmedContainer>
    );
})}

 

이러면 진짜 끝이다!

사용하는 쪽에서는 아래와 같이 사용하면 된다.

const onClickWithdrawalButton = async () => {
  await showModal(async () => {
    /* Validate API 실행하기
      만약 validated이면 return true, 아니면 return false하면 된다.
      return false를 하게되면 모달이 닫히지 않는다.
    */

  return true;
});

  hideModal();
};

 

처음에 구현할 때에는 조금 복잡복잡한 것 같은데, 이렇게 단계별로 구현하니 크게 어렵지는 않았다.

정리해서 글 쓰는게 사알짝 빡셌지만 ^^;;~~~~~~

모달을 사용해나가면서 점차 다듬어봐야겠다. 재밌고 뿌듯했다! 후후후,,,

쓰고 싶은 내용이 좀 더 있지만 일단 이번 내용은 요기서 마무으리~!

홍홍홍!!

 

 

 

 

아래는 그냥 궁금해서 찾아봤다. ㅋㅋㅋㅋㅋㅋ 모달을 부르는 이름이 되게 다양하길래..

(Modal, NonModal, Popup, Page의 차이)

 

댓글