☑️ 들어가며
지금 작업 중인 Next.js App Router 기반 프로젝트에서, 여러 개의 모달(지출/수입 등록, 삭제 확인, 얼럿 등)을 확장 가능하게 만들고 싶어서 Redux와 type 기반 상태 관리를 활용한 범용 모달 시스템을 만들었다.
이 글에서는 그 구조와 실제 구현 과정을 정리하려고 한다.
☑️ 모달 구조 설계
⭐ 구조 목표
- 한 개의 CommonModal 컴포넌트로 다양한 모달 대응
- Redux 상태에서 모달의 type을 기준으로 열고 닫기
- modalProps 확장 여지도 확보
1️⃣ modalSlice.ts 생성
// features/modal/modalSlice.ts
import { createSlice } from "@reduxjs/toolkit";
const initialState = {
type: null, // 'expense', 'delete', 'alert' 등
};
const modalSlice = createSlice({
name: "modal",
initialState,
reducers: {
openModal: (state, action) => {
state.type = action.payload.type;
},
closeModal: () => initialState,
},
});
export const { openModal, closeModal } = modalSlice.actions;
export default modalSlice.reducer;
😭 문제1. Redux 상태인데 모든 모달이 동시에 열리는 문제
처음에 modal.isOpen: boolean 으로만 모달 상태를 관리했더니, 여러 모달이 동시에 열리는 상황이 발생했다.
각 모달을 구분하지 않으니 조건 분기가 불가능한거다. (당연한 소리...ㅎㅎ)
🥳 해결
modal.type을 상태로 관리해서 모달의 "종류"를 기준으로 열고 닫도록 변경!
2️⃣ 모달 훅 작성
// hooks/useModal.ts
import { useDispatch } from "react-redux";
import { openModal, closeModal } from "@/features/modal/modalSlice";
export const useModal = () => {
const dispatch = useDispatch();
const handleOpenModal = ({ type }: { type: string }) => {
dispatch(openModal({ type }));
};
const handleCloseModal = () => {
dispatch(closeModal());
};
return { openModal: handleOpenModal, closeModal: handleCloseModal };
};
3️⃣ CommonModal 컴포넌트 작업
// components/modal/CommonModal.tsx
"use client";
import { ReactNode, useEffect } from "react";
type CommonModalProps = {
isOpen: boolean;
onClose: () => void;
children: ReactNode;
};
export default function CommonModal({ isOpen, onClose, children }: CommonModalProps) {
useEffect(() => {
const handleEsc = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
document.addEventListener("keydown", handleEsc);
return () => document.removeEventListener("keydown", handleEsc);
}, [onClose]);
if (!isOpen) return null;
return (
<div onClick={onClose}>
<div onClick={(e) => e.stopPropagation()}>
<button onClick={onClose}>닫기</button>
{children}
</div>
</div>
);
}
4️⃣ 사용 예시(테스트): DashboardLayout.tsx
"use client";
import { useModal } from "@/hooks/useModal";
import { useSelector } from "react-redux";
import { RootState } from "@/store";
import CommonModal from "@/components/modal/CommonModal";
export default function DashboardLayout() {
const { openModal, closeModal } = useModal();
const modalType = useSelector((state: RootState) => state.modal.type);
return (
<>
<button onClick={() => openModal({ type: "expense" })}>모달 열기</button>
<CommonModal isOpen={modalType === "expense"} onClose={closeModal}>
<p>지출 등록 폼 들어갈 자리!</p>
</CommonModal>
</>
);
}
😭 문제2. Redux Provider를 layout.tsx에서 사용해서 에러
Next.js의 layout.tsx는 기본적으로 Server Component인데, 여기서 클라이언트 전용인 <Provider>를 사용해서 에러 발생. (사실 제일 기본인데, 이걸 또 놓침)
🥳 해결
ReduxProvider라는 클라이언트 전용 래퍼 컴포넌트를 만들어 분리!
// components/providers/ReduxProvider.tsx
"use client";
export default function ReduxProvider({ children }) {
return <Provider store={store}>{children}</Provider>;
}
// layout.tsx에서는 사용만
<ReduxProvider>{children}</ReduxProvider>
✨ 배운점
모달은 UI에서 자주 쓰이는 컴포넌트지만 Next.js App Router + Redux + SCSS 환경에서는 작은 실수들이 에러로 이어질 수 있다는 걸 알았다.
이번 경험을 통해 단순히 모달을 만드는 것을 넘어서, 서버 컴포넌트 vs 클라이언트 컴포넌트 개념, Redux 구조화에 깊이 이해할 수 있었다.
'Next' 카테고리의 다른 글
| Next 기반 학원비 통계 컴포넌트 개발 회고 (0) | 2025.07.10 |
|---|---|
| Firebase를 활용한 로그인 & 회원가입 구현하기 (feat. React Hook Form) (0) | 2025.05.13 |
| [Next] 페이지별 레이아웃 설정하기 (1) | 2024.11.18 |
| [Next] API Routes (3) | 2024.11.15 |
| [Next] 프리페칭(Pre-fetching) (0) | 2024.11.07 |