프로젝트/Next+TypeScript

[Coworkers] 공통 컴포넌트 Modal

dev-hpk 2025. 1. 20. 16:10

고급 프로젝트 Coworkers의 시작❗

저는 Modal 공통 컴포넌트를 맡게 되었습니다.

 

작업을 시작하기 전에 지난 프로젝트에서 팀원분께서 만들어주신 Modal 컴포넌트를 사용할 때 좋았던 점들과 불편했던 점들을 생각해 봤어요🤔

좋은 점

  • Modal의 Container만 만들어두고 Content는 children으로 받아 사용할 수 있어 편했음
  • Modal의 dim 영역을 클릭했을 때 닫히는 기능이 있어 편했음

불편한 점

  • Modal 컴포넌트를 사용하는 페이지에서 모달의 open 관련 State를 선언하고 관리해야 해서 불편했음
  • DOM 계층의 Depth가 깊어지면 z-index 관련해서 신경쓸 부분이 많아 불편했음
  • Modal의 dim과 컨텐츠가 각각 Dim, Modal 컴포넌트로 분리되어 있어 불편했음

위 내용을 고려하면서 작업 목표를 아래처럼 작성했습니다.

modal 작업 목표

 

useModal 커스텀 훅

import { useState } from 'react';

interface ModalHookProps {
  initialState?: boolean;
}

function useModal({ initialState = false }: ModalHookProps = {}) {
  const [isOpen, setIsOpen] = useState(initialState);

  const openModal = () => setIsOpen(true);
  const closeModal = () => setIsOpen(false);

  return { isOpen, openModal, closeModal };
}

export default useModal;
  • initialState : 모달의 초기 상태가 꼭 닫혀있는 것은 아닌 것 같아서 추가했습니다. 웹이나 앱을 이용하다 보면 광고나 공지 같은 모달이 열려있는 상태로 진입하는 경우를 많이 볼 수 있잖아요😀 

Modal 컴포넌트

'use client';

