Zustand로 흩어진 모달 상태를 한곳에서 관리하기
- 1.다중 OAuth 제공자 환경에서 DX와 UX 동시에 달성하기
- 2.천진난만 Go 성적계산기 도입 이야기
- 3.Turborepo 도입으로 빌드 시간과 CI 가독성 함께 개선하기
- 4.Zustand로 흩어진 모달 상태를 한곳에서 관리하기← 현재

서론
Hello, GSM에는 로그인 안내, 결과 발표, 서류 제출 여부 변경, 이미지 용량 제한 안내처럼 사용자에게 확인을 요청하거나 상황을 알리는 모달이 많습니다. 사용자용 페이지와 관리자 페이지를 합치면 18개에 달했습니다.
기존에는 모달이 필요한 컴포넌트마다 열림 상태와 마크업을 직접 가지고 있었습니다. 모달 수가 적을 때는 문제가 없었지만, 기능이 늘어나면서 비슷한 코드가 여러 곳에 흩어지고 같은 모달을 다른 곳에서 쓰기 어려워졌습니다.
이번 글에서는 Zustand 기반 전역 모달 스토어와 ModalContainer로 모달 관리를 중앙화한 과정, 그리고 적용 후 얻은 변화와 아쉬웠던 점을 정리합니다.
기존 구조의 문제
상태와 마크업이 컴포넌트마다 흩어져 있었습니다
예를 들어 증명사진을 업로드하는 UploadPhoto 컴포넌트는 파일 용량이 5MB를 넘으면 안내 모달을 띄웁니다. 기존 코드는 다음과 같았습니다.
const UploadPhoto = ({ setValue, watch }: UploadPhotoProps) => {
const [showModal, setShowModal] = useState(false);
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
if (file.size >= MAX_FILE_SIZE) return setShowModal(true);
// ...
};
return (
<>
{/* 업로드 UI */}
<AlertDialog open={showModal}>
<AlertDialogContent className={cn('w-[400px]')}>
<AlertDialogHeader>
<AlertDialogTitle>이미지는 5MB 이하만 가능합니다.</AlertDialogTitle>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogAction onClick={() => setShowModal(false)}>확인</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
};
컴포넌트의 본래 역할은 사진 업로드이지만, 모달의 열림 상태와 마크업까지 함께 책임지고 있었습니다. 이런 구조가 회원가입 페이지, 성적 계산 페이지, 결과 확인 페이지, 관리자 지원자 목록 등 프로젝트 전반에 반복되어 있었습니다.
같은 형태의 모달을 반복해서 구현했습니다
관리자 페이지의 FilterBar에는 1차 결과 발표와 2차 결과 발표 버튼이 있습니다. 두 버튼이 띄우는 모달은 문구와 버튼 구성이 완전히 같았지만, 상태와 마크업은 각각 따로 작성되어 있었습니다.
const [showFirstModal, setShowFirstModal] = useState<boolean>(false);
const [showSecondModal, setShowSecondModal] = useState<boolean>(false);
// ...
<AlertDialog open={showFirstModal}>
{/* "확인 버튼 클릭시 전체 지원자들에게 합격, 불합격 여부가 공개됩니다." */}
</AlertDialog>
<AlertDialog open={showSecondModal}>
{/* 같은 문구, 같은 버튼 구성 */}
</AlertDialog>
원서 제출 완료 모달도 비슷했습니다. 사용자용과 관리자용은 문구와 이동 경로만 다를 뿐 구조가 같았지만, 이를 하나로 묶을 공통 위치가 없었습니다. 결국 모달을 다른 곳에서 재사용하려면 상태 선언과 마크업을 다시 작성하거나, 기존 컴포넌트를 수정해야 했습니다.
개선 방향
필요했던 것은 모달을 하나씩 고치는 것이 아니라, 모달을 다루는 방식 자체를 정리하는 것이었습니다. 목표는 다음과 같았습니다.
- 모달의 열림 상태를 한곳에서 관리할 것
- 모달의 마크업을 한곳에서 렌더링할 것
- 모달을 사용하는 컴포넌트는 "열기" 액션만 호출할 것
- 모달마다 필요한 추가 데이터를 타입으로 표현할 것
상태 관리 도구로는 Zustand를 선택했습니다. 별도 Provider 없이 훅 하나로 상태와 액션을 꺼내 쓸 수 있고, 스토어를 모노레포의 shared 패키지에 두면 사용자용과 관리자용 애플리케이션에서 같은 스토어를 그대로 가져다 쓸 수 있었습니다. 모달 열림 여부처럼 단순한 UI 상태를 다루기에는 충분히 가벼운 도구라고 판단했습니다.
구현
모달 타입 정의
먼저 모달이 가질 수 있는 상태를 타입으로 정리했습니다. 모든 모달은 isOpen을 기본으로 가지고, 모달마다 필요한 값이 있다면 확장하는 방식입니다.
export interface SimpleModal {
isOpen: boolean;
}
export interface ConfirmModal extends SimpleModal {
onConfirm: () => void;
}
export interface LoginModal extends SimpleModal {
isMain: boolean;
}
export interface ScoreModal extends SimpleModal {
data: MockScoreType | null;
type: 'score' | 'mock';
}
export interface ApplicationModal extends SimpleModal {
type: 'client' | 'admin';
}
처음에는 추가 데이터가 없는 모달을 boolean 하나로만 표현했습니다. 하지만 코드 리뷰에서 다른 모달은 { isOpen: boolean } 형태인데 단순 모달만 boolean이라 일관성이 떨어진다는 의견을 받았습니다. 나중에 단순 모달에 값이 추가될 때도 객체 형태가 확장하기 쉽다고 판단해, 모든 모달을 SimpleModal을 기반으로 하는 객체 형태로 통일했습니다.
모달은 사용 위치에 따라 세 그룹으로 나누었습니다.
| 그룹 | 개수 | 예시 |
|---|---|---|
| AdminModals | 5 | 서류 제출 여부 변경, 입학동의서 제출 여부 변경, 1차·2차 결과 발표 |
| ClientModals | 9 | 로그인 필요 안내, 결과 조회 기간 안내, 회원가입 성공·실패, 전화번호 중복 |
| SharedModals | 4 | 시스템 점검, 성적 계산 완료, 원서 제출 완료, 이미지 용량 제한 |
export interface ModalStore extends AdminModals, ClientModals, SharedModals, ModalActions {}
전역 모달 스토어
스토어는 그룹별 초기값과 모달별 액션으로 구성했습니다. 초기값은 그룹마다 생성 함수로 분리해, 어떤 모달이 어느 그룹에 속하는지 한눈에 보이도록 했습니다.
const createInitialSharedModals = (): SharedModals => ({
systemInspectionModal: { isOpen: false },
scoreCalculationCompleteModal: { isOpen: false, data: null, type: 'mock' },
applicationSubmitModal: { isOpen: false, type: 'client' },
imageUploadSizeLimitModal: { isOpen: false },
});
export const useModalStore = create<ModalStore>((set) => ({
...createInitialAdminModals(),
...createInitialClientModals(),
...createInitialSharedModals(),
setImageUploadSizeLimitModal: (isOpen) => {
set({ imageUploadSizeLimitModal: { isOpen } });
},
setScoreCalculationCompleteModal: (isOpen, data = null, type = 'mock') => {
set({ scoreCalculationCompleteModal: { isOpen, data, type } });
},
// ...
}));
액션은 set{모달 이름}(isOpen, ...추가 데이터) 형태로 통일했습니다. 추가 데이터에는 기본값을 두어, 단순히 모달을 닫을 때는 setXxxModal(false)만 호출하면 되도록 했습니다.
확인 동작은 콜백으로 전달
서류 제출 여부 변경처럼 "확인"을 눌렀을 때 실행할 동작이 호출하는 쪽마다 다른 모달도 있었습니다. 관리자 지원자 목록에서는 행마다 변경할 지원자가 다르기 때문에, 모달 안에 동작을 고정할 수 없었습니다.
이런 모달은 ConfirmModal 타입으로 정의하고, 모달을 열 때 실행할 함수를 onConfirm으로 함께 전달했습니다.
// ApplicantTR
<Toggle onClick={() => setDocumentSubmissionChangeModal(true, handleRealOneseoArrived)} />
// AdminModals
<AlertDialogAction onClick={documentSubmissionChangeModal.onConfirm}>변경하기</AlertDialogAction>
모달은 "무엇을 실행하는지"를 알 필요 없이 전달받은 동작만 실행하고, 실제 비즈니스 로직은 기존처럼 호출하는 컴포넌트에 남겨둘 수 있었습니다.
ModalContainer로 렌더링 위치 통합
모달 마크업은 그룹별 컴포넌트로 옮기고, 이를 ModalContainer 하나로 묶었습니다.
const ModalContainer = () => {
return (
<>
<AdminModals />
<ClientModals />
<SharedModals />
</>
);
};
그리고 두 애플리케이션의 루트 레이아웃에 한 번만 배치했습니다.
<Provider>
{children}
<ModalContainer />
</Provider>
모달 컴포넌트들이 shared 패키지로 이동하면서, 모달 안에서 사용하던 컴포넌트도 함께 옮겨야 했습니다. 로그인 필요 안내 모달은 사용자용 애플리케이션에 있던 LoginDialog와 LoginIcon을 사용하고 있었는데, shared 패키지는 애플리케이션 코드를 참조할 수 없습니다. 그래서 두 컴포넌트를 shared 패키지로 옮겨 의존 방향을 맞췄습니다.
사용하는 쪽의 변화
이제 컴포넌트는 모달의 존재를 마크업으로 알 필요가 없습니다. 앞서 본 UploadPhoto는 다음과 같이 바뀌었습니다.
const UploadPhoto = ({ setValue, watch }: UploadPhotoProps) => {
const { setImageUploadSizeLimitModal } = useModalStore();
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
if (file.size >= MAX_FILE_SIZE) {
setImageUploadSizeLimitModal(true);
return;
}
// ...
};
return <>{/* 업로드 UI */}</>;
};
useState와 AlertDialog 마크업이 사라지고, 모달이 필요한 시점에 액션을 호출하는 한 줄만 남았습니다. 컴포넌트는 사진 업로드라는 본래 역할에만 집중할 수 있게 되었습니다.
결과
이번 작업에서 수정한 파일은 34개입니다. 새로 추가한 스토어, 타입, 모달 그룹 컴포넌트를 제외하면, 기존 컴포넌트에서는 주로 코드가 줄었습니다.
| 파일 | 삭제 | 추가 |
|---|---|---|
| SignUpPage | 107줄 | 22줄 |
| FilterBar | 80줄 | 7줄 |
| ApplicantTR | 75줄 | 25줄 |
| StepWrapper | 56줄 | 8줄 |
| CalculatePage | 41줄 | 13줄 |
전체 변경량은 902줄 추가, 711줄 삭제로 오히려 늘었습니다. 다만 늘어난 코드의 대부분은 흩어져 있던 모달 마크업이 그룹별 파일로 모인 것입니다. 코드의 양보다는 위치가 바뀐 작업에 가까웠습니다.
체감한 변화는 다음과 같습니다.
- 모달을 띄우기 위해 상태를 선언하고 마크업을 작성할 필요 없이, 스토어 액션 하나만 호출하면 됩니다.
- 같은 모달을 다른 페이지에서 사용할 때 기존 컴포넌트를 수정하지 않아도 됩니다.
- 프로젝트에 어떤 모달이 있는지 타입 파일 하나에서 확인할 수 있습니다.
- 모달 문구나 스타일을 수정할 때 그룹별 모달 파일만 찾으면 됩니다.
실제로 이후 원서 미제출자 안내 모달, 테스트 기간 안내 모달 등이 추가될 때도 같은 구조 위에서 작업할 수 있었습니다.
아쉬웠던 점
두 애플리케이션이 모든 모달을 렌더링합니다
ModalContainer는 사용자용과 관리자용 애플리케이션 모두에서 Admin, Client, Shared 모달을 전부 렌더링합니다. 사용자용 페이지에서는 관리자 모달이 열릴 일이 없지만, 컴포넌트 자체는 마운트됩니다.
특히 AdminModals는 결과 발표 처리를 위해 운영 정보 조회 쿼리를 사용합니다. 이 때문에 사용자용 애플리케이션에서도 관리자 전용인 운영 정보 조회 API가 호출되었고, 실제로 사용자용 페이지에서 403 에러가 발생했습니다.
이후 ModalContainer가 props로 애플리케이션을 구분해, 해당 애플리케이션의 모달만 렌더링하도록 수정했습니다.
const ModalContainer = ({ modal }: ModalContainerProps) => {
return (
<>
{modal === 'admin' && <AdminModals />}
{modal === 'client' && <ClientModals />}
<SharedModals />
</>
);
};
이 수정으로 불필요한 API 호출과 에러는 사라졌습니다. 다만 조건부 렌더링은 코드 분할이 아니기 때문에, 두 애플리케이션의 번들에는 여전히 서로의 모달 코드가 함께 포함됩니다.
스토어 전체를 구독하고 있습니다
각 컴포넌트는 useModalStore()에서 필요한 값을 구조 분해해 사용합니다. 셀렉터 없이 스토어 전체를 구독하는 방식이라, 모달 하나의 상태가 바뀌어도 스토어를 사용하는 모든 컴포넌트가 다시 렌더링됩니다.
모달 상태는 자주 바뀌는 값이 아니어서 체감할 만한 성능 문제는 없었습니다. 다만 회원가입 폼처럼 큰 컴포넌트도 스토어를 구독하고 있어, 사용하는 곳이 늘어날수록 불필요한 렌더링도 함께 늘어나는 구조입니다.
닫히는 순간 모달 내용이 바뀝니다
모달마다 추가 데이터에 기본값을 둔 결정은 모달을 닫을 때 예상하지 못한 문제를 만들었습니다. 예를 들어 setScoreCalculationCompleteModal(false)로 모달을 닫으면 data와 type도 기본값인 null과 'mock'으로 함께 초기화됩니다.
모달은 닫힐 때 짧은 애니메이션이 재생되는데, 그사이 내용이 먼저 바뀌어 버립니다. 성적 계산 완료 모달은 점수가 빈칸으로 바뀌고, 관리자용 원서 수정 완료 모달은 사용자용 문구로 잠깐 바뀐 채 사라집니다.
이후 결과 조회 기간 안내 모달에서 같은 문제가 발견되어 해당 모달만 기존 값을 유지하도록 수정했습니다. 하지만 원인인 액션 구조는 바뀌지 않았습니다.
모달 하나를 추가할 때 수정할 곳이 많습니다
새 모달을 추가하려면 타입, 초기값, 액션, 렌더링 네 곳을 함께 수정해야 합니다. 모달마다 전용 상태와 액션을 두는 방식은 타입이 명확하다는 장점이 있지만, 모달 수가 늘어날수록 스토어와 타입 파일, 모달 그룹 파일도 함께 길어집니다.
지금 돌아보면
시간이 지나 다시 코드를 살펴보니, 같은 목표를 더 적은 비용으로 달성할 수 있었던 방법들이 보였습니다.
지금 구조를 유지한다면
전체 구조를 바꾸지 않더라도 다음과 같은 개선은 바로 적용할 수 있었을 것 같습니다.
- 액션만 필요한 컴포넌트는 셀렉터로 해당 액션만 구독할 것
- 모달을 닫을 때는
isOpen만 바꾸고 추가 데이터는 유지할 것 - 관리자용·사용자용 모달은 shared 패키지가 아닌 각 애플리케이션에 둘 것
// 스토어 전체 구독
const { setImageUploadSizeLimitModal } = useModalStore();
// 필요한 액션만 구독
const setImageUploadSizeLimitModal = useModalStore((state) => state.setImageUploadSizeLimitModal);
액션 함수는 참조가 바뀌지 않으므로, 이렇게 구독하면 다른 모달의 상태가 바뀌어도 해당 컴포넌트는 다시 렌더링되지 않습니다. 변경량은 크지 않지만 앞서 정리한 렌더링과 앱 경계 문제의 상당 부분을 줄일 수 있습니다.
모든 모달을 전역 상태로 올릴 필요는 없었습니다
중앙화를 결정한 가장 큰 이유는 재사용이었습니다. 하지만 PR 시점의 호출 위치를 다시 세어 보니, 18개 모달 중 두 곳 이상에서 여는 모달은 로그인 필요 안내와 성적 계산 완료 2개뿐이었습니다. 15개는 한 곳에서만 열렸고, 시스템 점검 모달은 여는 곳이 없었습니다.
돌아보면 반복 구현의 원인은 상태가 각 컴포넌트에 있었기 때문이라기보다, 같은 형태의 마크업을 공통 컴포넌트로 추출하지 않았기 때문에 가까웠습니다. 안내형·확인형 모달의 마크업만 공통 컴포넌트로 만들고 열림 상태는 기존처럼 각 컴포넌트에 두었다면, 타입·스토어·컨테이너를 함께 관리하지 않고도 중복의 상당 부분을 줄일 수 있었을 것 같습니다.
const [isSizeErrorOpen, setIsSizeErrorOpen] = useState(false);
<NoticeDialog
open={isSizeErrorOpen}
onOpenChange={setIsSizeErrorOpen}
title="이미지는 5MB 이하만 가능합니다."
/>
지역 상태는 페이지를 벗어나면 함께 정리되고, 렌더링 시점의 값을 그대로 읽기 때문에 닫힐 때 내용이 초기화되는 문제도 생기지 않습니다.
확인형 모달은 Promise로 결과를 받는 편이 자연스러웠을 것 같습니다
서류 제출 여부 변경처럼 사용자의 선택에 따라 다음 동작이 달라지는 모달은 onConfirm 콜백을 스토어에 저장하는 방식으로 구현했습니다. 이 방식은 동작에는 문제가 없지만, 함수가 전역 상태에 들어가고 모달을 닫는 책임이 호출하는 쪽과 모달 쪽으로 나뉘게 됩니다.
토스에서 공개한 overlay-kit처럼 모달을 Promise로 열고 결과를 await로 받는 방식을 사용했다면, 호출하는 쪽의 코드가 더 단순해졌을 것 같습니다.
// 현재 방식
setDocumentSubmissionChangeModal(true, handleRealOneseoArrived);
// Promise 기반 방식
const isConfirmed = await confirm({
title: '서류 제출 여부를 변경하시겠습니까?',
confirmText: '변경하기',
});
if (isConfirmed) handleRealOneseoArrived();
export const confirm = ({ title, confirmText = '확인' }: ConfirmOptions) =>
overlay.openAsync<boolean>(({ isOpen, close }) => (
<ConfirmDialog
open={isOpen}
title={title}
confirmText={confirmText}
onConfirm={() => close(true)}
onCancel={() => close(false)}
/>
));
모달을 사용하는 곳에서 직접 열기 때문에 별도의 타입 정의나 스토어 수정 없이 모달을 추가할 수 있고, 각 애플리케이션은 자신이 사용하는 모달만 가져오게 됩니다. 또한 모달을 닫는 것과 제거하는 것이 분리되어 있어, 닫힘 애니메이션 중에 내용이 바뀌는 문제도 피할 수 있습니다.
반면 외부 라이브러리 의존성이 하나 늘어나고, 페이지를 이동할 때 열려 있는 모달을 정리하는 처리는 직접 추가해야 합니다. 그럼에도 대부분의 모달이 한 곳에서만 쓰이는 알림·확인형이라는 점을 생각하면, 공통 컴포넌트와 Promise 기반 방식을 함께 사용하는 편이 지금 구조보다 효율적이었을 것 같습니다.
마무리
이번 작업은 Zustand를 도입하는 데서 끝나지 않았습니다. 모달을 "각 컴포넌트가 직접 그리는 UI"가 아니라 "필요한 곳에서 요청하는 공통 기능"으로 바라보도록 구조를 바꾼 작업이었습니다.
기존 구조도 동작에는 문제가 없었습니다. 다만 모달이 18개까지 늘어난 시점에서는, 상태와 렌더링을 한곳에 모으고 컴포넌트는 액션만 호출하는 구조가 코드 중복을 줄이고 재사용성을 높이는 데 분명한 효과가 있었습니다.
반면 중앙화가 모든 문제를 해결해 주지는 않았습니다. 한곳에 모은 만큼 어디에서 무엇을 렌더링하고 구독하는지도 함께 관리해야 했습니다. 공통 구조를 만들 때는 사용하는 쪽의 편의뿐 아니라, 그 구조가 각 애플리케이션에 어떤 비용을 남기는지도 함께 확인해야 한다고 느꼈습니다. 지금 다시 같은 작업을 한다면, 모든 모달을 전역 상태로 올리기 전에 어떤 모달이 실제로 여러 곳에서 쓰이는지부터 확인할 것 같습니다.
변경된 파일이 많은 작업이었지만, 결과적으로 프로젝트 전반의 모달을 일관된 방식으로 관리할 수 있게 되었습니다.