React에서 전역 모달 시스템 구축하기: Singleton + Observer 패턴
발행일: 2025년 12월 16일 오전 03:00
디자인 패턴(Singleton, Observer)과 History API를 활용해 window.alert를 대체하는 전역 모달 시스템을 구축하는 방법을 알아봅니다. React 커스텀 훅으로 간편하게 사용할 수 있습니다.
window.alert()와 window.confirm()을 사용하면서 불편함을 느껴본 적 있나요? 브라우저 기본 다이얼로그는 스타일링이 불가능하고, UX도 좋지 않습니다. 이 글에서는 디자인 패턴을 활용한 전역 모달 시스템을 구축하는 방법을 알아봅니다.
왜 전역 모달 시스템이 필요할까?
기존 방식의 문제점
1// ❌ 문제 1: 브라우저 기본 alert - 스타일링 불가 2window.alert('저장되었습니다!'); 3 4// ❌ 문제 2: 각 컴포넌트마다 모달 상태 관리 5const [isModalOpen, setIsModalOpen] = useState(false); 6const [modalMessage, setModalMessage] = useState(''); 7 8// ❌ 문제 3: Props drilling 9<ChildComponent onShowModal={setIsModalOpen} />
전역 모달 시스템의 장점
1// ✅ 어디서든 한 줄로 모달 표시 2const { showAlert } = useAlertModal(); 3showAlert({ title: '성공', message: '저장되었습니다!', type: 'success' });
- 일관된 디자인 시스템 적용
- Props drilling 없이 어디서든 호출
- 브라우저 뒤로가기로 모달 닫기 지원
- 여러 모달을 스택으로 관리
핵심 개념: 사용된 디자인 패턴
1. Singleton 패턴
하나의 인스턴스만 존재하도록 보장합니다. 모달 상태를 전역에서 일관되게 관리할 수 있습니다.
1class ModalManager { 2 private static instance: ModalManager; 3 4 // private 생성자로 외부에서 new 불가 5 private constructor() {} 6 7 // 항상 같은 인스턴스 반환 8 public static getInstance(): ModalManager { 9 if (!ModalManager.instance) { 10 ModalManager.instance = new ModalManager(); 11 } 12 return ModalManager.instance; 13 } 14} 15 16// 사용: 어디서 호출해도 같은 인스턴스 17const manager1 = ModalManager.getInstance(); 18const manager2 = ModalManager.getInstance(); 19console.log(manager1 === manager2); // true
2. Observer 패턴
상태 변경을 구독자들에게 자동 알림합니다. React 컴포넌트가 모달 상태 변화를 감지할 수 있습니다.
1class ModalManager { 2 private observers: Set<(modals: Modal[]) => void> = new Set(); 3 4 // 구독 등록 5 subscribe(observer: (modals: Modal[]) => void) { 6 this.observers.add(observer); 7 return () => this.observers.delete(observer); // 구독 해제 함수 반환 8 } 9 10 // 상태 변경 시 모든 구독자에게 알림 11 private notifyObservers() { 12 this.observers.forEach(observer => observer(this.modals)); 13 } 14}
3. History API 통합
브라우저 뒤로가기 버튼으로 모달을 닫을 수 있습니다.
1// 모달 열 때: history에 상태 추가 2window.history.pushState({ modal: modalId }, '', window.location.href); 3 4// 뒤로가기 감지 5window.addEventListener('popstate', (event) => { 6 if (!event.state?.modal) { 7 this.closeTop(); // 최상위 모달 닫기 8 } 9});
구현하기
1단계: 타입 정의
먼저 필요한 타입들을 정의합니다.
1// types.ts 2export type ModalId = string; 3export type ModalType = 'alert' | 'confirm' | 'custom'; 4 5// Alert 모달 옵션 6export interface AlertModalOptions { 7 id?: ModalId; 8 title?: string; 9 message: string; 10 confirmText?: string; 11 type?: 'info' | 'success' | 'warning' | 'error'; 12 onConfirm?: () => void; 13 closeOnBackdrop?: boolean; 14 closeOnEsc?: boolean; 15} 16 17// Confirm 모달 옵션 18export interface ConfirmModalOptions { 19 id?: ModalId; 20 title?: string; 21 message: string; 22 confirmText?: string; 23 cancelText?: string; 24 type?: 'info' | 'warning' | 'danger'; 25 onConfirm?: () => void; 26 onCancel?: () => void; 27 closeOnBackdrop?: boolean; 28 closeOnEsc?: boolean; 29} 30 31// 내부 모달 상태 32export interface ModalState { 33 id: ModalId; 34 type: ModalType; 35 options: AlertModalOptions | ConfirmModalOptions; 36 isVisible: boolean; 37 timestamp: number; 38} 39 40// Observer 타입 41export type ModalObserver = (modals: ModalState[]) => void;
2단계: ModalManager 클래스 구현
핵심이 되는 싱글톤 매니저 클래스입니다.
1// modal-manager.ts 2import { v4 as uuidv4 } from 'uuid'; 3 4const MODAL_STATE_KEY = '__modal_state__'; 5 6class ModalManager { 7 private static instance: ModalManager; 8 private modals: ModalState[] = []; 9 private observers: Set<ModalObserver> = new Set(); 10 11 private constructor() { 12 // 브라우저 뒤로가기 리스너 등록 13 window.addEventListener('popstate', this.handlePopState); 14 } 15 16 public static getInstance(): ModalManager { 17 if (!ModalManager.instance) { 18 ModalManager.instance = new ModalManager(); 19 } 20 return ModalManager.instance; 21 } 22 23 // 뒤로가기 처리 24 private handlePopState = (event: PopStateEvent) => { 25 if (this.modals.length > 0 && !event.state?.[MODAL_STATE_KEY]) { 26 this.closeTop(); 27 } 28 }; 29 30 // History에 모달 상태 추가 31 private pushHistoryState(modalId: ModalId) { 32 const newState = { [MODAL_STATE_KEY]: modalId }; 33 window.history.pushState(newState, '', window.location.href); 34 } 35 36 // 모든 Observer에게 알림 37 private notifyObservers() { 38 const modalsCopy = [...this.modals]; 39 this.observers.forEach(observer => observer(modalsCopy)); 40 } 41 42 // 모달 추가 (내부용) 43 private addModal(type: ModalType, options: ModalOptions): ModalId { 44 const id = options.id || uuidv4(); 45 46 const modalState: ModalState = { 47 id, 48 type, 49 options: { ...options, id }, 50 isVisible: true, 51 timestamp: Date.now(), 52 }; 53 54 this.modals.push(modalState); 55 this.pushHistoryState(id); 56 this.notifyObservers(); 57 58 return id; 59 } 60 61 // Alert 모달 열기 62 public alert(options: Omit<AlertModalOptions, 'id'>): ModalId { 63 return this.addModal('alert', { 64 closeOnBackdrop: true, 65 closeOnEsc: true, 66 confirmText: '확인', 67 type: 'info', 68 ...options, 69 }); 70 } 71 72 // Confirm 모달 열기 73 public confirm(options: Omit<ConfirmModalOptions, 'id'>): ModalId { 74 return this.addModal('confirm', { 75 closeOnBackdrop: true, 76 closeOnEsc: true, 77 confirmText: '확인', 78 cancelText: '취소', 79 type: 'info', 80 ...options, 81 }); 82 } 83 84 // 특정 모달 닫기 85 public close(id: ModalId) { 86 const index = this.modals.findIndex(m => m.id === id); 87 if (index === -1) return; 88 89 this.modals.splice(index, 1); 90 91 // 최상위 모달이면 history.back() 92 if (index === this.modals.length) { 93 window.history.back(); 94 } 95 96 this.notifyObservers(); 97 } 98 99 // 최상위 모달 닫기 100 public closeTop() { 101 if (this.modals.length === 0) return; 102 const topModal = this.modals[this.modals.length - 1]; 103 this.close(topModal.id); 104 } 105 106 // 모든 모달 닫기 107 public closeAll() { 108 this.modals = []; 109 this.notifyObservers(); 110 } 111 112 // Observer 구독 113 public subscribe(observer: ModalObserver): () => void { 114 this.observers.add(observer); 115 observer([...this.modals]); // 즉시 현재 상태 전달 116 return () => this.observers.delete(observer); 117 } 118} 119 120export const modalManager = ModalManager.getInstance();
3단계: React Hook 만들기
React 컴포넌트에서 사용할 커스텀 훅입니다.
1// use-modal-manager.ts 2import { useState, useEffect, useCallback } from 'react'; 3import { modalManager } from './modal-manager'; 4 5export function useModalManager() { 6 const [modals, setModals] = useState<ModalState[]>([]); 7 8 useEffect(() => { 9 // Observer 패턴으로 상태 변화 구독 10 const unsubscribe = modalManager.subscribe(setModals); 11 return unsubscribe; 12 }, []); 13 14 const closeModal = useCallback((id: ModalId) => { 15 modalManager.close(id); 16 }, []); 17 18 return { 19 modals, 20 closeModal, 21 closeTopModal: useCallback(() => modalManager.closeTop(), []), 22 closeAllModals: useCallback(() => modalManager.closeAll(), []), 23 hasModals: modals.length > 0, 24 }; 25}
1// use-alert-modal.ts 2import { useCallback } from 'react'; 3import { modalManager } from './modal-manager'; 4 5export function useAlertModal() { 6 const showAlert = useCallback((options: Omit<AlertModalOptions, 'id'>) => { 7 return modalManager.alert(options); 8 }, []); 9 10 const closeAlert = useCallback((id: ModalId) => { 11 modalManager.close(id); 12 }, []); 13 14 return { showAlert, closeAlert }; 15}
1// use-confirm-modal.ts 2import { useCallback } from 'react'; 3import { modalManager } from './modal-manager'; 4 5export function useConfirmModal() { 6 const showConfirm = useCallback((options: Omit<ConfirmModalOptions, 'id'>) => { 7 return modalManager.confirm(options); 8 }, []); 9 10 const closeConfirm = useCallback((id: ModalId) => { 11 modalManager.close(id); 12 }, []); 13 14 return { showConfirm, closeConfirm }; 15}
4단계: 모달 컴포넌트 구현
실제로 렌더링되는 모달 컴포넌트입니다.
1// AlertModal.tsx 2import { AlertCircle, CheckCircle, AlertTriangle, Info } from 'lucide-react'; 3 4interface AlertModalProps { 5 options: AlertModalOptions; 6 isVisible: boolean; 7 zIndex: number; 8 onClose: () => void; 9 onBackdropClick: () => void; 10} 11 12export const AlertModal: React.FC<AlertModalProps> = ({ 13 options, 14 isVisible, 15 zIndex, 16 onClose, 17 onBackdropClick, 18}) => { 19 if (!isVisible) return null; 20 21 const { title, message, confirmText = '확인', type = 'info', onConfirm } = options; 22 23 const handleConfirm = () => { 24 onConfirm?.(); 25 onClose(); 26 }; 27 28 const handleBackdropClick = (e: React.MouseEvent) => { 29 if (e.target === e.currentTarget) { 30 onBackdropClick(); 31 } 32 }; 33 34 // 타입별 아이콘/색상 설정 35 const getTypeConfig = () => { 36 switch (type) { 37 case 'success': 38 return { Icon: CheckCircle, color: 'text-green-500', bg: 'bg-green-500/10' }; 39 case 'warning': 40 return { Icon: AlertTriangle, color: 'text-yellow-500', bg: 'bg-yellow-500/10' }; 41 case 'error': 42 return { Icon: AlertCircle, color: 'text-red-500', bg: 'bg-red-500/10' }; 43 default: 44 return { Icon: Info, color: 'text-blue-500', bg: 'bg-blue-500/10' }; 45 } 46 }; 47 48 const { Icon, color, bg } = getTypeConfig(); 49 50 return ( 51 <div 52 className="fixed inset-0 flex items-center justify-center bg-black/50 backdrop-blur-sm" 53 style={{ zIndex }} 54 onClick={handleBackdropClick} 55 > 56 <div className="bg-white rounded-lg shadow-xl min-w-[320px] max-w-[480px] p-6"> 57 {/* 아이콘 + 제목 */} 58 <div className="flex items-start gap-3 mb-4"> 59 <div className={`w-10 h-10 rounded-full ${bg} flex items-center justify-center`}> 60 <Icon className={`w-5 h-5 ${color}`} /> 61 </div> 62 {title && <h3 className="text-lg font-semibold pt-2">{title}</h3>} 63 </div> 64 65 {/* 메시지 */} 66 <p className="text-gray-600 mb-6 ml-[52px]">{message}</p> 67 68 {/* 버튼 */} 69 <div className="flex justify-end"> 70 <button 71 onClick={handleConfirm} 72 className="px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600" 73 > 74 {confirmText} 75 </button> 76 </div> 77 </div> 78 </div> 79 ); 80};
5단계: ModalContainer - 모달 렌더링 관리
App 최상단에 배치해 모든 모달을 렌더링합니다.
1// ModalContainer.tsx 2import { useEffect, useCallback } from 'react'; 3import { useModalManager } from './hooks/use-modal-manager'; 4import { AlertModal } from './AlertModal'; 5import { ConfirmModal } from './ConfirmModal'; 6 7const BASE_Z_INDEX = 1000; 8 9export const ModalContainer: React.FC = () => { 10 const { modals, closeModal } = useModalManager(); 11 12 // ESC 키로 모달 닫기 13 const handleKeyDown = useCallback((event: KeyboardEvent) => { 14 if (event.key === 'Escape' && modals.length > 0) { 15 const topModal = modals[modals.length - 1]; 16 if (topModal.options.closeOnEsc !== false) { 17 closeModal(topModal.id); 18 } 19 } 20 }, [modals, closeModal]); 21 22 useEffect(() => { 23 window.addEventListener('keydown', handleKeyDown); 24 return () => window.removeEventListener('keydown', handleKeyDown); 25 }, [handleKeyDown]); 26 27 const handleBackdropClick = (modalId: string, closeOnBackdrop: boolean) => { 28 if (closeOnBackdrop !== false) { 29 closeModal(modalId); 30 } 31 }; 32 33 return ( 34 <> 35 {modals.map((modal, index) => { 36 const zIndex = BASE_Z_INDEX + index; 37 const commonProps = { 38 isVisible: modal.isVisible, 39 zIndex, 40 onClose: () => closeModal(modal.id), 41 onBackdropClick: () => handleBackdropClick(modal.id, modal.options.closeOnBackdrop ?? true), 42 }; 43 44 switch (modal.type) { 45 case 'alert': 46 return <AlertModal key={modal.id} {...commonProps} options={modal.options} />; 47 case 'confirm': 48 return <ConfirmModal key={modal.id} {...commonProps} options={modal.options} />; 49 default: 50 return null; 51 } 52 })} 53 </> 54 ); 55};
6단계: App에 ModalContainer 추가
1// App.tsx 2import { ModalContainer } from './components/modal/ModalContainer'; 3 4function App() { 5 return ( 6 <> 7 <Router> 8 {/* 라우트들... */} 9 </Router> 10 11 {/* 모든 모달 렌더링 */} 12 <ModalContainer /> 13 </> 14 ); 15}
사용 방법
Alert 모달
1import { useAlertModal } from '@/components/modal/hooks'; 2 3function MyComponent() { 4 const { showAlert } = useAlertModal(); 5 6 const handleSave = async () => { 7 await saveData(); 8 9 // 성공 알림 10 showAlert({ 11 title: '저장 완료', 12 message: '데이터가 성공적으로 저장되었습니다.', 13 type: 'success', 14 }); 15 }; 16 17 const handleError = () => { 18 // 에러 알림 19 showAlert({ 20 title: '오류 발생', 21 message: '서버 연결에 실패했습니다. 잠시 후 다시 시도해주세요.', 22 type: 'error', 23 onConfirm: () => console.log('확인 클릭됨'), 24 }); 25 }; 26 27 return ( 28 <button onClick={handleSave}>저장</button> 29 ); 30}
Confirm 모달
1import { useConfirmModal } from '@/components/modal/hooks'; 2 3function DeleteButton({ itemId, itemName }) { 4 const { showConfirm } = useConfirmModal(); 5 6 const handleDelete = () => { 7 showConfirm({ 8 title: '삭제 확인', 9 message: `"${itemName}"을(를) 정말 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.`, 10 type: 'danger', 11 confirmText: '삭제', 12 cancelText: '취소', 13 onConfirm: async () => { 14 await deleteItem(itemId); 15 console.log('삭제 완료'); 16 }, 17 onCancel: () => { 18 console.log('삭제 취소됨'); 19 }, 20 }); 21 }; 22 23 return ( 24 <button onClick={handleDelete} className="text-red-500"> 25 삭제 26 </button> 27 ); 28}
모달 타입별 스타일
| 타입 | 용도 | 색상 |
|---|---|---|
info | 일반 정보 안내 | 파란색 |
success | 작업 성공 | 초록색 |
warning | 주의/경고 | 노란색 |
error | 오류 발생 | 빨간색 |
danger | 위험한 작업 (삭제 등) | 빨간색 |
왜 이 패턴이 좋은가?
1. 관심사 분리
- ModalManager: 상태 관리 로직
- Hooks: React와의 인터페이스
- Components: UI 렌더링
2. 테스트 용이성
1// 모달 매니저를 독립적으로 테스트 가능 2const manager = ModalManager.getInstance(); 3manager.alert({ message: 'Test' }); 4expect(manager.getModals().length).toBe(1);
3. 확장성
새로운 모달 타입을 쉽게 추가할 수 있습니다:
1// 새로운 Prompt 모달 추가 2public prompt(options: PromptModalOptions): ModalId { 3 return this.addModal('prompt', { 4 closeOnBackdrop: false, 5 closeOnEsc: true, 6 ...options, 7 }); 8}
4. 일관된 UX
- 모든 모달이 같은 애니메이션, 닫기 동작 공유
- 브라우저 뒤로가기로 자연스럽게 닫힘
- ESC 키로 닫기 지원
정리
이 글에서 구현한 모달 시스템의 핵심 포인트:
- Singleton 패턴: 전역에서 하나의 상태 관리
- Observer 패턴: React 컴포넌트와 상태 동기화
- History API: 브라우저 뒤로가기 지원
- 커스텀 훅: 간편한 사용 인터페이스
이제 window.alert()와 작별하고, 프로젝트에 맞는 아름다운 모달 시스템을 사용해보세요!
파일 구조
src/components/modal/
├── modal-manager.ts # 싱글톤 매니저 (핵심)
├── types.ts # 타입 정의
├── ModalContainer.tsx # 모달 렌더링 컨테이너
├── AlertModal.tsx # Alert 모달 UI
├── ConfirmModal.tsx # Confirm 모달 UI
└── hooks/
├── use-modal-manager.ts # 모달 상태 구독 훅
├── use-alert-modal.ts # Alert 전용 훅
└── use-confirm-modal.ts # Confirm 전용 훅
태그
#React#TypeScript#Design Pattern#Modal#Singleton#Observer
0개의 댓글
21회 조회