최근 회사에서 React를 플러터 WebView에 이식한 앱을 개발할 일이 있었습니다. 처음에는 모달을 쿼리 스트링(Query String)을 활용해 관리했는데, 특정 모달을 열면 URL에 ?modal=open 같은 형식으로 쿼리스트링을 추가하고, 뒤로가기를 하면 해당 모달을 닫는 방식이었습니다.
그런데 iOS 사파리에서 문제가 발생했는데, 사파리에서는 페이지 이동 시 기본적으로 뒤로가기 애니메이션이 존재하기 때문에, 모달을 종료하기 위해서 뒤로가기를 사용하면 페이지가 변하는 애니메이션이 동작하지만, 애니메이션이 끝나더라도 모달만 사라질뿐 같은 페이지에 있으니 굉장히 어색하다고 생각했습니다.
이 문제를 해결하기 위해 모달을 내부 상태(Zustand)를 활용해 관리하는 방식으로 변경했습니다. 또한, 플러터에서 전달받는 뒤로가기 이벤트를 감지하여,
- 특정 모달이 열려 있으면 모달을 닫고,
- 모달이 없으면 페이지를 뒤로 이동하는
방식으로 동작하도록 만들었습니다.
이 글에서는 Zustand를 활용해 모달을 전역적으로 관리하는 방법과, 모달 스택을 관리하는 방법을 정리합니다.
Zustand 모달 스토어 구현
import { create } from 'zustand';
const useModalStore = create((set) => ({
modals: [],
openModal: (Component, props = {}) =>
set((state) => ({
modals: [...state.modals, { id: Date.now(), Component, props }],
})),
closeModal: (id) =>
set((state) => ({
modals: state.modals.filter((modal) => modal.id !== id),
})),
popModal: () =>
set((state) => ({
modals: state.modals.slice(0, -1),
})),
closeAllModals: () => set({ modals: [] }),
}));
- modals: 현재 열려 있는 모달 목록을 배열로 저장
- openModal(Component, props): 새로운 모달을 modals 배열에 추가
- closeModal(id): 특정 모달을 ID 기준으로 찾아 제거
- popModal(): 최근에 열린 모달을 하나 닫음
- closeAllModals(): 모든 모달을 한 번에 닫음
모달을 렌더링하는 ModalProvider
const ModalProvider = () => {
const { modals, closeModal } = useModalStore();
return (
<>
{modals.map(({ id, Component, props }) => (
<Component key={id} {...props} onClose={() => closeModal(id)} />
))}
</>
);
};
modals 배열을 순회하면서 현재 열려 있는 모달을 렌더링
Component를 동적으로 렌더링하여 여러 종류의 모달을 관리 가능
onClose={() => closeModal(id)}를 전달하여 닫기 버튼을 누르면 해당 모달만 닫힘
이제 Zustand의 상태만 업데이트하면 자동으로 모달이 렌더링되거나 제거되는 구조가 됩니다.
전체 코드
import React from 'react';
import { create } from 'zustand';
// Zustand 모달 스토어
const useModalStore = create((set) => ({
modals: [],
openModal: (Component, props = {}) =>
set((state) => ({
modals: [...state.modals, { id: Date.now(), Component, props }], // 고유 ID 추가
})),
closeModal: (id) =>
set((state) => ({
modals: state.modals.filter((modal) => modal.id !== id),
})),
popModal: () =>
set((state) => ({
modals: state.modals.slice(0, -1), // 가장 최근 모달 닫기
})),
closeAllModals: () => set({ modals: [] }),
}));
// 모달 컴포넌트 1
const MyModal = ({ title, onClose }) => {
return (
<div className="modal">
<div className="modal-content">
<h2>{title}</h2>
<button onClick={onClose}>닫기</button>
</div>
</div>
);
};
// 모달 컴포넌트 2
const AnotherModal = ({ title, onClose }) => {
return (
<div className="modal">
<div className="modal-content">
<h2>{title}</h2>
<button onClick={onClose}>닫기</button>
</div>
</div>
);
};
// ModalProvider (모달을 렌더링하는 컴포넌트)
const ModalProvider = () => {
const { modals, closeModal } = useModalStore();
return (
<>
{modals.map(({ id, Component, props }) => (
<Component key={id} {...props} onClose={() => closeModal(id)} />
))}
</>
);
};
const ModalExample = () => {
const { openModal, popModal, closeAllModals } = useModalStore();
return (
<div>
<button onClick={() => openModal(MyModal, { title: '첫 번째 모달' })}>
첫 번째 모달 열기
</button>
<button onClick={() => openModal(AnotherModal, { title: '두 번째 모달' })}>
두 번째 모달 열기
</button>
<button onClick={popModal}>마지막 모달 닫기</button>
<button onClick={closeAllModals}>모든 모달 닫기</button>
</div>
);
};
// App 전체 구성
const App = () => {
return (
<div>
<h1>Zustand 모달 관리</h1>
<ModalProvider />
<ModalExample />
</div>
);
};
export default App;
애니메이션 처리
Framer Motion 사용
https://www.npmjs.com/package/framer-motion
npm i framer-motion
import { Motion, AnimatePresence } from 'framer-motion';
const ModalWrapper = ({ children, onClose }) => (
<Motion.div
className="modal-overlay"
style={{
position: 'fixed',
top: 0,
left: 0,
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: 'rgba(0, 0, 0, 0.5)',
}}
variants={overlayVariants}
initial="hidden"
animate="visible"
exit="exit"
onClick={onClose}
>
<Motion.div
className="modal-content"
style={{
background: '#fff',
padding: '20px',
borderRadius: '8px',
boxShadow: '0 4px 10px rgba(0, 0, 0, 0.3)',
}}
variants={modalVariants}
initial="hidden"
animate="visible"
exit="exit"
onClick={(e) => e.stopPropagation()}
>
{children}
</Motion.div>
</Motion.div>
);
const MyModal = ({ title, onClose }) => (
<ModalWrapper onClose={onClose}>
<h2>{title}</h2>
<button onClick={onClose}>닫기</button>
</ModalWrapper>
);
const AnotherModal = ({ title, onClose }) => (
<ModalWrapper onClose={onClose}>
<h2>{title}</h2>
<button onClick={onClose}>닫기</button>
</ModalWrapper>
);
React Transition Group 사용
https://www.npmjs.com/package/react-transition-group
npm i react-transition-group
/* 페이드 인/아웃 애니메이션 */
.fade-enter {
opacity: 0;
transform: translateY(-20px);
}
.fade-enter-active {
opacity: 1;
transform: translateY(0);
transition: opacity 0.3s ease-in-out, transform 0.3s ease-in-out;
}
.fade-exit {
opacity: 1;
transform: translateY(0);
}
.fade-exit-active {
opacity: 0;
transform: translateY(20px);
transition: opacity 0.2s ease-in-out, transform 0.2s ease-in-out;
}
import { CSSTransition, TransitionGroup } from 'react-transition-group';
// ModalProvider (nodeRef 추가)
const ModalProvider = () => {
const { modals, closeModal } = useModalStore();
return (
<TransitionGroup component={null}>
{modals.map(({ id, Component, props, nodeRef }) => (
<CSSTransition key={id} timeout={300} classNames="fade" nodeRef={nodeRef}>
<div ref={nodeRef}>
<Component {...props} onClose={() => closeModal(id)} />
</div>
</CSSTransition>
))}
</TransitionGroup>
);
};
현재 코드는 modals 배열을 기반으로 여러 개의 모달을 관리하지만, 외부에서 특정한 모달을 닫으려면 closeModal(id)를 호출해야 합니다. 하지만 문제가 있습니다.
외부에서 특정 모달을 닫으려면 해당 모달의 id를 알아야 하지만 현재 코드는 모달의 id를 추적하기가 어렵습니다.
기존 코드 방식의 문제점
- 외부에서 모달을 닫을 수 없는 문제
- 호출 위치를 지정할 수 없는 문제
위 문제를 해결하기 위해서 코드를 수정 했습니다.
먼저 생성해야하는 모달을 특정 컴포넌트에서 호출을 할 것인데, 등록하는 과정과 등록을 해제하는 함수를 추가 했습니다.
그다음 스토어에서는 모달 컴포넌트의 상태만 관리하는 방식으로 수정했습니다.
import { useEffect, useRef } from "react";
import { create } from "zustand";
import { useShallow } from "zustand/react/shallow";
const useModalStore = create()((set, get) => {
let id = 0;
return {
modals: [],
register: ({ viewId, pathname }) => {
const isOpen = false;
let currentId = id;
if (viewId !== undefined && viewId !== null) {
currentId = viewId;
}
const existModal = get().modals.find((modal) => modal.id === currentId);
if (existModal) {
return currentId;
}
set((state) => ({
modals: [
...state.modals,
{
id: currentId,
isOpen,
},
],
}));
id++;
return currentId;
},
unRegister: (id) => {
set((state) => ({
modals: state.modals.filter((modal) => modal.id !== id),
}));
},
openModal: (id) => {
set((state) => {
const updatedModals = state.modals.filter((modal) => modal.id !== id); // 기존 모달을 제거
const targetModal = state.modals.find((modal) => modal.id === id); // 이동할 모달 찾기
if (!targetModal) return state; // 모달이 없으면 그대로 반환
return {
modals: [...updatedModals, { ...targetModal, isOpen: true }], // 맨 뒤로 추가
};
});
},
closeModal: (id) => {
const isOpen = false;
set((state) => {
return {
modals: state.modals.map((modal) => {
if (modal.id === id) {
return {
...modal,
isOpen,
};
}
return modal;
}),
};
});
},
popModal: () => {
set((state) => {
const lastOpenIndex = state.modals
.map((modal, index) => ({ modal, index }))
.reverse()
.find(({ modal }) => modal.isOpen)?.index;
if (lastOpenIndex === undefined) return state;
return {
modals: state.modals.map((modal, index) =>
index === lastOpenIndex ? { ...modal, isOpen: false } : modal
),
};
});
},
closeAllModals: () => {
const isOpen = false;
set((state) => ({
modals: state.modals.map((modal) => {
return { ...modal, isOpen };
}),
}));
},
};
});
export function useModal(id) {
const [modalId, setModalId] = useState(null);
const [register, unRegister, openModal, closeModal] = useModalStore(
useShallow((state) => [
state.register,
state.unRegister,
state.openModal,
state.closeModal,
])
);
const modal = useModalStore(
useShallow((state) => {
return state.modals.find((modal) => modal.id === modalId);
})
);
useEffect(() => {
const registerId = register({
viewId: id,
});
setModalId(registerId);
return () => {
unRegister(registerId);
};
}, []);
return {
modal,
isOpen: modal && modal.isOpen,
open: () => modal && openModal(modal.id),
close: () => modal && closeModal(modal.id),
toggle: () => {
if (!modal) return;
if (!modal.isOpen) {
openModal(modal.id);
return;
}
closeModal(modal.id);
},
};
}
export default useModalStore;
export default function Modal({ close }) {
return (
<div>
나 모달 <button onClick={close}>닫기</button>
</div>
);
}
import Modal from "./components/modal";
import { useModal } from "./store/modal";
import "./styles.css";
export default function App() {
const { isOpen, open, close } = useModal();
return (
<div className="App">
<button onClick={open}>모달 열기</button>
{isOpen && <Modal close={close} />}
</div>
);
}
개선된 방식은 기존 방식에서 발생했던 외부 제어 문제를 해결하면서도, 보다 유연한 모달 관리가 가능한 거 같습니다.