[Final Project] 공용 모달 커스텀 훅

liinyeye·2024년 8월 9일
0

Project

목록 보기
40/44

🔎 문제 상황

팀원분이 만들어주신 zustand를 사용한 모달 공용 컴포넌트가 React Portal 과 충돌되는 문제가 발생해 새롭게 공용 모달 컴포넌트를 구현해야했음

요구 사항

  • 버튼이 1개 또는 2개
  • 버튼 컬러가 동작에 따라 달라짐
  • 버튼 클릭 후 다른 액션이 일어날 수도 있음
  • 반응형 디자인
  • 모달은 각 페이지에서 독립적으로 띄워짐 (상태 공유 x)

💡 해결 과정

1. Context API vs Custom Hook 비교

Context API

장점

  • 전역 상태 관리가 가능하여 여러 컴포넌트에서 모달 상태 공유 용이
  • 중첩된 컴포넌트에서도 Props Drilling 없이 모달 상태 접근 가능
  • 모달 관련 로직을 중앙 집중화하여 관리 가능

단점

  • 작은 규모의 프로젝트에서는 과도한 보일러플레이트 코드 발생
  • 불필요한 리렌더링이 발생할 수 있음
  • 모달 간 상태 공유가 필요 없는 경우에도 전역 상태로 관리되어 오버엔지니어링될 수 있음

Custom Hook

장점

  • 각 컴포넌트에서 독립적으로 모달 상태 관리 가능
  • 더 간단하고 직관적인 구현
  • 필요한 컴포넌트에서만 모달 로직을 사용할 수 있어 코드 분리가 깔끔함
  • React Portal과의 충돌 가능성이 낮음

단점

  • 여러 모달 간의 상태 공유가 필요한 경우 구현이 복잡해질 수 있음
  • 동일한 로직이 여러 컴포넌트에서 중복될 수 있음

2. Custom Hook 방식 채택

이유

1. 프로젝트 요구사항 부합

  • 모달은 각 페이지에서 독립적으로 동작하며, 모달 간 상태 공유가 필요하지 않았음
  • 간단한 확인/취소 동작만 필요한 모달이 대부분이었음

2. 기술 스택 충돌 방지

  • React Modal Portal을 사용하고 있었고, 이전에 Zustand와 같은 상태관리 도구 사용 시 충돌이 발생한 경험이 있었음
  • Custom Hook 방식은 각 컴포넌트에서 자체적으로 모달 상태를 관리하기 때문에 Portal과 충돌 위험이 낮음

3. 프로젝트 규모 최적화

  • 현재 프로젝트 규모에서는 모달만을 위해 Context API를 도입할만큼 복잡한 상태 관리가 필요하지 않았음
  • Custom Hook으로도 충분히 모달 기능을 구현할 수 있었고, 오히려 더 깔끔한 코드 구조를 만들 수 있었음

4. 유지보수성

  • 각 컴포넌트에서 필요한 모달 로직만 가져다 사용할 수 있어 코드 이해가 쉬움
  • 모달 관련 로직이 캡슐화되어 있어 수정이 용이함

코드 참고

"use client";
import CloseBtn from "@/components/icons/modal/CloseBtn";
import ModalBtn from "@/components/modal/ModalBtn";
import React, { useCallback, useEffect, useState } from "react";
import ReactModal from "react-modal";

type ButtonConfig = {
  text: string;
  style: string;
};

type ModalConfig = {
  message: string;
  confirmButton: ButtonConfig;
  cancelButton?: ButtonConfig;
};

