개발을 하다 보면 모달을 자주 사용하게 되는데, 내가 모달을 잘 관리하고 있는지 의문이 들었습니다.
그동안 다양한 방식으로 모달을 구현하고 리팩토링하면서 몇 가지 문제점을 발견하게 되었는데 이번 글에서는 기존의 모달 구현 방식에서 발견한 문제점들과, 이를 해결하기 위해 Redux-Toolkit을 사용했지만, 최종적으로 Zustand로 전환하게 된 이유에 대해 정리하고자 합니다.
그동안의 모달 관련 글들...
Redux-toolkit 을 사용해 모달 전역으로 관리하기
아래 블로그에 도움을 많이 받았습니다.
모달 관리에 대한 고민을 하던 중, 여러 코드를 참고하게 되었고 그 중에서도 제가 원하는 방향과 가장 유사하게 구현된 코드가 있었는데 아래 글에서 많이 배웠습니다!
기본적인 모달 구현 코드는 useState로 모달의 상태를 관리하고, 모달을 열고 닫는 함수를 버튼에 연결하여 상태가 true일 때 모달을 띄우는 방식입니다. 아래는 기본적인 모달 관리 코드의 예시입니다.
const [isModal1Open, setIsModal1Open] = useState(false);
const [isModal2Open, setIsModal2Open] = useState(false);
const [isModal3Open, setIsModal3Open] = useState(false);
const openModal1 = () => setIsModal1Open(true);
const closeModal1 = () => setIsModal1Open(false);
const openModal2 = () => setIsModal2Open(true);
const closeModal2 = () => setIsModal2Open(false);
// ...(생략)
return (
<div>
<button onClick={openModal1}>모달 (1) 띄우기</button>
<button onClick={openModal2}>모달 (2) 띄우기</button>
<button onClick={openModal3}>모달 (3) 띄우기</button>
{isModal1Open && (
<Modal closeModal={closeModal1}>
<Component1 closeModal={closeModal1} />
</Modal>
)}
{isModal2Open && (
<Modal closeModal={closeModal2}>
<Component2 closeModal={closeModal2} />
</Modal>
)}
{isModal3Open && (
<Modal closeModal={closeModal3}>
<Component3 closeModal={closeModal3} />
</Modal>
)}
</div>
);
1. 모달과 로직의 강한 결합
각 모달을 열고 닫는 로직이 모달 자체와 강하게 결합되어 있습니다. 모달이 추가될 때마다 상태와 함수를 각각 정의해주어야 하며, 이는 코드의 반복과 복잡성을 증가시킵니다.
2. 모달 사용의 제약
모달과 모달을 사용하는 컴포넌트 간에 props를 전달해야 하며, 이로 인해 컴포넌트들이 강하게 결합됩니다.
또한, 각 모달마다 동일한 방식으로 props를 전달해야 하므로 재사용성이 떨어집니다.
위의 문제점을 해결하기 위해 useModal이라는 커스텀 훅을 만들어 모달 상태 관리 로직을 분리해보았습니다.
useModal 훅을 사용하면 상태와 모달을 열고 닫는 함수를 일관되게 사용할 수 있고 로직을 캡슐화함으로써 반복적인 코드 작성을 줄일 수 있었습니다.
그러나 useModal 훅을 사용한 방식에도 여전히 몇 가지 한계가 있었습니다. 모달을 열고 닫는 로직이 모달 컴포넌트와 강하게 연결되어 있었고, 각 모달을 사용하는 컴포넌트 간에 props를 계속해서 전달해야 하는 문제가 여전히 남아 있었습니다.
마찬가지로 isOpen && <Modal />
과 같은 문법은 선언적이지 못했습니다.
const { isOpen, openModal, closeModal } = useModal();
const { isOpen: isOpen2, openModal: openModal2, closeModal: closeModal2 } = useModal();
return (
<div>
<button onClick={openModal}>모달 (1) 띄우기</button>
<button onClick={openModal2}>모달 (2) 띄우기</button>
{isOpen && (
<Modal closeModal={closeModal}>
<Component1 closeModal={closeModal} />
</Modal>
)}
{isOpen2 && (
<Modal closeModal={closeModal2}>
<Component2 closeModal={closeModal2} />
</Modal>
)}
</div>
);
이러한 방식으로 모달을 구현한 후 문제점들을 정리해보니, 다음과 같은 개선이 필요하다는 결론에 이르게 되었습니다:
1. 모달을 열고 닫는 액션과 모달 자체의 연결성을 끊어야 한다
모달의 열고 닫는 동작이 모달 컴포넌트와 강하게 결합되어 있으면, 모달을 재사용하거나 다른 컴포넌트에서 사용할 때마다 반복적인 코드를 작성해야 하고 관리가 복잡해집니다.
2. 어느 곳에서든 모달을 열고 닫을 수 있어야 한다
1번과 비슷한 맥락이지만, 모달과 모달을 사용하는 컴포넌트 간의 의존성을 최소화하여, 모달 상태를 더 유연하고 자유롭게 관리할 수 있어야 합니다.
3. 선언적으로 모달을 사용할 수 있어야 한다
{isOpen && <Modal><Component /></Modal>}
와 같은 형태 대신, 더 간결하고 직관적인 방법으로 모달을 사용할 수 있는 구조가 필요합니다.
이런 요구사항을 바탕으로, 모달 관리의 방식을 다시 설계하게 되었습니다.
useModal 훅을 사용하면서 발생한 문제를 해결하기 위해, 당시 사용하고 있던 Redux-Toolkit을 사용하여 모달의 상태를 전역으로 관리하기로 했습니다.
이 방법은 여러 모달을 중앙에서 관리하고, 모달을 열고 닫는 로직을 컴포넌트로부터 분리하는 데 효과적이라고 생각했습니다.
Redux-Toolkit을 사용하여 모달 상태를 전역으로 관리한 코드의 주요 부분은 다음과 같습니다:
Redux의 createSlice 함수를 사용하여 모달 관련 상태와 리듀서를 정의합니다.
이 슬라이스는 모달의 type과 props를 관리하는 ModalType 배열을 상태로 가지고 있습니다.
import { createSlice } from "@reduxjs/toolkit";
import { RootState } from "./store";
export type ModalType = {
type: string;
props?: any;
};
const initialState: ModalType[] = [];
export const modalSlice = createSlice({
name: "modal",
initialState,
reducers: {
openModal: (state, action) => {
const { type, props } = action.payload;
return state.concat({ type, props });
},
closeModal: (state) => {
state.pop();
},
},
});
export const modalSelector = (state: RootState) => state.modal as ModalType[];
export const { openModal, closeModal } = modalSlice.actions;
export default modalSlice.reducer;
각 모달을 type에 따라 동적으로 렌더링하기 위해 MODAL_COMPONENTS 객체를 사용합니다. 이 객체는 모달 타입을 키로, 해당 타입의 컴포넌트를 값으로 가지고 있습니다.
MODAL_COMPONENTS 객체는 모달 타입을 해당 컴포넌트에 매핑하여, 모달 타입에 따라 동적으로 컴포넌트를 렌더링할 수 있도록 합니다. 이를 통해 모달을 선언적으로 사용할 수 있고, 필요에 따라 쉽게 확장할 수 있습니다.
type MODAL_COMPONENT_TYPE = {
[key: MODAL_TYPE_KEYS]: (props: any) => JSX.Element;
};
export const MODAL_TYPE = {
NoticeModal: "NoticeModal",
LoginModal: "LoginModal",
TwoBtnModal: "TwoBtnModal",
OneBtnModal: "OneBtnModal",
} as const;
export const MODAL_COMPONENTS = {
[MODAL_TYPE.NoticeModal]: NoticeModal,
[MODAL_TYPE.LoginModal]: LoginModal,
[MODAL_TYPE.TwoBtnModal]: TwoBtnModal,
[MODAL_TYPE.OneBtnModal]: OneBtnModal,
};
이 접근 방식은 모달의 상태와 로직을 컴포넌트에서 분리하고, 전역에서 관리함으로써 코드의 중복을 줄이고 유지보수를 용이하게 했습니다. 그러나, Redux-Toolkit을 사용한 방법은 설정이 다소 복잡하고 모달 컴포넌트를 추가할 때마다 Redux의 슬라이스와 컴포넌트 매핑을 업데이트해야 하는 점에서 유연성이 다소 떨어진다는 단점이 있었습니다.
이러한 이유로 더 간단한 상태 관리 도구인 Zustand를 이용해 모달 상태를 관리하는 방식으로 전환하게 되었습니다. Zustand를 사용하면 더 간결하고 직관적인 코드 작성이 가능하며, 모달 상태 관리를 보다 쉽게 할 수 있었습니다.
Zustand로 전환한 이유는 Redux-Toolkit에 비해 더 간단하고 선언적인 상태 관리가 가능하기 때문입니다. 상태 관리 로직을 간결하게 유지하면서 모달을 쉽게 제어할 수 있는 장점이 있습니다. 아래에서 Zustand를 사용한 전역 모달 관리 방법을 단계별로 설명하겠습니다.
먼저 Zustand의 create 함수를 사용하여 모달 상태를 관리하는 전역 스토어를 만듭니다. 여기서는 모달 타입과 열림 상태를 관리합니다.
import { ModalKeysType } from '@/components/common/Modals';
import { create } from 'zustand';
type ModalStore = {
modalType: ModalKeysType | null;
isOpen: boolean;
openModal: (type: ModalKeysType) => void;
closeModal: () => void;
};
const useModalStore = create<ModalStore>((set) => ({
modalType: null,
isOpen: false,
openModal: (type: ModalKeysType) => set({ modalType: type, isOpen: true }),
closeModal: () => set({ modalType: null, isOpen: false }),
}));
export default useModalStore;
useModal 훅을 통해 특정 모달을 제어할 수 있습니다.
기존에는 {isOpen && <Modal>...}
처럼 작성해야 했었지만, 이제는 useModal 훅 내부에서 modalType과 type이 일치하고 isOpen이 true일 때에만 현재 모달이 열려 있다고 판단합니다. 이렇게 함으로써 특정 모달의 열림 상태를 독립적으로 관리할 수 있습니다.
import { ModalKeysType } from '@/components/common/Modals';
import useModalStore from '@/shared/store/useModalStore';
export const useModal = (type: ModalKeysType) => {
const { modalType, isOpen, openModal, closeModal } = useModalStore();
return {
isOpen: modalType === type && isOpen,
onOpen: () => openModal(type),
onClose: closeModal,
};
};
모든 모달을 한 컴포넌트에서 관리하고, 전역 상태에 따라 해당 모달을 렌더링하는 컴포넌트를 작성합니다.
'use client';
import useModalStore from '@/shared/store/useModalStore';
import Modal from './Modal';
import TodayMoodSheet from '../modal/TodayMoodSheet';
import { ReactNode } from 'react';
import TodayWeightSheet from '../modal/TodayWeightSheet';
import CalendarModal from '../modal/CalendarModal';
export const ModalType = {
todayMood: 'todayMood',
todayWeight: 'todayWeight',
mainCalendar: 'mainCalendar',
} as const;
export type ModalKeysType = keyof typeof ModalType;
type Props = {
modals: Record<ModalKeysType, ReactNode>;
};
const Switch = ({ modals }: Props) => {
const { modalType, isOpen, closeModal } = useModalStore();
if (!modalType || !isOpen) return null;
const ModalComponent = modals[modalType];
return (
<Modal isOpen={isOpen} onClose={closeModal}>
{ModalComponent}
</Modal>
);
};
export const Modals = () => (
<Switch
modals={{
todayMood: <TodayMoodSheet />,
todayWeight: <TodayWeightSheet />,
mainCalendar: <CalendarModal />,
}}
/>
);
그럼 사용할땐 아래와같이 useModal 에 모달 타입을 매개변수로 넣어주고 onOpen 를 이벤트 핸들러에 적용시켜 주면 됩니다.
const Component = () => {
const { onOpen } = useModal(ModalType.어떠한모달);
return (
<div className={styles.stateItem} onClick={onOpen}>
<div>내용</div>
</div>
);
};
export default Component;
const 모달컴포넌트 = () => {
const { isOpen, onClose } = useModal(ModalType.어떠한모달);
return (
<BottomSheet isOpen={isOpen} onClose={onClose}>
<div className={styles.layout}>
모달 내용이 들어갈 자리
</div>
</BottomSheet>
);
};
export default 모달컴포넌트;
Zustand를 사용한 로직은 모달을 열고 닫는 액션과 모달 자체가 느슨하게 결합되어 있고, 선언적으로 사용할 수 있게 되었습니다. 상태 관리가 전역적으로 이루어져 컴포넌트 간의 결합도를 최소화했으며, 모달을 추가할 때마다 새로운 상태 관리 코드를 작성할 필요가 없어졌습니다. 이로써 초기 모달 설계를 위한 목표에 부합하며, Redux-Toolkit을 사용했을 때 보다 좀 더 간단하고 선언적인 상태 관리가 가능해졌습니다.