클린한 모달 사용하기 - 모달과 컴포넌트의 분리

sxungchxn.dev·2023년 1월 24일
101

Frontend

목록 보기
3/7
post-thumbnail
post-custom-banner

📕 tl;dr

  • 컴포넌트와 결합된 모달을 분리시킨다.
  • 모달을 전역에서 관리하고 렌더링 시키도록 한다.
  • 모달의 열림/닫힘 상태 대신 렌더링할 모달들을 상태값으로 지정한다.
  • 코드 스플리팅을 적용해 모달 컴포넌트로 인한 성능 저하를 예방한다.

🧹 시작은 클린코드 영상으로부터..

유튜브에서 프론트엔드 클린코드 관련 영상을 보면서 되게 인상 깊은 코드 사례를 보게 되었다.

출처: SLASH21 - 실무에서 바로 쓰는 Frontend Clean Code

위 사례에서는 컴포넌트 내부적으로 사용할 팝업(모달)을 위해서 렌더링 여부를 담는 상태값, 팝업 내 이벤트 핸들러, 팝업 컴포넌트 관련 코드들을 작성해야했고 이들은 뿔뿔히 흩어지게 되는 경우를 보여주고 있다. 이로 인해 팝업 관련 로직은 읽기가 정말 어려워 졌다.


출처: SLASH21 - 실무에서 바로 쓰는 Frontend Clean Code

영상에서는 이러한 문제점을 해결하기 위해 팝업을 여는 것과 관련된 훅인 usePopup 을 정의한 뒤 팝업 컴포넌트를 최상단에서 렌더링 하도록 하여 관련 로직을 하나로 응집시키고 코드의 가독성을 높였다. 이 영상을 보면서 기존에 했던 프로젝트에서도 이와 유사한 방식을 적용해 볼 수 있다고 느꼈다.


👻 기존 프로젝트 내 모달의 문제점

1. 모달 로직으로 인한 코드의 가독성 저하

위의 코드는 버튼 클릭시 지원과 관련된 모달을 띄우고 모달 내 버튼 클릭시 지원하기 로직을 처리하는 코드이다. 위에서 볼 수 있듯이 모달을 띄우기 위해서 상태값 정의, 모달 이벤트 핸들러 정의, 모달 컴포넌트와 관련된 코드들을 작성해야 했으며 이는 컴포넌트 내에서 뿔뿔이 흩어져 있다. 이로 인해 지원하기 버튼 이라는 컴포넌트 내에서 핵심 로직이 지원하기 이지만 모달을 띄우는 로직들로 인해 관련 내용을 파악하기 어려워 졌다.


2. 불필요하게 많은 모달 컴포넌트 렌더링

기존 프로젝트 내에서 위와 같이 작성한 댓글이 있으면 해당 댓글을 삭제할 수 있는지 확인받도록 모달을 띄우는 로직을 정의할 필요가 있었다. 이를 위해 아래와 같은 Comment 컴포넌트를 정의하고 사용했다.

Comment 컴포넌트에서 모달 로직은 응집성이 떨어진 채로 선언되어있으며 더 큰 문제점은 ConfirmModal이라는 컴포넌트가 댓글의 개수만큼 조건부 렌더링 될 것이다. 화면 내에서 보이는 요소는 똑같은 모달 하나 뿐이며 쓰일지도 모를 모달을 위해 불필요하게 많은 모달 컴포넌트들이 렌더링 영역 어딘가를 차지하고 있다는 말이다.

이러한 문제점들이 생기는 이유는 본질적으로 모달이라는 요소가 다른 컴포넌트들과 불필요하게 결합되어 있기 때문이다. 이 모달들은 화면 내에서 어떠한 요소들 보다항상 최상단에서 렌더링 되고 있는 점에서 모달 컴포넌트 자체는 다른 컴포넌트들과 직접적으로 결합될 이유가 전혀 없다.


🪝 1차 리팩토링 - 모달을 전역으로 관리하기