const useModal = () => {
  const [isModalOpen, setIsModalOpen] = useState(false);
  const [config, setConfig] = useState<ModalConfig>({
    message: "",
    confirmButton: { text: "", style: "" },
    cancelButton: undefined
  });
  const [onConfirm, setOnConfirm] = useState<(() => void) | null>(null);

  const openModal = useCallback((newConfig: ModalConfig, confirmCallback?: () => void) => {
    setConfig(newConfig);
    setIsModalOpen(true);
    setOnConfirm(() => confirmCallback || null);
  }, []);

  const closeModal = useCallback(() => {
    setIsModalOpen(false);
    setOnConfirm(null);
  }, []);

  const handleConfirm = useCallback(() => {
    if (onConfirm) {
      onConfirm();
    }
    closeModal();
  }, [onConfirm, closeModal]);

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

  const getButtonStyle = (style: string) => {
    switch (style) {
      case "확인":
        return "bg-system-red200 text-system-white";
      case "취소":
        return "bg-system-white border border-solid border-gray-400 text-system-black";
      case "삭제":
        return "bg-system-red200 text-system-white";
      case "시스템":
        return "bg-gradient-pai400-fai500-br text-system-white hover:border-paiTrans-60032 active:bg-gradient-pai600-fai700-br";
      default:
        return style;
    }
  };

  const Modal = () => {
    console.log();
    return (
      <ReactModal
        isOpen={isModalOpen}
        onRequestClose={handleCancel}
        className="text-center bg-whiteTrans-wh72 mobile:w-[calc(100%-32px)] mx-auto rounded-[32px] p-6 desktop:w-[343px] outline-none"
        overlayClassName="fixed inset-0 bg-modalBg-black40 backdrop-blur-md z-[10000] flex items-center justify-center"
        ariaHideApp={false}
        shouldCloseOnEsc={true}
        shouldCloseOnOverlayClick={true}
      >
        <div className="mb-5 relative">
          <CloseBtn btnStyle={"absolute right-0 top-[-6px] cursor-pointer"} onClick={handleCancel} />
          <div className="flex flex-col min-h-16 items-center justify-center">
            {config.message.split("\n").map((line, index) => (
              <React.Fragment key={index}>
                <span className="flex items-center justify-center font-medium text-gray-900 text-base leading-[27px]">
                  {line}
                </span>
              </React.Fragment>
            ))}
          </div>
        </div>
        <div className="flex justify-center gap-2">
          {config.cancelButton && (
            <ModalBtn
              className={getButtonStyle(config.cancelButton.style)}
              onClick={handleCancel}
              text={config.cancelButton.text}
            />
          )}
          <ModalBtn
            className={getButtonStyle(config.confirmButton.style)}
            onClick={handleConfirm}
            text={config.confirmButton.text}
          />
        </div>
      </ReactModal>
    );
  };

  return {
    isModalOpen,
    openModal,
    closeModal,
    Modal
  };
};

export default useModal;

동작 흐름

  • 모달 열기 → openModal 호출 → onConfirm 상태에 함수 저장
  • 사용자 확인 버튼 클릭 → handleConfirm 호출 → 저장된 onConfirm 함수 실행 → 페이지 이동 또는 다른 동작 수행
  1. 모달이 열릴 때 (openModal 호출 시)
    • openModal 함수는 두 개의 인자를 받음
    • 모달 설정(newConfig)과 확인 시 실행할 콜백 함수(confirmCallback).
    • 이 때, setOnConfirm(() => confirmCallback || null)로 onConfirm 상태를 설정
    • 여기서 () => confirmCallback || null은 함수를 반환하는 함수. 이는 confirmCallback을 즉시 실행하지 않고, 나중에 실행할 수 있도록 저장
  2. 사용자가 확인 버튼을 누를 때
    • handleConfirm 함수가 호출
    • 이 함수는 if (onConfirm) { onConfirm(); } 를 실행
    • 여기서 onConfirm은 1단계에서 저장해 둔 함수를 실행
  3. 결론
    • () => confirmCallback || null로 wrapping함으로써, confirmCallback의 실행을 지연
    • 이로 인해 모달이 열리는 시점에는 아무 일도 일어나지 않고, 사용자가 확인 버튼을 클릭할 때만 실제로 confirmCallback이 실행

트러블슈팅

❗️모달에서 확인 버튼을 누를 시, 다른 페이지로 이동해야하는데 모달을 열자마다 다른 페이지로 이동하는 오류 발생

문제 분석

onComfirm에 함수가 아니라 함수값을 저장 시 문제

  • 이 경우, confirmCallback 함수 자체가 아니라 그 함수의 실행 결과가 저장
  • 따라서 모달이 열리는 순간 confirmCallback이 실행되어 즉시 페이지 이동이 일어남
const openModal = useCallback((newConfig: ModalConfig, confirmCallback?: () => void) => {
    setConfig(newConfig);
    setIsModalOpen(true);
    setResult(null); 
    setOnConfirm(confirmCallback || null); // 함수값을 저장
  }, []);

