오랜만의 포스팅이다!
3달 동안 프로젝트를 진행하느라 포스팅을 미루고 미루다가, 드디어 배포를 끝내고 아껴놨던 포스팅거리들을 풀 시간이 된 것 같다. 이번에는 "덕풀"이라는 덕질 공유 플랫폼 프로젝트를 개발했는데, 혼자서 개발 환경 세팅, 기능 개발, 배포까지 A to Z를 해야하다보니 중간중간 지칠때도 많았던 것 같다. 역시 개발은 여러명이서 함께해야 즐거워,,,
이번에 작성할 블로그 주제는 프로미스를 사용한 전역 모달 관리다.
이전의 프로젝트를 진행하면서도 전역 모달 관리를 사용했고, 관련해서 블로그 포스팅도 썼는데도 불구하고 다시 한 번 같은 주제를 들고왔다.
이전의 전역 모달 관리 방법을 "덕풀" 프로젝트에서도 사용하려고 보니 생각보다 효율적이지 않은 부분이 많다는 것을 알게되었고, 해당 문제점들을 어떻게 하면 해결할 수 있을지 그리고 어떻게 좀 더 고도화 할 수 있을지에 대해서 고민한 흔적을 남겨보면 좋을 것 같다는 생각이 들었다.
이전에 사용했던 전역 모달 관리 방법
가장 먼저 이전에 전역 모달을 관리하던 블로그 포스팅을 간단하게 정리해보자면, 다음과 같다.
1. recoil을 사용하여 모달 전역 관리를 위해 boolean 값을 갖는 modalAtom을 생성한다.
2. 애플리케이션의 레이아웃 컴포넌트에서 모달 컴포넌트를 임포트하고 modalAtom의 값에 따라 분기처리하여 렌더링할 수 있도록 한다.
3. 모달을 열고 닫아야하는 컴포넌트 또는 페이지에서 atom setState를 통해 특정 조건에 모달의 상태를 조작한다.
4. 모달이 열고 닫힌다.
이제 위와 같이 구현했을 때의 문제 상황을 하나씩 짚어보자.
문제상황 1️⃣ atom은 애플리케이션 상태에 더 적합하다.
첫 번째 이전 전역 모달 관리 방법과의 차이점은 atom으로 모달의 상태를 관리하지 않고, Context API를 사용했다는 점이다.
Recoil, Jotai와 같은 전역 상태관리 라이브러리의 atom은 단순하게 상태를 관리하는 독립적인 단위일 뿐 react의 컴포넌트와는 별개의 개념이다.
조금 더 살을 덧붙이자면, atom은 상태를 나타내는 객체이고, react 컴포넌트에서 사용되는 상태를 관리하는 데에만 사용되는 것으로 상태 관리 도구 그 자체로만 동작한다. 또한 전역 상태 라이브러리를 통해 관리해야하는 상태는 View의 라이프 사이클과 무관하게 애플리케이션 자체의 라이프 사이클을 따라간다.
// modalState.ts
export const modalState = atom<boolean>({
key: 'modalState',
default: false,
});
// Layout.tsx
const Layout = ({ children }: Props) => {
const [isModal, setIsModal] = useRecoilState(modalState);
return (
<LayoutBox>
{isModal && (
<Modal {...} />
)}
{children}
</LayoutBox>
);
};
위의 이전 전역 모달 관리 로직을 보면 modalState에서 모달의 상태를 atom으로 관리하고, 모달의 렌더링은 atom의 상태 값에 따라 Layout 컴포넌트에서 담당하는 것을 볼 수 있다. 이 부분에서 모달에 대한 상태를 관리할 수 있고, 전역 상태가 뷰와 밀접한 관계를 가지도록 렌더링을 담당하는 부분을 떨어뜨리지 않는 방법에 대해 고민했다.
해결방안 ✅ ContextAPI
ContextAPI는 컴포넌트 트리 전체에 걸쳐서 상태를 공유하고, ContextAPI를 사용할 때, Provider 컴포넌트 내에서 상태와 관련된 추가적인 렌더링 로직을 처리할 수 있다는 특징이 있다. 따라서 하위 컴포넌트들에서 모달에 대한 전역 상태를 공유할 수 있고, 수정할 수 있다. 또한 모달은 애플리케이션 전체 View 뿐만 아니라 더 작은 서브 View에서도 관리해야할 수 있고 View의 라이프 사이클을 따라갈 수 있도록 구현이 가능하기 때문에 뷰와 더 밀접하다.
const ModalProvider = ({ children }: Props): JSX.Element => {
const [modals, setModals] = useState<React.ReactElement[]>([]);
...
return (
<ModalContext.Provider value={{ openModal, hideModal }}>
{children}
{modals.map((modal, idx) => (
<Fragment key={idx}>{modal}</Fragment>
))}
</ModalContext.Provider>
);
};
ModalProvider는 modals라는 state를 가지고 value로 modals state를 조작할 수 있는 함수를 제공한다. 따라서 ModalProvider 하위에 위치하는 컴포넌트들은 state를 조작하고 state의 변경을 감지한 ModalProvider는 내부에서 modal에 관한 렌더링 처리를 진행해준다. 이렇게 첫번째 문제상황을 처리했다.
문제상황 2️⃣ 한가지의 모달만 띄울 수 있는 문제
여러 서비스들을 이용하다보면 모달이 띄워진 상태에서 해당 모달이 닫히지 않은 상태로 또 다른 모달이 띄워지는 경우를 볼 수 있다. 하지만, 이전의 전역 모달 관리 로직에서는 모달의 열림, 닫힘과 관련있는 boolean 값을 관리했다. 이 방식대로라면 한 가지의 모달 정보만을 가질 수 있고, 또 다른 모달을 호출할 때 기존 모달이 닫히고 추가 모달이 새로 띄워지는 현상이 발생한다.
해결방안 ✅ 모달 배열 관리
여러개의 모달을 순차적으로 차곡차곡 쌓기 위해서는 모달을 배열로 관리해주어야 한다.
const ModalProvider = ({ children }: Props): JSX.Element => {
const [modals, setModals] = useState<React.ReactElement[]>([]);
const openModal = <T extends {}>(element: React.ReactElement): Promise<T> => {
...
setModals((prev) => [...prev, modal]);
...
};
...
};
위의 openModal을 통해서 모달을 열고 setModals((prev) => [...prev, modal])을 통해서 modal state 리스트에 새로 열린 모달을 쌓아 주고, modal의 액션이 이루어지면 가장 뒤의 모달을 제거해주는 방식으로 두 번째 문제상황을 해결했다.
문제상황 3️⃣ 모달에 액션을 전달해야 하는 현상
const Reviews = () => {
...
return (
<PageLayout className="bg-bg-primary">
{isModal && (
<Modal
isModal={isModal}
title="리뷰 삭제"
message="리뷰 작성 내역을 삭제하시나요?"
left="아니요"
right="네"
leftEvent={() => setIsModal(false)}
rightEvent={() => {
deleteReview(reviewId as number);
setIsModal(false);
}}
/>
)}
</PageLayout>
);
};
이전 모달 로직을 사용하던 하나의 예시를 가져와봤다. 한 번 살펴보면 각각의 버튼이 클릭됐을 때 실행되어야 할 액션을 props로 전달해주고 있고, 이 후에 modal을 닫힘 상태로 변경해주는 동작까지 전달해줌으로써, 재사용성과 범용성이 떨어지는 것을 볼 수 있다. 또한 모달이 너무 많은 역할을 담당하게 함으로써 모달 그 자체로의 기능이 퇴색되는 느낌을 준다. 일반적으로 모달은 위와 같은 상황처럼 등록, 삭제 또는 탈퇴에 대한 상황에 자주 사용된다. 뿐만 아니라 프롬프트 모달처럼 인풋이 존재하고 입력값을 전달해야하는 모달도 사용될 수 있는데, 모달은 모달로써의 역할만 수행할 수 있도록 하면서 동시에 모달 내부의 버튼 클릭에 따라 특정 액션이 수행될 수 있도록 해야했다.
해결방안 ✅ promise를 사용한 모달 구현
이전에 async/await 관련 포스팅을 쓰면서 promise를 통해서 모달도 구현할 수 있더라~~ 하면서 간단한 예시를 하나 보여줬었는데, 이게 이렇게 쓰일줄은 몰랐다. 해당 포스팅에서는 promise에 대해서 자세하게 설명하지는 않겠다. 일단 이 모달 로직에서 promise의 핵심적인 특징은, promise가 resolve, reject 되기 전까지는 해당 promise가 pending 상태라는 것, 또한, resolve, reject로 전달한 인자는 후속처리 메서드에서 사용가능하다는 것. await를 이용한다면 변수에 저장해서 사용할 수도 있겠다. 그럼 코드를 보자.
const ModalProvider = ({ children }: Props): JSX.Element => {
const [modals, setModals] = useState<React.ReactElement[]>([]);
...
const openModal = <T extends {}>(element: React.ReactElement): Promise<T> => {
const promiseResolver = () => {
let resolveFn: (value: T) => void = () => {};
let rejectFn: (ex: Error) => void = () => {};
const promise: Promise<T> = new Promise((resolve, reject) => {
resolveFn = resolve;
rejectFn = reject;
});
return { promise, resolveFn, rejectFn };
};
const { promise, resolveFn, rejectFn } = promiseResolver();
const modal: React.ReactElement = cloneElement(element, {
onSubmit: (value: T) => {
resolveFn(value);
setModals((prev) => prev.slice(0, -1));
},
onAbort: (ex: Error) => {
rejectFn(ex);
setModals((prev) => prev.slice(0, -1));
},
});
setModals((prev) => [...prev, modal]);
return promise;
};
...
};
모달을 여는 openModal 함수 내에서는 promiseResolver를 통해서 생성한 promise와 해당 promise의 resolve, reject 함수를 각각 담은 resolveFn, rejectFn을 반환한다. 그리고 modal 변수에 openModal 함수의 인자로 전달받은 Modal 컴포넌트에 resolve를 담당하는 onSubmit과 reject를 담당하는 onAbort를 props로 전달한 새로운 컴포넌트를 modals 배열 state에 저장한다. 이로써, openModal을 통해 열린 모달은 onSubmit과 onAbort를 통해서 promise를 fulfilled 상태로 변환할 수 있다. 이제 액션이 이루어지는 곳을 보자.
const TalkCommentItem = memo(
() => {
...
const { openModal } = useContext(ModalContext);
const { mutate: deleteComment } = useDeleteTalkComment();
const handleDelete = async () => {
const isDeleted = await openModal(<CommentModal />).catch(() => false);
if (isDeleted) deleteComment({ id, contentId });
};
return (
<FormProvider {...methods}>
...
<StyledEditButton onClick={handleDelete}>
삭제
</StyledEditButton>
...
</FormProvider>
);
},
);
삭제버튼을 누르면 openModal을 통해서 모달이 띄워지고 promise가 fulfilled 상태가 되기를 기다린다. 그리고 Modal에서 onSubmit을 통해서 전달한 인자가 truthy한 값이라면 deleteComment mutation을 통해서 삭제가 이루어지고, resolve 이후에 해당 모달을 state 리스트에서 제거한다. 해당 프로젝트를 진행하면서 인풋이 필요한 프롬프트 모달은 구현하지 않았지만, 인풋에 입력한 값을 onSubmit의 인자로 전달한다면 해당 값을 전달받아 사용하는 로직도 쉽게 사용할 수 있을 것이다.
이번 포스팅은 이렇게 더 효율적으로 전역모달을 관리하는 방법에 대해서 다시 한 번 정리해봤다. 모달을 사용할 때 promise가 유용하게 사용되는 것을 다시 한 번 체감하면서 역시나 개발을 진행하면 할수록 기본이 중요하다는 점,,,, 기존에 사용했던 전역 모달관리의 문제점과 해결 방안에 초점을 맞춰서 글을 작성했는데 궁금한 점이 있다면 댓글을 남겨주시면 좋을 것같다. 또 많은 도움이 되었으면 하는 바람이다.
'개발' 카테고리의 다른 글
애플리케이션 에러처리 중앙화 (0) | 2024.03.14 |
---|---|
애플리케이션 유저 로그인 상태 및 인증 로직 관리 (feat. Jotai) (1) | 2024.03.11 |
[개발] Nextjs에서 SEO 최적화하기 (0) | 2023.06.02 |
[개발] Nextjs Link 컴포넌트와 <a> 태그 (feat. 이메일 링크걸기) (0) | 2023.05.28 |
[개발] nextjs에서 모달 관리하기 (feat. recoil) (0) | 2023.05.08 |