모달과 컴포넌트가 결합되는 문제를 해결하기 위해 모달을 전역에서 관리하도록 바꿔줄 필요가 있다. 방법은 간단하다.

  • 전역에서 모달의 열림/닫힘 상태값를 소유하고 이 값에 따라 모달 컴포넌트를 렌더링 한다.

  • 모달을 띄우는 로직이 필요한 컴포넌트에서 전역단에 선언한 모달 열림/닫힘 상태값을 변경할 수 있도록 한다.

이를 위해 우선 아래와 같이 모달의 상태를 전역으로 사용하고 관리할 수 있는 커스텀 훅을 선언하자.

import { atom, useRecoilState } from 'recoil';

const modalOpenAtom = atom({
  key: 'modalOpenAtom',
  default: false,
});

const useModals = () => {
  const [modalOpen, setModalOpen] = useRecoilState(modalOpenAtom);

  const openModal = useCallback(() => {
      setModalOpen(true);
  }, [setModalOpen]);

  const closeModal = useCallback(() => {
      setModalOpen(false);
  }, [setModalOpen]);

  return {
    modalOpen,
    openModal,
    closeModal,
  };
};

export default useModals;

위에서는 기존의 프로젝트에 전역 클라이언트 상태관리를 모두 Recoil로 처리했기에 이를 사용했으나 Context로 충분히 대체할 수 있다.

useModals 훅은 여러 군데에서 사용되어지기 때문에 이 훅을 사용하는 곳에서 리렌더링이 발생하는 것을 방지하기 위해 훅 내 함수들은 useCallback을 이용해 메모이제이션 해주었다.

이러한 훅을 사용해 아래처럼 페이지 최상단에서 모달을 조건부 렌더링 시킬 수 있다.


// 최상단 컴포넌트
const App = () => {
  	...
  	return (
      <>
       	<Modals />
        <Component {...pageProps} />
      </>
    );
}


const Modals = () => {
 	const { modalOpen, closeModal } = useModals();
  	
  	// 모달이 열려서 확인버튼을 눌렀을때 처리하는 로직
  	const handleClickConfirmButton = () => {
    	비즈니스_로직();
      	closeModal();
	}
    
  	return (
      <>
          	<ConfirmModal 
              open={openModal} 
              onConfirmButtonClick={handleClickConfirmButton} 
              message="..." 
              onClose={closeModal} 
            />
         }	
      </>
    );

}

그리고 이를 사용하는 측에선 다음과 같이 훅을 통해 모달을 열도록 제어할 수 있다. 아래는 ApplyButton 에 적용한 예시이다.

const ApplyButton = () => {
  
  const { openModal } = useModals();
  
  return (
      <Button onClick={openModal}>
        참가하기
      </Button>
  );
};

이렇게 1차적인 리팩토링을 적용하여 컴포넌트 내의 모달 로직을 분리시켜보았다. 이렇게 Modals 라는 컴포넌트에 모달 렌더링 로직을 위임함으로써 모달이 필요한 컴포넌트에서 불필요한 코드를 포함하지 않도록 할 수 있었다.

💣 잘못된 리팩토링

이렇게 컴포넌트와 모달간의 불필요한 결합을 없애는데에는 성공했으나 다른 문제점들이 존재한다.

1. 컴포넌트와 비즈니스 로직의 분리


// 지원하기 버튼 컴포넌트

const ApplyButton = () => {
  const { openModal } = useModals();
  return (
      <Button onClick={openModal}>
        참가하기
      </Button>
  );
};

// 모달을 관리하는 상위 컴포넌트

const Modals = () => {
 	const { modalOpen, closeModal } = useModals();
  	
  	// 모달이 열려서 확인버튼을 눌렀을때 처리하는 로직
  	const handleClickConfirmButton = () => {
    	비즈니스_로직();
      	closeModal();
	}
    
  	return (
      	<>
          	<ConfirmModal 
              open={openModal} 
              onConfirmButtonClick={handleClickConfirmButton} 
              message="참가 신청하시겠습니까?" 
              onClose={closeModal} 
            />
      	</>
    );

}

ApplyButton 은 버튼 클릭시 모달을 띄우고 모달 내 확인 버튼을 눌렀을때 비즈니스 로직을 실행하도록 설계되어 있었다. 허나 리팩토링 이후 상황에서는ApplyButton 내에 존재해야될 비즈니스 로직이 Modals 라는 다른 컴포넌트에 존재해야 된다.