import { PropsWithChildren, useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import IconClose from '@/app/components/icons/IconClose';

interface ModalProps {
  hasCloseBtn?: boolean;
  portalRoot?: HTMLElement;
  closeModal: () => void;
}

function Modal({
  hasCloseBtn = false,
  portalRoot,
  closeModal,
  children,
}: PropsWithChildren<ModalProps>) {
  const [isMounted, setIsMounted] = useState(false);
  const modalRef = useRef<HTMLDivElement | null>(null);

  useEffect(() => {
    setIsMounted(true);

    // 모달 외부 클릭 시 닫히도록 이벤트 처리
    const handleClickOutside = (event: MouseEvent) => {
      if (modalRef.current && modalRef.current.contains(event.target as Node)) {
        closeModal();
      }
    };

    // 마운트 시 이벤트 리스너 추가
    document.addEventListener('mousedown', handleClickOutside);

    // clean-up : 언마운트 시 이벤트 리스너 제거
    return () => {
      document.removeEventListener('mousedown', handleClickOutside);
    };
  }, [closeModal]);

  if (!isMounted) return null;

  return createPortal(
    <div className="fixed inset-0 z-50 flex flex-col items-center justify-center tablet:justify-end">
      {/* 모달 dim 영역 - 클릭 시 모달 닫힘 */}
      <div ref={modalRef} className="absolute inset-0 bg-black opacity-50" />
      <div className="relative flex w-96 flex-col items-center rounded-xl bg-background-secondary pb-8 pt-12 tablet:w-full tablet:rounded-b-none">
        {hasCloseBtn && (
          <button
            type="button"
            className="absolute right-4 top-4 h-6 w-6"
            title="모달 닫기"
            onClick={closeModal}
          >
            <IconClose />
          </button>
        )}
        {children}
      </div>
    </div>,
    portalRoot || document.body,
  );
}

export default Modal;

Props

  • hasCloseBtn : 닫기 버튼(X) 유무 설정
  • portalRoot : Modal을 렌더링할 상위 DOM 노드, body 하위가 아닌 다른 곳에 렌더링 할 경우를 고려해 prop으로 추가했습니다.
  • closeModal : Modal 닫기 함수
  • children : Modal 내부에 렌더링될 컨텐츠

CreatePortal()

  • portalRoot || document.body : portalRoot prop을 지정하지 않으면 기본적으로 body 하위에 모달을 렌더링 하도록 설정했습니다.

ModalRef

  • 기존에는 dim 영역 <div>의 onClick 이벤트 핸들러에 closeModal()을 사용했습니다.
  • dropdown 작업을 하신 팀원분이 useRef를 이용해 닫기 이벤트를 처리하셔서, 공통으로 뺄 수 있을 것 같아 논의 후 수정했습니다.

if (!isMounted) return null;

  • SSR 환경에서 internal server error(500)이 발생하는 문제를 해결하기 위해, modal이 마운트 되기 전에 null을 리턴하도록 했습니다.

🌈 작업 완료 & 테스트

모달 동작 테스트

 

Modal이 body 하위에 잘 렌더링 되고 dim 영역 클릭 시 닫히는 기능도 잘 동작합니다.

 

이렇게 마무리하나 싶었는데, 스크럼 회의 때 애니메이션을 추가했으면 좋겠다는 의견이 나왔어요👀

의견을 듣고 Modal을 동작시켜 보니 좀 심심해 보이긴 하네요😅😅

수정 전

export function Home() {
    const {isOpen, openModal, closeModal} = useModal();
    
    const handleClose = () => {
    	/* TODO */
    	closeModal();
    }
    
    return (
      <div>
        {isOpen && (<Modal closeModal={handleClose}>모달 컨텐츠</Modal>)}
      </div>
    )
}

 

기존 Modal은 아래 코드처럼 조건부로 렌더링을 관리했어요.

그런데 이 방법으로는 애니메이션을 추가해도 적용이 안되네요.. 다른 방법을 찾아야 할 것 같습니다😭

 

여러 프레임워크의 Modal 컴포넌트를 찾아보다가 Bootstrap의 Modal이 가장 적합한 것 같아 참고하기로 했습니다❗

수정 후

export function Home() {
    const {isOpen, openModal, closeModal} = useModal();
        
    const handleClose = () => {
    	/* TODO */
    	closeModal();
    }
    
    return (
      <div>
        <Modal isOpen={isOpen} closeModal={handleClose}>모달 컨텐츠</Modal>
      </div>
    )
}
'use client';

import { PropsWithChildren, useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import IconClose from '@/app/components/icons/IconClose';

interface ModalProps {
  hasCloseBtn?: boolean;
  portalRoot?: HTMLElement;
  isOpen: boolean;
  closeModal: () => void;
}

function Modal({
  hasCloseBtn = false,
  portalRoot, // Modal 렌더링할 상위 DOM 노드
  closeModal,
  isOpen,
  children,
}: PropsWithChildren<ModalProps>) {
  const [renderModal, setRenderModal] = useState(isOpen);
  const modalRef = useRef<HTMLDivElement | null>(null);

  useEffect(() => {
    if (isOpen) {
      setRenderModal(true); // isOpen이 true면 렌더링 활성화
    }
  }, [isOpen]);

  const handleAnimationEnd = () => {
    if (!isOpen) {
      setRenderModal(false); // 애니메이션 종료 후 렌더링 중단
    }
  };

  useEffect(() => {
    // 모달 외부 클릭 시 닫히도록 이벤트 처리
    const handleClickOutside = (event: MouseEvent) => {
      if (modalRef.current && modalRef.current.contains(event.target as Node)) {
        closeModal();
      }
    };

    // 마운트 시 이벤트 리스너 추가
    document.addEventListener('mousedown', handleClickOutside);

    // clean-up : 언마운트 시 이벤트 리스너 제거
    return () => {
      document.removeEventListener('mousedown', handleClickOutside);
    };
  }, [closeModal]);

  if (!renderModal) return null;

  return createPortal(
    <div
      className={`tablet:justify-center fixed inset-0 z-50 flex flex-col items-center justify-end transition-opacity ${
        isOpen ? 'opacity-100' : 'pointer-events-none hidden opacity-0'
      }`}
      style={{ display: renderModal ? 'flex' : 'none' }}
    >
      <div ref={modalRef} className="absolute inset-0 bg-black opacity-50" />
      <div
        className={`tablet:w-96 tablet:rounded-b-xl relative flex max-h-[80%] w-full transform flex-col items-center overflow-y-hidden rounded-t-xl bg-background-secondary pb-8 pt-12 transition-transform ${isOpen ? 'translate-y-0' : 'translate-y-4'}`}
        onTransitionEnd={handleAnimationEnd}
      >
        {hasCloseBtn && (
          <button
            type="button"
            className="absolute right-4 top-4 h-6 w-6"
            title="모달 닫기"
            onClick={closeModal}
          >
            <IconClose />
          </button>
        )}
        {children}
      </div>
    </div>,
    portalRoot || document.body,
  );
}

export default Modal;

Props

  • isOpen : Modal 렌더링과 애니메이션 관리를 위해 prop으로 추가

renderModal

  • Modal의 렌더링 여부를 결정하기 위한 State로 isOpen prop의 값에 따라 토글 됩니다.

onTransitionEnd={handleAnimationEnd}

  • 애니메이션이 종료된 후 렌더링을 중단하기 위해 추가
    const handleAnimationEnd = () => {
        if (!isOpen) {
          setRenderModal(false); // 애니메이션 종료 후 렌더링 중단
        }
      };

🌈 애니메이션 동작 화면

모달 애니메이션 적용

 

tailwind CSS를 처음 접하다 보니 작업 속도가 많이 느리고, 애니메이션이나 UX 적인 부분을 작업 초반에 고려하지 못해 많은 수정을 했네요..😅

 

그래도 여러 번 수정 끝에 좋은 결과물을 만들어 내고, 팀원들에게 긍정적인 코드 리뷰를 받아서 기분이 좋네요🎉🎉

코드 리뷰
코드 리뷰

저도 많이 부족한데 하나 배워 가신다니 머쓱하지만 더 열심히 해야겠다는 의지가 불타오르네요🔥🔥