확장 가능한 앱을 위한 React 모달 스택 아키텍처
복잡한 React 앱에서 모달은 금세 골칫거리로 변합니다. 기본 useState로 구현한 단일 모달은 독립적으로는 잘 작동하지만, 프로젝트가 커지면 중복 컴포넌트, prop drilling, 얽힌 상태 의존성이 생깁니다. 해결책? 트리거 소스별로 로직을 분리하는 타입화된 오버레이 스택입니다.
전형적인 마켓플레이스 관리자 대시보드의 상품 테이블을 생각해보세요. 각 행마다 IMEI, SGTIN 같은 식별자 추가 모달이 필요하고, 주문 테이블, 유효성 검사, Excel 업로드, 다단계 마법사까지 포함됩니다.
로컬 렌더링의 문제점
테이블 행마다 IdentificationModal을 렌더링하면 수천 개의 숨겨진 DOM 요소가 생성됩니다. 이 컴포넌트는 테이블, 마스킹 입력, RTK Query 훅, 스테퍼로 가득 차 있어 메모리 사용량이 폭증합니다.
나쁜 예시:
const ProductRow = ({ product }) => {
const [isOpen, setIsOpen] = useState(false);
return (
<tr>
<td>{product.name}</td>
<td>
<Button onClick={() => setIsOpen(true)}>식별자 추가</Button>
<IdentificationModal
isOpen={isOpen}
onClose={() => setIsOpen(false)}
productId={product.id}
/>
</td>
</tr>
);
};
핵심 포인트: 모달은 페이지 최상위에서 한 번만 렌더링해야 합니다.
Prop Drilling과 전역 상태
모달을 트리 상위로 이동하면 DOM 팽창은 해결되지만, 4~5개 컴포넌트 레이어를 거쳐 콜백을 전달해야 합니다:
ProductsPage→ProductsTable→TableBody→ProductRow→ActionsMenu
Redux에서 openIdent(productId) 디스패치로 prop을 없앨 수 있지만, 여러 컨텍스트에서 복잡해집니다.
다중 데이터 소스
모달은 서로 다른 API를 가진 세 곳에서 데이터를 가져옵니다:
| 트리거 소스 | 주문 | 데이터 로딩 | Excel 템플릿 |
|-------------|------|-------------|--------------|
| 테이블 헤더 | 전체 주문 | ordersApi.getIdentOrders() | ordersApi.getExcel() |
| 테이블 행 | 단일 주문 | orderId로 필터링 | ordersApi.getExcel() |
| 장바구니 | 장바구니 주문 | cartsApi.getCartIdentOrders(cartId) | cartsApi.getExcel() |
모달에 로직을 몰아넣으면 if문, 캐시 무효화, 분석으로 가득 찬 God Object가 됩니다. 반대로 순수 UI 모달에 orders와 onSave prop만 주면 페이지가 컨텍스트를 미리 알 수 없어 실패합니다.
모달 의존성
식별자 모달을 열면 벌크 액션 패널이 숨겨지고, 닫으면 다시 나타납니다. "저장되지 않은 변경사항이 있어요?" 확인 모달을 추가하면 플래그를 수동으로 관리해야 합니다:
openConfirmClose: (state) => {
state.isConfirmOpen = true;
state.isIdentOpen = false;
},
cancelConfirmClose: (state) => {
state.isConfirmOpen = false;
state.isIdentOpen = true;
},
평면적인 플래그 구조는 스택 순서나 롤백 전략을 담지 못합니다.
해결 아키텍처: 타입화된 스택
이 시스템은 세 가지 문제를 한 번에 해결합니다:
- 단일 렌더링을 위한 Redux 스택과 오버레이 큐.
- 컨텍스트 분리를 위한 소스 래퍼.
- 자동 의존성 관리를 위한 스택 규칙.
스택용 Redux Slice
export enum EOverlayName {
BulkActions = 'bulkActions',
IdentFromHeader = 'identFromHeader',
IdentFromRow = 'identFromRow',
IdentFromCart = 'identFromCart',
EditProduct = 'editProduct',
// ...
}
type OverlayItem<TData = unknown> = {
name: EOverlayName;
data: TData;
priority?: number;
};
type OverlayStack = OverlayItem[];
스택은 타입화된 데이터와 순서를 유지합니다. 비동기 액션으로 페이로드와 함께 오버레이를 푸시합니다.
트리거용 오버레이 훅
const useOverlay = () => {
const dispatch = useDispatch();
const open = <TData>(name: EOverlayName, data: TData) => {
dispatch(openOverlay({ name, data }));
};
const close = (name: EOverlayName) => {
dispatch(closeOverlay(name));
};
return { open, close };
};
행 버튼: open('identFromRow', { productId, orderId }). 모달은 스택에서 읽어 prop drilling 없음.
컨텍스트용 소스 래퍼
각 소스는 어댑터를 export:
// identFromRow.adapter.ts
export const useIdentFromRowAdapter = () => {
const { open } = useOverlay();
const openIdent = (productId: string, orderId: string) => {
const orders = ordersApi.useGetIdentOrdersQuery();
const filtered = orders.data?.filter(o => o.id === orderId);
open('identFromRow', { orders: filtered, productId });
};
return { openIdent };
};
모달은 소스를 모르고 준비된 orders와 onSave를 받습니다.
열기/닫기 전략
스택이 규칙을 강제합니다:
- 교체:
ident열기 시bulkActions닫기. - 일시정지: 닫으면 이전 오버레이 재개.
- 초기화: 확인 시 스택 리셋.
const openOverlay = createAsyncThunk(
'overlay/open',
async ({ name, data }, { getState }) => {
const stack = selectStack(getState());
// 교체/우선순위 로직
return { name, data };
}
);
주요 이점
- 스택이 prop drilling, DOM 중복, 상태 의존성을 한 아키텍처로 제거.
- 소스 래퍼가 API 로직 격리; 모달은 순수 UI 유지.
OverlayItem<TData>타입으로 데이터 안전성 보장.- 비동기 전략으로 열기/닫기 순서 자동화.
- 리듀서 팽창 없이 수십 개 모달 확장 가능.
— Editorial Team
아직 댓글이 없습니다.