모달 관련 내용으로 블로그에 글을 쓰는게 벌써 3번째이다. 처음에 단순히 모달을 구현하는 것에서 시작해서 createPortal 을 이용해 모달이 항상 최상위에 위치하도록 수정해줬고, useModal 이라는 훅을 만들어 모달 로직의 중복을 줄였었다.
근데 모달을 여기저기에서 사용하다보니 기존에 사용하던 방법에서도 매번 props 로 전달해줘야 하는 불편함, 모달 컴포넌트의 중복성 등을 느끼게 되어 다시 리팩토링을 하기로 마음을 먹었다.
아래는 useModal hook 컴포넌트이다.
import { useState } from "react";
export const useModal = () => {
const [modalIsShown, setModalIsShown] = useState(false);
const hideModalHandler = () => {
setModalIsShown(false);
};
return { modalIsShown, setModalIsShown, hideModalHandler };
};
아래는 BookingForm 컴포넌트로 useModal hook 을 불러와 아래와 같이 사용하고 있다.
const BookingForm = () => {
const { hideModalHandler, modalIsShown, setModalIsShown } = useModal();
.....(생략)
const showLoginForm = modalIsShown && isChecked && (
<Modal onClose={hideModalHandler}>
<LoginForm path="mypage/book" onClose={hideModalHandler} />
</Modal>
// Modal 및 LoginForm 컴포넌트에 props 로 전달해주고 있다.
);
※모달을 사용하는 컴포넌트에서 useModal 을 불러와 리턴값들을 props 로 전달해줘야 한다. 만약, 컴포넌트 안에 하위 컴포넌트가 존재하여 로직을 전달하여야 할 경우, props 의 복잡도가 더 높아진다.
앞서 말한 불편함을 느끼고 있을때, 또 다른 문제점에 직면하게 됐는데 지금까지는 모달이 한개만 띄어졌다면 두개, 세개 혹은 그 이상 다중 모달이 필요해졌다.
예를들어 예약 안내 모달에서 확인을 누르면 로그인 모달이 띄어져야 하는 상황이다. 여기서 두가지 문제점에 직면 했는데,
첫번째는, 한 컴포넌트에서 두개의 모달을 띄어야 하니 useModal hook 만으로는 구현이 안되어 결국 state 를 만들어야했다. 그런데 만약에 모달이 이중이 아니라 삼중, 사중, 여러개가 띄어져야 한다면 그만큼 state 가 늘어날 것이고 코드가 매우 복잡해지고 유지보수가 어려워질 것 같다는 생각이 들었다.
const BookingForm = () => {
const [isChecked, setIsChecked] = useState(false);
const [isConfirmed, setIsConfirmed] = useState(false); /// 이 부분이 새로 생겼다.
const { hideModalHandler, modalIsShown, setModalIsShown } = useModal();
.... (생략)
const showBookingNoticeForm = modalIsShown && isChecked && (
<Modal onClose={hideModalHandler} height="33rem">
<BookingNotice onClose={hideModalHandler} isConfirmed={setIsConfirmed} />
</Modal>
);
/// 모달이 하나 두개 생길 수록 조건부가 더 길어지고, 복잡해질 것이다.
const showLoginForm = modalIsShown && isChecked && isConfirmed && (
<Modal onClose={hideModalHandler}>
<LoginForm path="mypage/book" onClose={hideModalHandler} />
</Modal>
);
두번째는, 예약 안내 모달에서 확인을 누르면, 로그인 모달이 띄어지는데, 보다시피 backDrop 이 두번 입혀져 로그인 모달의 뒷배경색이 검정색으로 바뀌는 문제가 생겼다.
그 이유는 아래와 같이 컴포넌트가 Modal 의 children 으로 감싸져 있는 형태로 매번 불러오기 때문이다. 모달 컴포넌트에는 backDrop 스타일이 적용 되어 있기 때문에 두번 모달을 호출하면 뒷 배경이 두번 씌어져 어둡게 변하는 문제가 생겼다.
<Modal><컴포넌트1></Modal>
<Modal><컴포넌트2></Modal>
기존 방법은 모달이 필요한 컴포넌트에서 Modal 을 불러와 컴포넌트를 감싸줘 그때그때 렌더링을 하는 방법이었다. 이 방법은 다소 불필요한 코드가 생겨나고 각각의 컴포넌트에서 모달에 관련된 코드가 계속 추가되어 하나의 컴포넌트가 너무 많은 역할을 담당하게 되었다.
import Modal from "../common/UI/Modal";
...(생략)....
const showBookingNoticeForm = modalIsShown && isChecked && (
<Modal onClose={hideModalHandler} height="33rem">
<BookingNotice onClose={hideModalHandler} isConfirmed={setIsConfirmed} />
</Modal>
);
const showLoginForm = modalIsShown && isChecked && isConfirmed && (
<Modal onClose={hideModalHandler}>
<LoginForm path="mypage/book" onClose={hideModalHandler} />
</Modal>
);
/// 매번 모달로 띄우고 싶은 컴포넌트를 Modal 컴포넌트의 children 으로 감싸줘야 한다.
이젠 문제점이 확연해졌다. useModal 훅을 만들어서 중복을 어느정도 해결은 했지만 여전히 useModal 을 호출해 props 로 넘겨줘야하는 props driling 문제와 컴포넌트에서 불필요한 모달 로직들이 생겨나는 점이 가장 큰 문제점이었다.
props driling 을 방지하고 필요한 곳에서만 가져다 쓸 수 있게 하자
라는 부분에서 모달의 상태를 전역으로 관리하면 되겠다는 생각이 들었고, 이미 redux-toolkit 을 프로젝트에서 사용하고 있었기에 지금까지 만든 모달을 redux-toolkit 으로 수정하기로 하였다.
기존 방식에서는 useModal 이라는 hook 을 사용해 각 컴포넌트마다 props 를 넘겨줬다면, 이제는 필요한 컴포넌트에서 dispatch 로 action 을 트리거 해주면 된다. 더이상 props 를 일일이 넘겨주지 않아도 된다.
type
과 props
를 가진다.type
은 모달창으로 띄어질 컴포넌트의 이름을 props
에는 해당 컴포넌트에 필요한 props 를 의미한다. 배열
로 주어 모달 정보를 배열에 순서대로 넣어줘야한다.openModal
: action 이 발생하면 모달의 정보를 배열에 넣어준다.closeModal
: action 이 발생하면 배열의 가장 마지막에 있는 모달이 pop 되도록 설정해줬다.import { createSlice } from "@reduxjs/toolkit";
import { RootState } from "./store";
import { MODAL_TYPE_VALUE } from "../@types/modal";
export type ModalType = {
type: MODAL_TYPE_VALUE;
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;
각 모달 props의 타입을 어떻게 지정해줘야 할지 고민이다.
모달을 한데 모아 관리할 수 있도록 ModalStore 을 만들었다.
프로젝트에서 필요한 모달들을 만들어 store 에 넣어주면 된다.
MODAL_TYPE
: 모달의 종류들을 객체 상태로 한데 모아 관리해준다. type 을 상수로 줬다. MODAL_COMPONENTS
: MODAL_COMPONENTS 를 통해 component 형태로 변환해준다. typescript 를 사용했기 때문에 인덱스 시그니처를 통해 타입을 지정해주었다. <수정전>
import { MODAL_TYPE_KEYS } from "../../@types/modal";
import ConfirmModal from "../common/UI/modal/ConfirmModal";
import LoginModal from "../common/UI/modal/LoginModal";
import NoticeModal from "../common/UI/modal/NoticeModal";
import TwoBtnModal from "../common/UI/modal/TwoBtnModal";
type MODAL_COMPONENT_TYPE = {
[key: MODAL_TYPE_KEYS]: (props: any) => JSX.Element;
};
export const MODAL_TYPE = {
NoticeModal: NoticeModal,
LoginModal: LoginModal,
TwoBtnModal: TwoBtnModal,
ConfirmModal: ConfirmModal,
} as const;
export const MODAL_COMPONENTS: MODAL_COMPONENT_TYPE = {
NoticeModal: NoticeModal,
LoginModal: LoginModal,
TwoBtnModal: TwoBtnModal,
ConfirmModal: ConfirmModal,
};
지금 보면 왜 저렇게 코드를 짰는지 이해가 잘 안가는데, 위 코드에서는 MODAL_TYPE 과 MODAL_COMPONENTS 가 중복돼있다.
<수정후>
import OneBtnModal from "../common/UI/modal/OneBtnModal";
import LoginModal from "../common/UI/modal/LoginModal";
import NoticeModal from "../common/UI/modal/NoticeModal";
import TwoBtnModal from "../common/UI/modal/TwoBtnModal";
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,
};
+수정) string 타입의 MODAL_TYPE 값을 받아 MODAL_COMPONENTS 에서 컴포넌트 형태로 변환 되도록 수정해주었다.
여기서 이렇게 수정하고, middleware 옵션을 제거해도 non-serializable value 에러가 뜨지 않았다.
코드를 위처럼 직렬화 가능한 값으로 한번 바꾸어 전달 해주니 middleware 에서 옵션을 삭제해도 더이상 에러가 뜨지 않았던 것이다.
기존 방식에서는 모달을 필요로하는 컴포넌트에서 <Modal>
을 불러와 렌더링 했다면, 이제는 <ModalContainer>
라는 글로벌한 컴포넌트를 생성해 여기서 모달을 렌더링 하도록 수정해줬다.
import { useSelector, useDispatch } from "react-redux";
import { modalSelector, closeModal } from "../../stores/modalSlice";
import { createPortal } from "react-dom";
import styled from "styled-components";
import { MODAL_COMPONENTS } from "./ModalStore";
const BackDrop = () => {
const dispatch = useDispatch();
return <BackDropStyle onClick={() => dispatch(closeModal())}></BackDropStyle>;
};
const ModalContainer = () => {
const modalList = useSelector(modalSelector);
const portalElement = document.getElementById("overlays") as HTMLElement;
if (modalList.length === 0) {
return null;
}
const renderModal = modalList.map(({ type, props }) => {
const ModalComponent = MODAL_COMPONENTS[type];
return <ModalComponent key={type} {...props} />;
});
return (
<>
{createPortal(<BackDrop />, portalElement)}
{createPortal(<>{renderModal}</>, portalElement)};
</>
);
};
export default ModalContainer;
위에 만든 컴포넌트를 이제 최상위에 두어 이제 어디서든 action 이 트리거 되면 모달이 렌더링 되도록 해줘야 한다. 최상위에 둬야 해서 index.tsx 에 두었는데 오류가 발생했다.
<index.tsx>
const root = ReactDOM.createRoot(
document.getElementById("root") as HTMLElement
);
root.render(
<>
<GlobalStyles />
<Provider store={store}>
<ModalContainer />
<RouterProvider router={router} />
</Provider>
</>
);
에러코드
useNavigate() may be used only in the context of a
<Router>
component.
즉, React Router 로 감싸진 내부에서만 useNavigate 를 사용할 수 있다는 뜻이다.
왜 이런 오류가 발생했나 보니, <ModalContainer>
컴포넌트가 <RouterProvider>
컴포넌트 밖에 위치하다 보니, 모달 컴포넌트에서 react-rotuer 라이브러리에 접근이 불가해지면서 에러가 발생했던 것이다.
생각해보니 react-router 내부에 있으면서 최상위인 App 에 두면 되겠다 싶어서 App 컴포넌트에 ModalContainer 를 import 했더니 에러가 사라지고 컴포넌트가 모두 잘 작동했다.
import { Outlet } from "react-router-dom";
import ModalContainer from "./components/common/UI/ModalContainer";
function App() {
return (
<div style={{ height: "100vh" }}>
<ModalContainer />
<Outlet />
</div>
);
}
export default App;
+수정) ModalStore 컴포넌트에서 string 타입의 값을 받아 컴포넌트 형태로 변환 되도록 수정해주니, 에러가 사라졌다.
이제 모달이 필요한 곳에서 아래와 같이 dispatch 로 openModal 액션을 트리거 해주면 된다.
const bookMassageHandler = () => {
if (!isChecked) return setIsError("* 예약 내역을 확인 후 체크해주세요.");
dispatch(openModal({ type: "BookingNotice" }));
};
그런데 콘솔에 아래와 같은 오류가 찍혔다.
A non-serializable value was detected in the state, in the path:
modal.0.content.$$typeof
.
이러한 에러가 발생한 이유는 검색해보니.. 내가 action.payload 로 보낸 값이 직렬화 할 수 없는 값이라서 에러가 발생 한 것으로 보였다.
여기서 직렬화할 수 없는 값이란, Promise, Symbols, Maps/Sets, functions, class instance 를 말하는데 내가 보내는 값이 Symbols 이라서 에러가 발생했던 것이다.
나는 2번째 방법을 사용했고, store 에서 middleware 에 serializableCheck 를 false 로 주었더니(기본값은 true), 에러 메세지가 사라졌다.
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware({
serializableCheck : false /// 이 부분을 추가했다.
}).concat(
massageApi.middleware,
noticeApi.middleware,
timeApi.middleware
),
더이상 에러 메시지가 나타나지 않고, 아래와 같이 payload 값이 잘 넘어간 것을 확인 할 수 있었다.
앞서 언급했던 기본 방식에서 불편했던 점들을 Redux-toolkit 을 사용해 모달을 전역 관리하면서 중복 요소를 없애고 간편하게 사용할 수 있도록 리팩토링 했다. 하지만 여전히 손봐야 할 곳이 많아 보인다.
이번에 리팩토링 하면서 한가지 반성할 점은 처음부터 모달을 구성할때 프로젝트에서 모달이 어떻게 쓰일지, 어떤 모달이 필요할지 등등 전혀 고려하지 않았다는 점이다. 모달을 만드는거에만 급급했기 때문에 나중에 한가지 모달에 여러가지 기능들을 props 로 주입하고 커스텀 하느라 코드가 상당히 복잡해져 있었다. 그래서 리팩토링 하는데 시간도 오래걸렸고 여전히 수정해야 할 부분이 많다.
오히려 여러 컴포넌트에서 가져다 쓰는 common 한 컴포넌트 일수록 여러 상황들을 고려해서 처음부터 구조를 잘 잡아야 한다는 걸 배웠다! 어느정도 진행 된 후에 리팩토링을 하려니 여러 컴포넌트에서 가져다 쓰고 있어 수정하는데 상당히 어려웠다ㅠㅠ
혹시 잘못 된 내용이나 앞으로 리팩토링을 진행하면서 수정 & 첨가 할 부분이 생기면 업데이트 하겠다.
참고한 사이트
https://velog.io/@pest95/RTK-non-serializable-value-%EC%98%A4%EB%A5%98-%ED%95%B4%EA%B2%B0-%EB%B0%A9%EB%B2%95
https://leego.tistory.com/entry/React-%ED%9A%A8%EC%9C%A8%EC%A0%81%EC%9C%BC%EB%A1%9C-%EB%AA%A8%EB%8B%AC-%EA%B4%80%EB%A6%AC%ED%95%98%EA%B8%B0