2. 취약한 확장성

현재는 참가신청 버튼에 대한 모달만을 띄우도록 변경해놓았지만 다른 컴포넌트들에서 또다른 모달을 사용해야한다면 어떻게 해야할까?


const ApplyButton = () => {
  const { openModal } = useApplyModal();
  return (
      <Button onClick={openModal}>
        참가하기
      </Button>
  );
};

const DeleteButton = () => {
  const { openModal } = useDeleteModal();
  return (
      <Button onClick={openModal}>
        삭제하기
      </Button>
  );
};

const Modals = () => {
  	const { modalOpen: applyModalOpen, closeModal: closeApplyModal } = useApplyModal();
 	const { modalOpen: deleteModalOpen, closeModal: closeDeleteModal } = useDeleteModal();
  	...
  	
  	// 모달이 열려서 확인버튼을 눌렀을때 처리하는 로직
  	const handleClickApplyModalConfirm = () => {
    	비즈니스_로직1();
      	closeApplyModal();
	}
    
    const handleClickDeleteModalConfirm = () => {
     	비즈니스_로직2(); 
    	closeDeleteModal();
    }
    ...
    
  	return (
      	<>
          	<ConfirmModal 
              open={applyModalOpen} 
              onConfirmButtonClick={handleClickApplyModalConfirm} 
              message="참가 신청하시겠습니까?" 
              onClose={closeApplyModal} 
            />
         	<ConfirmModal 
              open={deleteModalOpen} 
              onConfirmButtonClick={handleClickDeleteModalConfirm} 
              message="삭제 하시겠습니까?" 
              onClose={closeDeleteModal} 
            />
        	...
      	</>
    );

}
  • 모달이 추가될때마다 해당 모달의 열림/닫힘 여부와 관련된 상태값을 생성해주어야 한다. -> 이로 인해 새로운 atom과 새로운 커스텀 훅이 매번 생성된다.

  • 모달에 필요한 비즈니스 로직을 매번 추가해주어야한다. 그것도 저 만치 멀리 있는 곳에..

  • 모달이 추가될때마다 관련 컴포넌트를 추가 렌더링 시켜줘야 한다.

모달과 관련된 코드는 요구사항이 늘어남에 따라 비대해지고 가독성이 떨어지게 된다. 이로 인해 추가적인 모달이 생겨야하는 추가적인 요구사항을 반영하기 점점 어려워진다.


🤔 어떻게 해결해 볼 수 있을까?

위에서 서술한 문제점에 따른 요구사항들을 요약하면 아래와 같다.

  • 모달을 사용하는 컴포넌트와 비즈니스 로직간의 격리 -> 비즈니스 로직은 사용하는 컴포넌트 내에 있어야 한다.

  • 모달들의 확장성이 떨어진다 -> 확장성을 고려해야한다.

이를 충족할 수 있는 방법은 논리적으로는 간단하다. 바로 모달을 오픈할 때 모달에 대한 정보를 사용하는 컴포넌트에서 동적으로 제공하는 것이다. ApplyButton 컴포넌트를 예시로 들면

const ApplyButton = () => {
  
  const { openModal } = useModals();
  
  const handleClickButton = () => {
    openModal(
      {
        1. ConfirmModal을 열어라,
      	2. Modal 내 메시지는 "참가 신청하겠습니까?"로 해라,
        3. 클릭시에는 비즈니스_로직을 실행시켜라,
        4. 닫힘 버튼 클릭시에는 모달을 닫아라
      }
    );
  }
  
  
  
  return (
      <Button onClick={handleClickButton}>
        참가하기
      </Button>
  );
};

위와 같이 openModal 이라는 함수에 인자로 관련된 비즈니스 로직을 넘겨 확장성을 만족시킬 수 있을 것이다. 이를 좀 더 구체화해보면 아래와 같이 모달 컴포넌트 자체를 넘기는 식으로 처리할 수 있을 것이다. (아직은 실제로 동작할 수 없는 단계이다.)

