모달의 인자와 반환값을 추론할 수 있는 타입 기반 모달 시스템
프로덕션을 개발하면서 단순한 알림(alert)이나 확인(confirm) 정도를 넘어서, 사용자 입력을 받아야 하거나 파일 업로드 같은 비동기 처리를 수행해야 하는 등 꽤나 복잡한 로직이 모달 안에서 실행되곤 했습니다.
기존에는 모달의 표시 여부를 전역 상태의 플래그로 관리하는 방식으로 개발을 진행해 모달의 개수가 늘어날수록 전역 상태에 불필요한 상태 값이 누적되고, 특정 모달에서만 사용하는 데이터조차 전역으로 확장되면서 응집도가 떨어지는 문제가 발생했습니다.
이러한 문제는 프론트엔드 팀 회고 과정에서도 자주 언급되었고, 실제로 모달 관련 코드가 프로젝트의 복잡도를 높이는 주요 원인 중 하나였습니다.
이를 개선하기 위해, 모달을 하나의 함수처럼 호출하고 props와 결과를 모두 타입 안전하게 다룰 수 있는 시스템을 만들었습니다. 이 글에서는 해당 구조를 어떻게 설계하고 구현했는지 설명합니다.
기존 모달 관리 방식의 문제
프로젝트 규모가 커질수록 피로감이 커지고 모달 관련 개발 이슈도 자주 회고 주제로 등장했는데요, 대표적인 문제는 다음과 같았습니다.
-
모달이 열려 있는지 플래그 변수를 전역 상태에 저장해 관리하는 방식은, 모달 수가 늘어날수록 복잡해지고 코드도 많아져 피로도가 높아진다.
-
반대로 상태 없이 (UnControlled) JSX 내에서 트리거를 통해 모달을 띄우는 방식은, 모달을 세밀하게 제어하기 어려워 기능 확장이 어렵다.
-
파일 업로드나 사용자 입력 등 모달에서만 사용하는 상태를 다뤄야 할 때 이를 전역 상태로 처리해 캡슐화가 안되어 유지보수성이 떨어진다.
-
(+) 기존 프로젝트가 Redux-Saga 기반이라 Hook으로 관리하는 방식을 사용하기 애매해 React 외부에서도 모달 제어가 필요하다.
위와 같은 이유로 비즈니스 로직이 어디에 위치해야 할지 모호해지고, 코드의 응집도 역시 떨어지게 됩니다.
-
전역 상태에 불필요한 플래그 변수를 만들지 않을 것
-
모달 호출 시 필요한 데이터는 props로 전달하고 전역 상태를 사용하지 않을 것
-
모달의 기능에 맞는 단일 책임을 수행할 것 (기존에는 전역 상태에 비지니스 로직이 혼재되는 등 응집도를 낮추는 원인)
-
(+) 플래그 관리 방식은 플래그별 모달 컴포넌트가 연결된 코드까지 찾아야해서 번거로움이 있어, 모달 호출부에서 바로 해당 모달 컴포넌트의 코드로 이동할 수 있을 것
전체적인 구조
한번에 정리하면 구조는 아래와 같습니다.
-
Business Logic (모달 호출부)에서는 하나의 비동기 함수를 사용하는 것 처럼 모달을 여는 함수를 호출할 수 있고, 모달 컴포넌트에서 반환하는 결과를 반환 받습니다.
-
모달 상태 관리자에서는 컴포넌트와 Promise를 반환할 때 생성된 Promise와 모달 렌더링 시 필요한 props를 저장합니다.
-
모달의 렌더링을 담당하는 프로바이더에서는, 열려있는 모달의 상태가 있는 경우 모달 관리 함수를 주입해 UI에 렌더링 합니다.
-
모달 컴포넌트는 필수적으로 지정이 필요한 타입이 있어 생성 함수를 통해 만들어집니다.
위 구조가 완성되면 모달을 열때, 모달 컴포넌트 안에서 결과 값을 반환할 때, 결과 값을 받을 때 모두 타입 추론이 가능합니다.
- 모달을 호출할 때, 모달을 렌더링할 때 필요한 props의 타입을 자동으로 추론할 수 있습니다.
- 모달 컴포넌트에서 어떤 값을 반환할지 주입받는 submit 함수의 인자 값 타입을 자동으로 추론할 수 있습니다.
- 모달을 호출 시, 컴포넌트에서
submit
함수로 반환하는 타입을 자동으로 추론할 수 있습니다.
Promise 기반 모달 함수 만들기
먼저 비지니스 로직에서 모달을 호출하는 코드를 보면, 스토어에서 꺼낸 openModal
함수로 첫번째 인자에 모달 컴포넌트, 두 번째 인자에 props를 넣어 모달을 열 수 있습니다.
모달에서 반환하는 결과 값 / props 모두 타입스크립트 타입 추론이 가능합니다. Promise는 어디에 저장되는지, 어떻게 타입 추론이 가능한지는 이후 모달 스토어 섹션에서 후술되어 있습니다.
const result = await openModal(FileUploadModal, {
acceptedFiles: [".xlsx", ".pptx"],
});
모달의 상태를 저장하는 스토어 만들기 (useModalState)
모든 모달을 useModalState
상태에서 관리하는데요, 모달 관리 스토어에서 수행해야 하는 역할은 다음과 같습니다.
- 타입 추론이 가능한 모달을 제어하는 함수를 반환하기
- 비지니스 로직에서 모달을 열었을 때, 모달 컴포넌트를 저장하기
스토어에서 사용하는 전역 관리는 zustand
를 사용해 구현했습니다.
const initialModalState: ModalManagerState = {
stack: [],
};
먼저 모달은 여러개가 켜질 수 있고, 이 경우 가장 마지막에 연 모달이 보일 수 있도록 스택 구조로 데이터를 저장해줍니다.
그리고 모달을 제어하는를 보면, 모달을 여는 시점에 생성된 랜덤 id를 기반으로 목표하는 모달을 찾아 동작을 수행합니다. 랜덤 id 같은 경우에는 프로바이더에서 자동으로 주입해주기 때문에 개발자가 신경쓸 일은 없습니다.
모달 컴포넌트 안에서 주입되는 함수
closeModal
: 결과 값으로 null을 반환하고, 전역 상태에서 해당 모달을 제거합니다.
const closeModal = (id: string) => {
const foundModal = get().stack.find((v) => v.id === id);
if (!foundModal) throw new Error(foundModal);
foundModal.resolve(null);
set((state) => {
state.stack = state.stack.filter((v) => v.id !== id);
});
};
submit
: 결과 값으로 모달에서 받은 값으로resolve
를 호출해 모달 코드 호출부 (비지니스 로직)에 값을 전달하고, 전역 상태에서 해당 모달을 제거합니다.
const submitModal = (id: string, payload: any) => {
const foundModal = get().stack.find((v) => v.id === id);
if (!foundModal) throw new Error(foundModal);
foundModal.resolve(payload);
set((state) => {
state.stack = state.stack.filter((v) => v.id !== id);
});
};
모달 호출부 (비지니스 로직)에서 사용되는 함수
openModal
: 렌더링 할 모달을 여는 함수로 모달 컴포넌트와, 컴포넌트의 props, 옵션을 인자로 받습니다.
제네릭 타입 T
는 모달 컴포넌트를 의미합니다. 이를 통해 2가지의 타입을 추론하는데요,
- 컴포넌트를 렌더링할 때 필요한 props의 타입
React.ComponentProps
를 사용해 컴포넌트의 Props 타입만 추출한 후, 모달을 제어하는 용도로 자동으로 주입되는 modal
키는 제거해줍니다.
- 모달에서 반환하는 값의 타입
GetSubmitType
라는 유틸함수를 만들어 사용했습니다.
모달 컴포넌트가 모달 제어 함수로 주입받는 함수 중 submit
의 인자 타입이 곧 모달에서 반환해야 하는 값의 타입이므로 함수의 파라미터를 파싱합니다.
type ModalPropsType<T> = {
close: () => void;
submit: (data: T | null) => void;
};
type GetSubmitType<T extends React.FunctionComponent<any>> = Parameters<
Pick<React.ComponentProps<T>, "modal">["modal"]["submit"]
>[0];
openModal: <T extends React.FunctionComponent<any>>(
ModalComponent: WithModalComponent<T>,
props: Omit<React.ComponentProps<T>, "modal">,
option?: ModalOpenOptionType
) => {
return new Promise<GetSubmitType<T>>((resolve) => {
const id = nanoid();
set((state) => {
state.stack.push({
id,
component: ModalComponent,
props,
resolve,
option,
});
});
});
};
modal
타입에 관한 내용은 모달 컴포넌트를 만드는 modalFactory
함수를 통해서 지정할 수 있습니다.
모달 렌더링을 담당하는 프로바이더에서 사용하는 함수
getModalProps
: 단순히 현재 화면에 보여지고 있는 모달을 닫는 함수와 입력된 값을 제출하는 모달 제어 함수를 찾아 반환합니다.
추후 반환된 모달 제어 함수는, 모달 컴포넌트에 cloneElement
로 주입되게 됩니다.
getModalProps: (id: string) => {
return {
close: () => {
closeModal(id);
},
submit: (data: any) => {
submitModal(id, data);
},
};
},
Props와 결과의 타입을 지정할 수 있는 모달 만들기 (Modal Component (with modalFactory))
모달을 띄우기 위해서는 해당 컴포넌트가 어떤 props를 필요로 하는지 알아야 합니다. 이는 다음 세 가지 유형으로 나눌 수 있습니다.
-
모달 내부에서 훅이나 전역 상태로 가져오는 데이터
-
외부에서 props로 넘겨야 하는 데이터
-
모달 시스템이 주입하는 제어용 메소드 (close, submit 등)
1번은 컴포넌트 내부에서 해결되므로 모달을 호출할 때는 신경 쓸 필요가 없습니다.
필요한 타입은 2번과 3번입니다. 외부에서 모달을 호출할 때 필요한 props가 무엇인지 타입으로 추론할 수 있어야 하며, submit 함수에서 전달받는 인자의 타입도 외부에서 알 수 있어야 합니다.
2번의 경우 React.ComponentProps
를 통해 타입을 추론할 수 있지만 컴포넌트 내부에서 값을 제출하는 함수에 어떤 타입의 값이 들어가는지는 외부에서 추론할 수 없습니다.
const ExampleModal = () => {
const data = {
name: "홍길동",
};
export type ModalReturnType = typeof data; // 불가능
submitModal(data);
return <>... </>;
};
따라서, 모달 컴포넌트 밖에 모달이 반환해야 하는 타입을 만들어야 합니다.
이를 위해 모달을 제어하기 위해 주입되는 함수에 모달에서 반환하는 타입을 지정하도록 만들었습니다.
type ModalPropsType = {
// ...컴포넌트 props
modal: {
submit: (payload: Record<string, string>) => void;
};
};
위 타입을 모달 선언 시 중복해서 쓰면 불필요한 코드가 많아지고, 실수가 일어날 수도 있어서 올바른 타입으로 선언할 수 있는 유틸 함수를 만들었습니다.
InputType
에 모달 컴포넌트를 렌더링 할 때 필요한 props의 타입, OutputType
이 모달 컴포넌트에서 반환해야 하는 타입입니다.
export const modalFactory = <InputType, OutputType>(
Component: React.FunctionComponent<
InputType & {
modal: ModalPropsType<OutputType>;
}
>
) => {
return Component;
};
const MyModal = modalFactory<
// ... 모달 컴포넌트의 props 타입,
// ... 모달 컴포넌트에서 submit 함수에 인자로 들어갈 타입
>(({ modal }) => <>...</>);
modalFactory
를 통해 컴포넌트를 선언하면, 컴포넌트의 props 타입과 모달 컴포넌트에서 반환할 타입을 props에 modal
이라는 키로 넣어주게 됩니다.
모달 호출 하기 (Business logic)
openModal
함수는 상태 관리 파트에서 언급한 것 처럼 modalFactory
로 만들어진 모달 컴포넌트를 사용할지 인자로 받고, 해당 컴포넌트의 props 타입과 결과 타입을 자동으로 추론합니다.
const result = await openModal(모달 컴포넌트, 모달 컴포넌트의 props)
모달에 넘기는 props는 cloneDeep
이나 structuredClone
를 통해 복사한 값을 저장할까 하다가 원본 그대로 저장했습니다.
이에 따라 함수의 참조가 유지되어 호출할 때 가지고 있는 상태를 업데이트 하는 함수를 모달에 넣어 사용할 수도 있긴 합니다.
const [count, setCount] = useState(0);
const openModal = useModalState().actions.openModal;
return (
<>
<p>카운트: {count}</p>
<Button
onClick={() => {
openModal(CountModal, {
onCount: () => {
setCount((v) => v + 1);
},
});
}}
>
Open Modal
</Button>
</>
);
값을 그대로 저장할지 복사할지 고민하다가 함수의 참조가 유지되어 상태를 변경할 수 있도록 자유롭게 사용할 수 있는 구조로 만들어두었지만,
상태 변경을 디버깅하기 어려워진다거나, 도입부에서 언급했던 비지니스 로직이 파편화 되어 코드의 응집도가 떨어지는 등의 이슈가 생기면 값을 복사해 저장하는 방법으로 전환하는걸 고려중입니다.
스토어에 있는 모달을 렌더링하기 (ModalProvider)
const ModalProvider = ({ children }: ModalProviderProps) => {
return (
<>
{!!TopModalComponent && (
<div className={backgroundClassName}>
<div ref={modalWrapperRef}>{TopModalComponent}</div>
</div>
)}
{children}
</>
);
};
평소에는 children을 그대로 렌더링하고, 열려있는 모달이 있을 때만 모달을 렌더링하도록 되어 있습니다.
렌더링하는 모달 컴포넌트 TopModalComponent
는 아래 2가지 과정을 통해 만들어집니다.
-
스택에서 가장 위에 있는 (=가장 최신에 연 모달) 모달을 가져온다.
-
(1)에 모달 제어 함수를 주입한다.
const topModal = useModalManager((state) => state.stack.at(-1));
const { getModalProps, closeModal } = useModalManager((state) => state.actions);
const TopModalComponent = useMemo(() => {
if (!topModal) return null;
return React.cloneElement(<topModal.component {...topModal.props} />, {
modal: getModalProps(topModal.id),
});
}, [topModal]);
Zustand 스토어에서 .at(-1)로 마지막 모달을 가져오고, 열려 있는 모달이 있다면 해당 컴포넌트에 close, submit 같은 제어 함수들을 React.cloneElement로 주입합니다.
getModalProps
코드에서 설명한 것 처럼 모달 생성 시 자동으로 부여되는 id로 매칭되기 때문에, 모달 컴포넌트를 선언해 사용하는 개발자는 id는 신경쓸 필요가 없습니다.
이후 클론된 컴포넌트를 렌더링하며 필요에 따라 배경 오버레이, 외부 클릭 시 닫기, body 스크롤 잠금 등을 옵션에 따라 수행합니다.
정리
모달의 Props와 결과 타입 추론이 가능한 모달 관리 시스템을 만들어 프론트 회고 때 나왔던 전역 상태 관리 문제, 응집도가 떨어지는 문제, 불필요한 전역 상태를 생성하는 문제 등을 해결할 수 있었습니다.
이렇게 구조를 개선하면서 프로덕트에서도 불필요한 상태, 로직, 이분화된 타입 코드 등을 꽤 많이 제거할 수 있었고 window.confirm
처럼 값을 반환받을 수 있어 코드를 작성할 때 동기적인 코드 흐름 안에서 UI 제어와 비즈니스 로직을 가독성 좋게 작성할 수 있다는 장점이 있었습니다.