해결 방안

openModal 실행 시, setOnConfirm에 함수 자체를 넣어주어 confirmCallback함수가 실행될 수 있도록 변경

  const openModal = useCallback((newConfig: ModalConfig, confirmCallback?: () => void) => {
    setConfig(newConfig);
    setIsModalOpen(true);
    setOnConfirm(() => confirmCallback || null); // 함수 자체를 저장
  }, []);

컴포넌트에서 사용 방법

 const { openModal, Modal } = useModal();

openModal(
          // 첫번째 인자는 모달창 스타일과 관련된 내용
        {
          message: /* 모달창에 들어갈 텍스트 string으로 넣기 */,
          confirmButton: { text: /* 버튼에 들어갈 텍스트 string으로 넣기*/, style: /* useModal 훅에서 getButtonStyle 함수 switch문 case에 있는 텍스트 확인 후 맞는 케이스 넣기*/}
          // cancelButton 필요 시 넣기 (현재는 회원탈퇴 페이지에서만 쓰임)
        },
        // 확인 버튼 클릭 시 실행될 함수 두번째 인자로 넣기
        () => {
          router.push("/login");
        }
      );

사용 예시 1 : 취소 버튼 없는 경우

필수 인자인 message, confirmButton만 넣어 사용

"use client";
import { AIType } from "@/types/chat.session.type";
import SessionBtn from "./_components/SessionBtn";
import { Metadata } from "next";
import useModal from "@/hooks/useModal";
import { useRouter } from "next/navigation";

const metadata: Metadata = {
  title: "PAi 채팅 페이지",
  description: "PAi/FAi 채팅 페이지입니다.",
  keywords: ["chat", "assistant", "friend"],
  openGraph: {
    title: "채팅 페이지",
    description: "PAi/FAi 채팅 페이지입니다.",
    type: "website"
  }
};

const aiTypes: AIType[] = ["assistant", "friend"];

const ChatPage = () => {
  // TODO : 여기서 리스트 불러오면 prefetch 사용해서 렌더링 줄이기
  const { openModal, Modal } = useModal();
  const router = useRouter();

  const handleUnauthorized = () => {
    openModal(
      {
        message: "로그인 이후 사용가능한 서비스입니다.\\n로그인페이지로 이동하시겠습니까?",
        confirmButton: { text: "확인", style: "시스템" }
      },
      () => router.push("/login")
    );
  };

  return (
    <>
// 모달창 띄우는 컴포넌트에 모달 컴포넌트 넣기
// 하단이든 상단이는 위치는 상관없지만 상단에 두었을 때 모달이 쓰인다는 것을 직관적으로 알 수 있어 상단에 위치시키는 것을 더 권장
      <Modal />
      <div className="gradient-container w-full h-full rounded-t-[60px]">
        <div className="gradient-rotated gradient-ellipse w-full h-[90%]"></div>
        <div className="relative z-10 w-full h-full">
          <div className="flex flex-col items-center justify-center w-full h-full">
            <span className="text-gray-600 font-medium text-lg">어떤 파이와 이야기해 볼까요?</span>
            <div className="flex flex-col p-4 gap-6">
              {aiTypes.map((aiType) => (
                <SessionBtn key={aiType} aiType={aiType} handleUnauthorized={handleUnauthorized} />
              ))}
            </div>
          </div>
        </div>
      </div>
    </>
  );
};

export default ChatPage;

사용 예시 2 : 취소 버튼 있는 경우

3번째 인자로 cancelButton 추가하기

  const handleClickDelete = () => {
    if (!isAgreement) {
      toast.warn("회원 탈퇴 유의사항에 동의해주세요.");
      return;
    }
    openModal(
      {
        message: "정말 탈퇴하시겠어요?",
        confirmButton: { text: "확인", style: "확인" },
        cancelButton: { text: "취소", style: "취소" }
      },
      handleDeleteAccount
    );
  };

🚀 결론

Custom Hook 방식 채택으로 필요한 컴포넌트에서만 모달 상태 관리하여 불필요한 리렌더링 줄이고 성능 최적화, 코드 재사용성 및 유지보수 용이성 향상

https://github.com/yeliinbb/Ai-todo-app/issues/159

profile
웹 프론트엔드 UXUI

0개의 댓글