const ApplyButton = () => {
  
  const { openModal } = useModals();
  
  const applyForRecruitment = () => {
   	지원_비즈니스_로직(); 
  }
  
  const handleClickButton = () => {
    openModal(
        <ConfirmModal
          message="참가 신청하겠습니까?"
          onConfirmButtonClick={applyForRecruitment}
          onCancelButtonClick={모달 닫기}
          ...
        />
    );
  }
  
  
  
  return (
      <Button onClick={handleClickButton}>
        참가하기
      </Button>
  );
};

즉, 모달과 관련된 로직을 사용하는 측에서 원하는대로 정함으로써 확장성을 고려해 볼 수 있다는 것이다.

그렇다면 사용처에서 모달의 정보들을 다 정해줬으니 전역에서 모달을 띄우는 곳에선 사용처가 정해준 모달을 렌더링만 시켜주면 되지 않을까?

const useModals = () => {
  // 모달들의 목록을 보관하는 상태값
  const [modals, setModals] = useRecoilState(modalOpenAtom);

  // 인자로 모달을 받으면 이를 모달 목록에 추가하기
  const openModal = useCallback((modal) => {
	setModals(modals => [...modals, modal]);
  }, [setModalOpen]);
	
  ...
  
  return {
    modals,
    openModal,
    closeModal,
  };
};


