모달 내부에서 서버 통신을 위한 로직 개선
-
야무지게 모달을 완성했다고 생각했지만, 정작 모달이 필요한 이유였던 채팅방 생성 모달을 구현하려고 하니 비동기 처리가 되지 않는다는 사실을 알게 되었다. 그 말은 지금까지 만든 모달은 `Confirm`의 역할만 하고 있는 것이다. 더 이상 useModal 파일을 보지 않아도 될 것이라 생각했는데 결국 다시 뒤엎는 과정을 겪었다. 이쯤엔 거의 10차가 넘어가는 리팩토링 단계였기 때문에 그냥 넘어갈까?라는 고민도 많이 했다. 하지만 이 또한 궁금함과 하다가 만 느낌을 견디지 못해 다시 모달을 붙잡았다.
모달이 열렸을 때, 수행할 수 있는 역할 중에는 서버와의 통신도 많다. 등록/수정/삭제 또한 서버 통신이 필요하기 때문에 모달 내부 버튼에 비동기 함수를 연결해 주는 작업이 필요하다. 현재까지 구현된 모달만으로도 이 부분을 처리하는 방법은 있었다. 모달을 호출한 컴포넌트 내에서 모달 내 이벤트에 따라 처리되는 로직을 모두 구현하는 것이다. 하지만 이번 목표는 로직을 추상화하여 선언적으로 사용할 수 있는 모달 만들기이므로 모달 컴포넌트에서 일어난 이벤트로 호출되는 함수의 결과가 모달을 호출한 컴포넌트에서 처리될 수 있도록 전체적인 구조 개선이 필요했다.
//호출
const testAPI = () => {};
const createChatRoom = () => openModal(<ConfirmModal onSubmit={test} />);
//Lines.tsx
const result = await openModal(Confirm)
`openModal`로 전달되는 컴포넌트는 모달 콘텐츠 컴포넌트라고 볼 수 있다. 이 컴포넌트에서 처리되는 서버 통신의 결과가 호출 컴포넌트에서 처리된다.
const renderModal = useCallback(
(portalContainer?: MutableRefObject<HTMLDivElement | null>) => {
const modal = modals.find((modal) => modal.id === modalId);
const handleSubmit = async () => {
const onSubmit = modal && modal.element.props?.onSubmit;
if (onSubmit) await onSubmit();
closeModal();
};
return (
...
<ModalPortal>
<Modal modal={modal} onClose={closeModal} onSubmit={handleSubmit} />
</ModalPortal>
);
},
[modals, modalId, closeModal],
);
처음 개선 방향은 모달 컴포넌트를 호출할 때, `props`로 비동기 함수를 받는 방법이었다. 이를 처리하기 위해 `renderModal` 내부에서 받아온 함수를 처리하는 `handleSubmit` 함수를 작성해 렌더링 되는 `<Modal />`에 props로 전달했다.
>_ Modal.tsx
const Modal = ({ modal, onClose, onSubmit }: { modal: ModalType } & ModalProps) => {
const { element } = modal;
return (
<Overlay onClose={onClose}>
<div className={cx('container')} onClick={(e) => e.stopPropagation()}
onKeyDown={() => {}}>
<button className={cx('close')} onClick={onClose}>
<IoIosClose />
</button>
//기존: {element}
{cloneElement(element, { onClose, onSubmit })}
</div>
</Overlay>
);
};
기존에는 `element` 자체가 `onClose`를 갖고 있었으므로 그냥 element만 렌더링 해주면 되었지만, 지금은 onSubmit을 props로 내려주어야 해서 `cloneElement`를 활용했다.
나만의 요상한 로컬 모달을 만들면서 `Portal`을 원하는 부분에 열 수 있도록 구현했었다. 그렇다면 별도 isLocal과 같은 옵션 없이 Context 자체가 Portal의 역할을 할 수 있도록 하면 되겠다는 생각을 했다.
>_ ModalContext.tsx
포탈을 지정하려면 참조할 태그가 필요하고, 이 태그가 `ModalContextProvider`와 함께 움직여야 한다.
const ModalProvider = ({ children }: PropsWithChildren) => {
const [modals, setModals] = useState<ModalType[]>([]);
const portalRef = useRef<HTMLDivElement | null>(null);
const open = useCallback((element: JSX.Element, id: string, isLocal?: boolean) => {
const modal = {
id,
element,
isLocal,
};
setModals((prev) => [...prev, modal]);
}, []);
const close = useCallback((id: string) => setModals((prev) => prev.filter((v) => v.id !== id)), []);
return (
<modalContext.Provider value={{ modals, open, close, portalRef }}>
<div style={{ position: 'relative' }} ref={portalRef}>
{children}
</div>
</modalContext.Provider>
);
};
`children`만 감싸던 `Provider`에게 `div` 태그를 한 번 더 감싸도록 수정했다. 그리고 이 `div`에 ref를 연결하여 포탈이 열리는 곳을 지정하고, value에 `portalRef`를 추가해 준다.
✨ 변경사항으로 달라지는 점
하위 컴포넌트 내에서 모달을 호출할 때는 가장 가까이 있는 Provider의 값을 사용하게 된다.
만일 부분적으로 컴포넌트를 감싼 다음 모달을 호출하게 되면 바로 상위에 있는 Provider 위치에서 포탈이 열리게 된다.
>_ renderModal
return (
modal && (
<ModalPortal portalContainer={portalRef.current} key={modal.modalId}>
<Modal modal={modal} onClose={closeModal} onSubmit={handleSubmit} />
</ModalPortal>
)
);
Context로부터 받은 `portalRef` 정보를 이용해 props로 넘겨준다. (isLocal과 같은 냄새나는 분기는 이제 필요하지 않다.)
>_ ModalPortal.tsx
const ModalPortal = ({ children, portalContainer }: PropsWithChildren & portalContainer) => {
return createPortal(children, portalContainer!);
};
이제 `ModalPortal`은 포탈이 열려야 하는 Ref 정보를 props로 받아 해당하는 곳에서 열리게 된다.
`createPortal` 사용법을 검색하면 root 외부에 열기 위한 용도로 사용하는 예시를 많이 볼 수 있다. 내가 적용한 방법은 포탈을 root 요소 하위 원하는 곳에서 열리도록 한다. 여기에 대해 '성능상 문제가 없을까'에 대한 고민을 했다. 이와 관련된 레퍼런스가 없어 더 고민되었다. 주로 모달을 root 외부에 여는 이유는 모달 특성상 독립적으로 있는 것이 `z-index 문제`, `접근성`, `렌더링 최적화` 등의 장점이 있기 때문인데 하위에 부분적으로 자유롭게 포탈을 열면 그 장점들을 활용하지 못하는 것 같다는 생각도 했다.
우선은 이 문제의 해결 방안이 될 수 있다고 생각되는 createPortal의 `key` 옵션을 적용했다. key를 설정해 두면 이 모달이 다른 곳에서 렌더링 되어도 어떤 부모 컴포넌트의 자식인지 React가 알 수 있어 렌더링 시 컴포넌트 추적이 가능해지기 때문이다. 또, 모달은 일시적으로 사용되기도 하고 한번 열리면 사용자의 액션이 일어나기 전까지 다른 작업을 하는 경우가 드물기 때문에 크게 영향을 안 받을 것 같기도 했다. 하지만 웹접근성 문제에 대해서는 고려해 볼 필요가 있어 완성 이후 테스트를 진행해 볼 예정이다.
//Lines.tsx
openModal(<CreateChatRoomModal onSubmit={api함수} />
이번에 눈에 거슬렸던 부분은 모달을 호출할 때, props로 서버와 통신할 함수를 전달해야 한다는 점이었다. 어떻게 보면 맞는 것 같지만 흐름을 생각해 보면 어색한 점이 있다. openModal을 호출하고 있는 컴포넌트는 `<CreateChatRoomModal />`에서 이루어진 결과만 알면 된다. onSubmit으로 무엇을 할 것인지는 `<CreateChatRoomModal />`에서 관리하고 호출 컴포넌트는 관여하지 않았으면 한다.
//useModal.tsx
const openModal = useCallback(
(element: JSX.Element): Promise<unknown> => {
return new Promise((resolve: PromiseResolve, reject: PromiseReject) => {
const modal = {
element,
modalId,
resolve,
reject,
};
open(modal);
});
},
[modalId, open],
);
const handleResolve = useCallback(
<T extends {}>(modal: ModalType, value?: T) => {
modal.resolve(value);
closeModal();
},
[closeModal],
);
openModal이 결과만 받을 수 있도록 `Promise`를 반환하도록 한다. 이때 `resolve`와 `reject`를 modal의 상태에 함께 저장한다. handleResolve에서는 modal을 받아 그 모달 상태에 저장된 resolve를 이용하고, 실행 후 모달이 닫힐 수 있도록 처리한다.
const Modal = ({ modal, onClose, onSubmit }: { modal: ModalType } & ModalProps) => {
const { element } = modal;
const handleResolve = <T extends {}>(value?: T) => onSubmit?.(modal, value);
return <>{cloneElement(element, { onClose, handleResolve })}</>;
};
✅ 레이아웃 분리로 Modal 컴포넌트는 클론 되는 컴포넌트만 렌더링 한다. 이 부분은 챕터 6에서 다룬다.
여기까지 원하는 대로 동작도 하는데 어딘지 모르게 찜찜~하다.
modal 상태를 저장하는 시점에 resolve와 reject함수의 구현부를 미리 작성하여 캡슐화하는 것으로 개선했다. (구조를 보기 위해 타입을 제거한 코드로 변경했다.)
const openModal = useCallback(
(component) =>
new Promise((resolve, reject) => {
const modal = {
element: createElement(component),
modalId,
resolve: (value) => {
resolve(value);
closeModal();
},
reject: (reason) => {
reject(reason);
closeModal();
},
};
open(modal);
}),
[modalId, open, closeModal],
);
이렇게 미리 구현을 해두면 어디선가 따로 래핑 하지 않고 함수 그 자체를 바로 사용할 수 있다.
>_ Modal.tsx
const Modal = ({ modal }: { modal: ModalType }) => {
const { element, resolve, reject } = modal;
return <>{cloneElement(element, { onSubmit: resolve, onAbort: reject })}</>;
};
렌더링 되는 Modal 컴포넌트를 보면 차이점이 확실히 보인다. modal에 저장된 resolve와 reject 함수를 props로 바로 전달하여 호출해 사용하기만 하면 된다.
✅ onSubmit을 옵셔널로 두었던 이유
Confirm의 역할을 하는 모달은 확인을 눌렀을 때 어떤 결괏값이 없다고 생각했다. 꼭 서버 통신이 아니더라도 `확인`을 누르는 액션도 onSubmit으로, `취소`를 누르는 액션도 onAbort로 처리할 수 있다는 점을 인지하지 못했다. 결괏값을 호출 컴포넌트에서 처리할 수 있도록 만들었기 때문에 단순 확인의 역할만으로도 toast를 띄우거나 또 다른 액션을 적용해 사용자 경험을 개선할 수도 있겠다는 생각이 든다.
보통 다른 모달 구현에서는 라우팅 대응 방법으로 뒤로가기를 막거나 params를 이용하기도 하는데 useEffect를 활용했다.
//useModal.tsx
useEffect(() => closeModal, []);
useModal 커스텀훅은 훅을 호출한 컴포넌트의 라이프사이클에 따른다. 라우팅 변화로 호출 컴포넌트가 언마운트가 되면 useModal도 언마운트 되므로 clean-up 처리를 해주면 페이지 이동 시 열린 모달이 닫힌다. 단, 이 방법은 '무조건 닫힌다'가 기본이기 때문에 뒤로가기 이벤트에 따라 다른 로직이 추가되어야 한다면 수정이 필요하다.
이제 모달 호출 과정을 지금까지 최종 개선된 코드의 흐름으로 따라가 본다.
//호출
const ChatRooms = () => {
const { openModal, renderModal } = useModal();
...
const handleCreate = async () => {
try {
const chatRoomInfo = await openModal(CreateChatRoomModal);
mutate({ user, chatRoomInfo });
} catch (err) {
console.log('모달 닫습니다~!');
}
};
return (
<>
<section>
<h1>정류장</h1>
<nav>
<Button text="배차하기" variant="accent" onClick={handleCreate} />
</nav>
</section>
{renderModal()}
</>
);
};
//모달
const CreateChatRoomModal = ({ onSubmit, onAbort }: ModalContentProps<ChatRoomInfo>) => {
return (
<ModalTemplate onClose={onAbort} isOverlay={true}>
<ModalHeader title={'채팅방 생성'} onClose={onAbort} />
<ModalContent>
<Form onSubmit={onSubmit}>
<ModalButtonGroup>
<Button text="방만들기" variant="accent" type="submit" />
<Button text="취소" variant="default" onClick={onAbort} />
</ModalButtonGroup>
</Form>
</ModalContent>
</ModalTemplate>
);
};
서비스에서 다른 모달이 필요하다면 모달 레이아웃과 관련된 UI 템플릿을 이용해 원하는 모달 컴포넌트를 만들고, 어떤 컴포넌트에서든지 useModal 훅이 반환하는 openModal에 전달해주기만 하면 된다. 모달 컴포넌트 내부에서는 사용자의 액션에 따라 어떤 로직을 실행할 것인지를 작성해 적용하면 그에 따른 결과를 호출 컴포넌트가 받을 수 있다.
즉, 개발자는 모달의 내부 구현을 이해하지 않아도 위 2가지 규칙으로 새로운 모달을 만들 수 있다. 모달 레이아웃의 경우 템플릿 컴포넌트로 별도 분리하여 속성 또는 위치 등을 모달마다 자유롭게 커스텀할 수 있도록 했다. 이 부분은 다음 마지막 챕터에서 다루도록 한다.
이로써 모달이 생길 때마다 type을 추가하는 방법을 쓰지 않겠다는 생각으로 시작해 선언적인 모달이란 무엇인가로 이어지고, 모달 관련 로직을 추상화하고 선언적으로 사용할 수 있게 만들어보자!라는 목표로 달려온 전역 모달 리팩토링 대장정이 끝났다. 중간에 라이브러리의 유혹이 어마어마했지만 끝까지 파고들어 나만의 소박한 모달 라이브러리(?)를 만들어냈다. 완성된 결과만 보면 간단해 보이는 느낌이 조금 허무하기도 하지만, 이 과정을 거치면서 기본기를 많이 배웠다고 느낀다. 기회가 된다면 조금 더 다듬어서 라이브러리로 배포하는 경험도 해보고 싶다!
마지막으로 이 과정이 오래 걸리고 어렵게 느껴졌던 이유를 되돌아보며 간단히 회고해 보자면
이제 다짐은 무겁게 마음은 가볍게 모달 레이아웃 분리 과정을 담은 마지막 챕터로 넘어가 본다.
React 전역 Modal 추상화 정복기 - 마지막 (0) | 2024.04.16 |
---|---|
React 전역 Modal 추상화 정복기 - (4) (0) | 2024.04.13 |
React 전역 Modal 추상화 정복기 - (3) (0) | 2024.04.13 |
React 전역 Modal 추상화 정복기 - (2) (2) | 2024.04.13 |
React 전역 Modal 추상화 정복기 - (1) (2) | 2024.04.13 |