FSD 아키텍처로 구현하는 모달

IT/공통라이브러리 2025. 6. 10. 00:06

개요

모달은 특정 컨택스트에서 사용자에게 정보를 전달하거나, 상호작용을 하기 위해 사용할 수 있는 UI로 정보, 경고, 오류 메시지를 주로 전달한다.

 

 

정보, 경고, 오류의 모달창의 경우 alert 형태로 주로 간단한 메세지를 주로 보여주며, 거의 동일한 UI를 가지고 있다.

그리고 Memo를 입력하는 모달창 처럼, 사용자로부터 텍스트를 입력받거나 상호작용을 하는 경우도 있다. 이런 경우에는 각 용도에 따라 UI 구성이 달라진다.

 

보통 모달은 다른 페이지보다 높은 우선 순위로 랜더링되어 화면에 보여지면 보통은 position: fixed 속성을 사용한다. 또한 한번에 여러 모달창을 띄우는 경우도 있으므로 여러 모달창을 중첩하여 띄울 수 있도록 스택형태로 모달창을 출력해본다.

 

본문

FSD 아키텍처를 사용하여 여러 모달들을 띄울 수 있게 구성해본다.

 

FSD 아키텍처 구성도

app

-- test

---- page.jsx

provider

-- modalProvider.jsx

entities

-- lib

---- modalRegitry.js

-- model

---- modalStore.js

-- features

---- memo

------ modal

-------- memoModal.jsx

-- shared

---- ui

------ modal

--------baseAlertModal.jsx

 

 

해당 FSD 아키텍처 구성에서 baseAlertModal.jsx는 아래와 같이 공통된 Alert 형태의 UI에서 정보, 경고, 오류와 같은 타입별로 내부 구성요소를 달리 표현하여 재사용성을 높이고, message로는 Componet를 입력받아, 단순한 텍스트만 출력하는 것이 아닌 개발자가 원하는 UI 컴포넌트를 자유롭게 출력할 수 있게 했다.

 

shared/ui/modal/baseAlertModal.jsx

import { IconImage } from "../image";
import { Button } from "../button";
import clsx from "clsx";

export const BaseAlertModal = ({
  type,
  title,
  message,
  onCancel = () => {},
  onClose = () => {},
  onConfirm = () => {},
  confirmText = "확인",
  cancelText = "취소",
  showCancel = false,
  showConfirm = true,
}) => {
  const iconName = {
    warn: "MODAL_WARN",
    error: "MODAL_BAD",
    success: "MODAL_GOOD",
  }[type];

  const onCancelHandler = () => {
    onCancel();
    onClose();
  };

  const onConfirmHandler = () => {
    onConfirm();
    onClose();
  };

  return (
    <div
      className={clsx(
        "relative",
        "flex",
        "flex-col",
        "justify-center",
        "px-20",
        "pt-40",
        "pb-20",
        "overflow-y-auto",
        "w-400",
        "bg-white",
        "rounded-[10px]"
      )}
    >
      <button onClick={onCancelHandler} className="absolute top-20 right-20">
        <IconImage size={30} name={"MODAL_CLOSE"} />
      </button>
      {/* icon image */}
      <figure className="flex justify-center mb-10">
        <IconImage size={80} name={iconName} />
      </figure>

      {/* title */}
      <h1 className="mb-12 text-center style-text-title-01-B">{title}</h1>

      {/* content */}
      <div className="mb-40 text-center style-text-title-02-M">
        {message && message()}
      </div>

      {/* buttons */}
      <div className="flex gap-10">
        {/* cancel button*/}
        {showCancel && (
          <Button onClick={onCancelHandler} variant={"modal"} type={"default"}>
            {cancelText}
          </Button>
        )}

        {showConfirm && (
          <Button onClick={onConfirmHandler} variant={"modal"} type={"primary"}>
            {confirmText}
          </Button>
        )}
      </div>
    </div>
  );
};

 

 

 

해당 모달 컴포넌트를 페이지에 출력하기 위해서는 layout.jsx에서 provider 컴포넌트를 추가해야 하며, modal provider는 모달 entities의 store로 부터 출력할 모달 컴포넌트를 찾아서 랜더링을 해준다.

 

provider/modalProvider.jsx

"use client";
import { useModalStore } from "@/entities/modal/model/modalStore";
import { modalRegistry } from "@/entities/modal/lib/modalRegistry";

export const ModalProvider = () => {
  const { stack, closeModal } = useModalStore();

  return (
    <>
      {stack.map(({ name, props }, idx) => {
        const ModalComponent = modalRegistry[name];

        console.log("name", name);
        console.log("props", props);

        if (!ModalComponent) {
          return null;
        }

        const isTop = idx === stack.length - 1;

        return (
          <div
            key={idx}
            className="fixed inset-0 flex justify-center items-center bg-[#00000020]"
            style={{ zIndex: 1000 + idx }}
          >
            <div onClick={(e) => e.stopPropagation()}>
              <ModalComponent
                {...props}
                onClose={isTop ? closeModal : () => {}}
              />
            </div>
          </div>
        );
      })}
    </>
  );
};

 

 

modalProvider에서는 중첩 모달창을 출력하기 위해, modalStore로부터 stack 배열에 담긴 컴포넌트를 차례대로 출력하며, 최상위 모달컴포넌트에만 onClose 핸들러를 할당하고 그 이외의 모달창에는 onClose 핸들러를 비활성화 시키는 방법으로 예상치못하게 하위 모달창이 닫히는 것을 방지한다. 그리고 여러 모달창을 중첩하기 위해, zIndex를 1씩 올린다.

 