const Modals = () => {
 	const { modals } = useModals();
    
  	return (
      <>
        // 목록에 추가된 모달들을 렌더링시키기
        {modals.map((modal) => <modal />}  
      </>
    );

}

즉, 기존 처럼 모달의 열림/닫힘 상태를 보관하는 대신 필요한 모달 컴포넌트와 그에 대한 정보들을 전역 상태값에 담아두고 이를 렌더링 하는 방식으로 변경하는 것이다. 더 이상 모달을 관리하는 쪽에서는 모달별 비즈니스 로직을 신경 쓸 필요가 없어졌으며 모달이 여러개여도 코드의 가독성이 떨어지지 않도록 구성할 수 있을 것이다.

다시, 해결방법을 정리해보면 아래와 같다.

  • 사용하는 컴포넌트에서 모달에 대한 정보를 지정해준다.
  • 렌더링 할 모달과 그에 대한 정보들을 상태로 보관한다.
  • 모달을 관리하는 측에선 전달받은 모달을 렌더링 시킨다.

🧼 2차 리팩토링 - 모달 컴포넌트들을 상태값으로 보관하기

1. 모달 컴포넌트를 상태값으로 보관하기

우선 모달 컴포넌트들을 상태값으로 보관하도록 useModals 훅을 아래와 같이 바꿔보자.

import { atom, useRecoilState } from 'recoil';

const modalsAtom = atom({
  key: 'modalsAtom',
  default: [],
});

const useModals = () => {
  const [modals, setModals] = useRecoilState(modalsAtom);

  const openModal = useCallback((Component, props) => {
      setModals((modals) => [...modals, { Component, props: { ...props, open: true } }]);
    },
    [setModals]
  );

  const closeModal = useCallback((Component) => {
      setModals((modals) => modals.filter((modal) => modal.Component !== Component));
    },
    [setModals]
  );

  return {
    modals,
    openModal,
    closeModal,
  };
};
  • 이제 modalsAtom은 상태값에 모달 컴포넌트들을 배열형태로 보관하게 된다.
  • openModal 에서는 컴포넌트(함수)와 그의 props를 인자로 받아 상태값에 추가시킨다.
  • closeModal 에서는 컴포넌트(함수)를 인자로 받으면 이를 필터링 시켜 기존의 상태값에서 제거하는 역할을 한다.

2. 상태값으로 보관한 모달들 렌더링하기

이제 전역에서 모달을 렌더링하는 Modals 컴포넌트를 아래와 같이 수정하자.

import ConfirmModal from '@components/common/ConfirmModal';
import ParticipantsModal from '@components/article/ParticipantModal';

// 사용할 모달 컴포넌트들을 담은 Object
export const modals = {
  confirm: ConfirmModal, 
  participants: ParticipantsModal,
};

const Modals = () => {
  const { modals } = useModals();

  return (
    <>
      {modals.map(({ Component, props }, idx) => {
        return <Component key={idx} {...props} />;
      })}
    </>
  );
};
  • modals 객체 : 모달 컴포넌트들을 담고 있다. 이를 이용해 향후 사용처 컴포넌트에서 모달 컴포넌트들의 코드를 import 하지 않고도 모달의 종류를 선택할 수 있게 도와준다.

  • Modals 컴포넌트 : useModals 훅을 통해 modals상태값을 받아와 이를 컴포넌트로 렌더링 시킨다.

3. 모달 종류와 로직 넘겨주기

사용처에서 모달 컴포넌트의 종류와 해당 컴포넌트의 props를 넘겨주는 부분이다. 아래는 ApplyButton 컴포넌트의 예시이다.

import { modals } from '@components/common/Modals';

const ApplyButton = (...) => {

  const { openModal, closeModal } = useModals();

  return (
    <Button
      onClick={() =>
        openModal(modals.confirm, {
          message: '참가 신청하시겠습니까?',
          onConfirmButtonClick: () => {
    			지원_비즈니스_로직();
			    closeModal(modals.confirm);
		  },
          onCancelButtonClick: () => closeModal(modals.confirm),
        })
      }
    >
      참가하기
    </Button>
  );
};
  • 이전에 정의한 modals를 import 하여 필요한 모달이 무엇인지 지정한다.
  • openModal 함수의 인자로 모달 컴포넌트와 그의 props 들을 넘기는 방식이다.

이러한 방식을 통해 모달 로직들의 응집성을 높여 가독성을 높이고 추가적인 모달이 생기더라도 높은 확장성을 가져갈 수 있게 하였다.

✅ 부록1 - 타입스크립트

    <Button
      onClick={() =>
        openModal(modals.confirm, {
          message: '참가 신청하시겠습니까?',
          onConfirmButtonClick: () => {
    			지원_비즈니스_로직();
			    closeModal(modals.confirm);
		  },
          onCancelButtonClick: () => closeModal(modals.confirm),
     	  hello: 'world',
    	  asdadasd: 'asdasdad',
        })
      }
    >
      참가하기
    </Button>

위 예시에서 ConfirmModal에 들어갈 props가 아닌 다른 props를 넣어주면 어떻게 될까? props 부분은 단순 Object로 인식 되기에 다른 이상한 값을 넣어줘도 그대로 통과된다. 이를 방지하기 위해선 아래와 같이 타입 지정을 추가적으로 해줘야한다.

// modalsAtom
import { ComponentProps, FunctionComponent } from 'react';

const modalsAtom = atom<Array<{ Component: FunctionComponent<any>; props: ComponentProps<FunctionComponent<any>> }>>({
  key: `modalsAtom/${uuid()}`,
  default: [],
}); 


// useModals 훅

import { ComponentProps, FunctionComponent, useCallback } from 'react';

const useModals = () => {
  ...

  const openModal = useCallback(
    <T extends FunctionComponent<any>>(Component: T, props: Omit<ComponentProps<T>, 'open'>) => {
      setModals((modals) => [...modals, { Component, props: { ...props, open: true } }]);
    },
    [setModals]
  );

  const closeModal = useCallback(
    <T extends FunctionComponent<any>>(Component: T) => {
      setModals((modals) => modals.filter((modal) => modal.Component !== Component));
    },
    [setModals]
  );

  ...
};
          
// Modals
import { ComponentProps, FunctionComponent } from 'react';
          
import ConfirmModal from '@components/common/ConfirmModal';
import ParticipantsModal from '@components/article/ParticipantModal';
          
export const modals = {
  confirm: ConfirmModal as FunctionComponent<ComponentProps<typeof ConfirmModal>>,
  participants: ParticipantsModal as FunctionComponent<ComponentProps<typeof ParticipantsModal>>,
};
  • modalsAtom 에서는 컴포넌트와 props담은 객체들의 배열의 타입을 정의해주었다.

  • openModalcloseModal 에서는 제너릭을 통해 인자로 받은 컴포넌트로 부터 타입을 유추시킨다. 그래서 openModal의 경우에는 아래와 같이 받는 인자를 해당 컴포넌트의 props에 해당하는 것으로만 제한시킬 수 있다.

  • modals 객체는 모달 컴포넌트들을 담고있다. 여기서는 타입 호환을 위해 FunctionComponent 를 이용해 타입 단언을 시켰다. 타입 단언을 하지 않을 경우 (props: Props) => JSX.Element로 타입이 추론되어 타입을 맞추기 불가능했기 때문에 사용했다.

다만, 여러 컴포넌트에서 호환성을 맞추기 위해 any를 불가피하게 사용하게 되었다 🥲. 최대한 unknown을 쓰고 싶었지만 도저히 호환되는 타입을 정의할 수 없었다. 타입스크립트는 너무 어렵다.

🖖 부록2 - 코드 스플리팅

모달들을 담고있는 modals 쪽 코드를 다시 살펴보자.

import { ComponentProps, FunctionComponent } from 'react';
          
import ConfirmModal from '@components/common/ConfirmModal';
import ParticipantsModal from '@components/article/ParticipantModal';
          
export const modals = {
  confirm: ConfirmModal as FunctionComponent<ComponentProps<typeof ConfirmModal>>,
  participants: ParticipantsModal as FunctionComponent<ComponentProps<typeof ParticipantsModal>>,
};
                                             

모달들을 import 해서 이들을 객체에 담은 뒤 이 객체를 export 시키고 이를 이용해 컴포넌트에서 사용하도록 하고 있다. 그런데 만약 모달의 종류들이 점점 더 많아지면 어떻게 될까?

import { ComponentProps, FunctionComponent } from 'react';
          
import ConfirmModal from '@components/common/ConfirmModal';
import ParticipantsModal from '@components/article/ParticipantModal';
import Modal1 from '...';
import Modal2 from '...';
import Modal3 from '...';
...
          
export const modals = {
  confirm: ConfirmModal as FunctionComponent<ComponentProps<typeof ConfirmModal>>,
  participants: ParticipantsModal as FunctionComponent<ComponentProps<typeof ParticipantsModal>>,
  modal1: Modal1,
  modal2: Modal2,
  modal3: Modal3,
  ...
};
                                             

모달이 늘어나더라도 모달의 개수만 많아질 뿐 코드상으로는 문제가 없으나 문제는 modals 객체를 컴포넌트 단에서 사용하기 위해서 저 수많은 모달들을 모두 import 시켜야 한다는 것이다. 이렇게 되면 모달이 늘어남에따라 번들링되는 자바스크립트가 커지면서 컴포넌트 로딩 성능에도 영향을 미칠 수 있게 되는 것이다.

import loadable from '@loadable/component';

const ConfirmModal = loadable(() => import('@components/common/ConfirmModal'), { ssr: false });
const ParticipantsModal = loadable(() => import('@components/article/ParticipantsModal'), {
  ssr: false,
});

export const modals = {
  confirm: ConfirmModal as FunctionComponent<ComponentProps<typeof ConfirmModal>>,
  participants: ParticipantsModal as FunctionComponent<ComponentProps<typeof ParticipantsModal>>,
};

이를 방지하고자 모달 컴포넌트들에 대해서 코드스플리팅을 적용해볼 수 있다. 코드 스플리팅을 적용하면 해당 모달 컴포넌트가 렌더링 될때만 관련 코드를 로드하도록 하여 모달로 인한 리소스 로딩 성능 저하를 예방할 수 있다. 여기선 loadable 이라는 라이브러리를 사용했으며 기존 프로젝트는 Next.js 기반으로 이루어져 있기 때문에 ssr 옵션을 false 값으로 주도록 했다.

😳 리팩토링 적용

실제 리팩토링이 적용된 코드들은 여기서 살펴볼 수 있다.

⛳️ 출처 및 참고자료

profile
🏠 버튼을 누르면 더 많은 글들을 보실 수 있습니다
post-custom-banner

3개의 댓글

comment-user-thumbnail
2023년 1월 28일

선댓글 후감상, 목차만 봐도 엄청 정성스러운 글 같습니다. 잘 읽겠습니다!

1개의 답글
comment-user-thumbnail
2024년 5월 1일

예시처럼 중복되는 여러버튼에 동일한 모달창을 띄워야 해서 어떻게 하나했는데 완벽한 예시를 찾은거 같아요! 잘 읽겠습니다!

답글 달기