modalProvider는 useModalStore와 modalRegistry를 참조하는데, useModalStore는 전역 상태를 관리하며, 사용자에게 출력할 모달 컴포넌트가 저장되고, modalRegistry는 출력할 모달 컴포넌트를 참조한다.

 

useModalStore에는 출력할 모달 종류와 모달 컴포넌트에게 전달할 매개변수를 가지고 있고, modalProvider에서는 useModalStore의 출력할 모달 컴포넌트를 modalRegitry에서 찾아서 랜더링을 하는 방식이다.

 

따라서 해당 코드 구성에서는 modalRegistry에 사용할 모달 컴포넌트를 추가할 필요가 있다.

 

entities/modal/lib/modalRegistry.js

import { BaseAlertModal } from "@/shared/ui/modal/baseAlertModal";
import { MemoModal } from "@/features/memo/modal/memoModal";
import { ClientDetailModal } from "@/features/clientDetail/modal/clientDetailModal";

export const modalRegistry = {
  alert: BaseAlertModal,
  memo: MemoModal,
  clientDetail: ClientDetailModal,
};

 

modalRegitry는 key로 모달 이름을 가지고, useModalStore.js에서 stack 배열에 어떤 모달을 사용할 것인지 모달 이름을 추가한다.

 

entities/modal/model/useModalStore.js

import { create } from "zustand";

export const useModalStore = create((set) => ({
  stack: [],

  openModal: (name, props = {}) =>
    set((state) => ({
      stack: [...state.stack, { name, props }],
    })),

  closeModal: () =>
    set((state) => ({
      stack: state.stack.slice(0, -1),
    })),

  clearAll: () => set({ stack: [] }),
}));

 

 

 

BaseAlertModal 과 MemoModal을 사용하는 예시 코드는 아래와 같다.

 

BaseAlertModal

import { useModalStore } from "@/entities/modal/model/modalStore";
import { IconImage } from "@/shared/ui/image";

export const FailReasonButton = ({ id }) => {
  const { openModal } = useModalStore();

  ...

  const handleClick = () => {
    openModal("alert", {
      type: "error",
      title: "자동화 신청이 실패하였습니다.",
      message: () => (
        <>
          <div className="mb-20 leading-24">
            아래의 내용을 검토 후 정보를 수정해 주세요.
          </div>

          <div className="flex flex-col gap-10 overflow-scroll max-h-220">
            {reasons?.map((reason) => {
              const { insurer, failReason } = reason;
              return (
                <div className="bg-sub-01 rounded-[6px] p-16 flex gap-6 flex-col text-left">
                  <h2 className="text-main style-text-body-02-B">{insurer}</h2>
                  <p className="style-text-title-02-M">{failReason}</p>
                </div>
              );
            })}
          </div>
        </>
      ),
      confirmText: "확인",
      onConfirm: () => {
        console.log("확인됨");
      },
    });
  };

  return (
	...
  );
};

 

 

 

memo 모달

    openModal("memo", { id });

 

features/memo/modal/memoModal.jsx

import {
  ModalContainer,
  ModalHeader,
  ModalBody,
  ModalTitle,
  ModalFooter,
} from "@/shared/ui/modal";
import { FormProvider, useForm } from "react-hook-form";
import { useRegistClientMemo } from "../api/memo";
import { MemoInput } from "..";
import { Button } from "@/shared/ui/button";
import { useModalStore } from "@/entities/modal/model/modalStore";

export const MemoModal = ({ id }) => {
  const form = useForm();
  const { reset, handleSubmit } = form;
  const { closeModal } = useModalStore();

  const { mutate } = useRegistClientMemo({
    onSuccess: (data) => {
      console.log("success", data);
    },
  });

  const onCancel = () => {
    console.log("취소");
    reset();
    closeModal();
  };

  const onConfirm = () => {
    console.log("저장");
    handleSubmit((data) => {
      console.log("메모작성 완료", data);
      mutate({ id, data });
    })();
    closeModal();
  };

  return (
    <ModalContainer variant="memo">
      <ModalHeader variant="memo">
        <ModalTitle>메모 작성</ModalTitle>
      </ModalHeader>

      <ModalBody variant="memo">
        <FormProvider {...form}>
          <MemoInput />
        </FormProvider>
      </ModalBody>

      <ModalFooter variant="memo">
        <Button
          onClick={onCancel}
          variant={"modal"}
          type={"default"}
          className={"w-100"}
        >
          취소
        </Button>
        <Button
          onClick={onConfirm}
          variant={"modal"}
          type={"secondary"}
          className={"w-100"}
        >
          저장
        </Button>
      </ModalFooter>
    </ModalContainer>
  );
};

 

 

마무리

fsd 아키텍처로 모달 컴포넌트를 구성하는 첫번째 작업으로, 여러 폴더 구성요소들간 연결점을 설정하고 규칙을 설정하는 것이 생각보다 복잡하다는 생각이 들었다.

'IT > 공통라이브러리' 카테고리의 다른 글

FSD 아키텍처 - fsd-cli  (0) 2025.04.10
FSD 아키텍처 - 공식 사이트  (0) 2025.04.09
소스코드 정리 - 주석  (0) 2025.01.09
grid 레이아웃 구성  (0) 2024.12.12
editor.js + nextjs header 이슈  (0) 2024.12.11