# 해결 방법 3: 큐 기반 패널 액션 시스템 ## 📦 개요 **관련 파일**: - `src/actions/queuedPanelActions.js` - `src/middleware/panelQueueMiddleware.js` - `src/reducers/panelReducer.js` - `src/store/store.js` (미들웨어 등록 필요) **작성일**: 2025-11-06 **커밋**: - `5bd2774 [251106] feat: Queued Panel functions` - `f9290a1 [251106] fix: Dispatch Queue implementation` 미들웨어 기반의 **액션 큐 처리 시스템**으로, 패널 액션들을 순차적으로 실행합니다. ## ⚠️ 사전 요구사항 큐 시스템을 사용하려면 **반드시** store에 panelQueueMiddleware를 등록해야 합니다. **파일**: `src/store/store.js` ```javascript import panelQueueMiddleware from '../middleware/panelQueueMiddleware'; export const store = createStore( rootReducer, applyMiddleware(thunk, panelHistoryMiddleware, autoCloseMiddleware, panelQueueMiddleware) ); ``` 미들웨어를 등록하지 않으면 큐에 액션이 추가되어도 자동으로 처리되지 않습니다! ## 🎯 핵심 개념 ### 왜 큐 시스템이 필요한가? 패널 관련 액션들은 특히 순서가 중요합니다: ```javascript // 문제 상황 dispatch(pushPanel({ name: 'SEARCH' })); // 검색 패널 열기 dispatch(updatePanel({ results: [...] })); // 검색 결과 업데이트 dispatch(popPanel('LOADING')); // 로딩 패널 닫기 // 실제 실행 순서 (문제!) // → popPanel이 먼저 실행될 수 있음 // → updatePanel이 pushPanel보다 먼저 실행될 수 있음 ``` ### 큐 시스템의 동작 방식 ``` [큐에 추가] → [미들웨어 감지] → [순차 처리] → [완료] ↓ ↓ ↓ ↓ ENQUEUE 자동 감지 시작 PROCESS_QUEUE 다음 액션 ``` --- ## 🔑 주요 컴포넌트 ### 1. queuedPanelActions.js 패널 액션을 큐에 추가하는 액션 크리에이터들 ### 2. panelQueueMiddleware.js 큐에 액션이 추가되면 자동으로 처리를 시작하는 미들웨어 ### 3. panelReducer.js 큐 상태를 관리하는 리듀서 --- ## 📋 기본 패널 액션 ### 1. pushPanelQueued 패널을 큐에 추가하여 순차적으로 열기 ```javascript import { pushPanelQueued } from '../actions/queuedPanelActions'; // 기본 사용 dispatch(pushPanelQueued( { name: panel_names.SEARCH_PANEL }, false // duplicatable )); // 중복 허용 dispatch(pushPanelQueued( { name: panel_names.PRODUCT_DETAIL, productId: 123 }, true // 중복 허용 )); ``` ### 2. popPanelQueued 패널을 큐를 통해 제거 ```javascript import { popPanelQueued } from '../actions/queuedPanelActions'; // 마지막 패널 제거 dispatch(popPanelQueued()); // 특정 패널 제거 dispatch(popPanelQueued(panel_names.SEARCH_PANEL)); ``` ### 3. updatePanelQueued 패널 정보를 큐를 통해 업데이트 ```javascript import { updatePanelQueued } from '../actions/queuedPanelActions'; dispatch(updatePanelQueued({ name: panel_names.SEARCH_PANEL, panelInfo: { results: [...], totalCount: 100 } })); ``` ### 4. resetPanelsQueued 모든 패널을 초기화 ```javascript import { resetPanelsQueued } from '../actions/queuedPanelActions'; // 빈 패널로 초기화 dispatch(resetPanelsQueued()); // 특정 패널들로 초기화 dispatch(resetPanelsQueued([ { name: panel_names.HOME } ])); ``` ### 5. enqueueMultiplePanelActions 여러 패널 액션을 한 번에 큐에 추가 ```javascript import { enqueueMultiplePanelActions, pushPanelQueued, updatePanelQueued, popPanelQueued } from '../actions/queuedPanelActions'; dispatch(enqueueMultiplePanelActions([ pushPanelQueued({ name: panel_names.SEARCH_PANEL }), updatePanelQueued({ name: panel_names.SEARCH_PANEL, panelInfo: { query: 'test' } }), popPanelQueued(panel_names.LOADING_PANEL) ])); ``` --- ## 🚀 비동기 패널 액션 ### 1. enqueueAsyncPanelAction 비동기 작업(API 호출 등)을 큐에 추가하여 순차 실행 **파일**: `src/actions/queuedPanelActions.js:173-199` ```javascript import { enqueueAsyncPanelAction } from '../actions/queuedPanelActions'; dispatch(enqueueAsyncPanelAction({ id: 'search_products_123', // 고유 ID // 비동기 액션 (TAxios 등) asyncAction: (dispatch, getState, onSuccess, onFail) => { TAxios( dispatch, getState, 'post', URLS.SEARCH_PRODUCTS, {}, { keyword: 'test' }, onSuccess, onFail ); }, // 성공 콜백 onSuccess: (response) => { console.log('검색 성공:', response); dispatch(pushPanelQueued({ name: panel_names.SEARCH_RESULT, results: response.data.results })); }, // 실패 콜백 onFail: (error) => { console.error('검색 실패:', error); dispatch(pushPanelQueued({ name: panel_names.ERROR, message: error.message })); }, // 완료 콜백 (성공/실패 모두 호출) onFinish: (isSuccess, result) => { console.log('검색 완료:', isSuccess ? '성공' : '실패'); }, // 타임아웃 (ms) timeout: 10000 // 10초 })); ``` ### 동작 흐름 ``` 1. enqueueAsyncPanelAction 호출 ↓ 2. ENQUEUE_ASYNC_PANEL_ACTION dispatch ↓ 3. executeAsyncAction 자동 실행 ↓ 4. wrapAsyncAction으로 Promise 래핑 ↓ 5. withTimeout으로 타임아웃 적용 ↓ 6. 결과에 따라 onSuccess 또는 onFail 호출 ↓ 7. COMPLETE_ASYNC_PANEL_ACTION 또는 FAIL_ASYNC_PANEL_ACTION dispatch ``` --- ## 🔗 API 호출 후 패널 액션 ### createApiWithPanelActions API 호출 후 여러 패널 액션을 자동으로 실행 **파일**: `src/actions/queuedPanelActions.js:355-394` ```javascript import { createApiWithPanelActions } from '../actions/queuedPanelActions'; dispatch(createApiWithPanelActions({ // API 호출 apiCall: (dispatch, getState, onSuccess, onFail) => { TAxios( dispatch, getState, 'post', URLS.SEARCH_PRODUCTS, {}, { keyword: 'laptop' }, onSuccess, onFail ); }, // API 성공 후 실행할 패널 액션들 panelActions: [ // Plain action { type: 'PUSH_PANEL', payload: { name: panel_names.SEARCH_PANEL } }, // Dynamic action (response 사용) (response) => updatePanelQueued({ name: panel_names.SEARCH_PANEL, panelInfo: { results: response.data.results, totalCount: response.data.totalCount } }), // 또 다른 패널 액션 popPanelQueued(panel_names.LOADING_PANEL) ], // API 성공 콜백 onApiSuccess: (response) => { console.log('API 성공:', response.data.totalCount, '개 검색됨'); }, // API 실패 콜백 onApiFail: (error) => { console.error('API 실패:', error); dispatch(pushPanelQueued({ name: panel_names.ERROR, message: '검색에 실패했습니다' })); } })); ``` ### 사용 예제: 상품 검색 ```javascript export const searchProducts = (keyword) => createApiWithPanelActions({ apiCall: (dispatch, getState, onSuccess, onFail) => { TAxios( dispatch, getState, 'post', URLS.SEARCH_PRODUCTS, {}, { keyword }, onSuccess, onFail ); }, panelActions: [ // 1. 로딩 패널 닫기 popPanelQueued(panel_names.LOADING_PANEL), // 2. 검색 결과 패널 열기 (response) => pushPanelQueued({ name: panel_names.SEARCH_RESULT, results: response.data.results }), // 3. 검색 히스토리 업데이트 (response) => updatePanelQueued({ name: panel_names.SEARCH_HISTORY, panelInfo: { lastSearch: keyword } }) ], onApiSuccess: (response) => { console.log(`${response.data.totalCount}개의 상품을 찾았습니다`); } }); ``` --- ## 🔄 비동기 액션 시퀀스 ### createAsyncPanelSequence 여러 비동기 액션을 **순차적으로** 실행 **파일**: `src/actions/queuedPanelActions.js:401-445` ```javascript import { createAsyncPanelSequence } from '../actions/queuedPanelActions'; dispatch(createAsyncPanelSequence([ // 첫 번째 비동기 액션 { asyncAction: (dispatch, getState, onSuccess, onFail) => { TAxios(dispatch, getState, 'get', URLS.GET_USER_INFO, {}, {}, onSuccess, onFail); }, onSuccess: (response) => { console.log('사용자 정보 조회 성공'); dispatch(pushPanelQueued({ name: panel_names.USER_INFO, userInfo: response.data.data })); }, onFail: (error) => { console.error('사용자 정보 조회 실패:', error); } }, // 두 번째 비동기 액션 (첫 번째 완료 후 실행) { asyncAction: (dispatch, getState, onSuccess, onFail) => { const userInfo = getState().user.info; TAxios( dispatch, getState, 'get', URLS.GET_CART, {}, { mbrNo: userInfo.mbrNo }, onSuccess, onFail ); }, onSuccess: (response) => { console.log('카트 정보 조회 성공'); dispatch(updatePanelQueued({ name: panel_names.USER_INFO, panelInfo: { cartCount: response.data.data.length } })); }, onFail: (error) => { console.error('카트 정보 조회 실패:', error); } }, // 세 번째 비동기 액션 (두 번째 완료 후 실행) { asyncAction: (dispatch, getState, onSuccess, onFail) => { TAxios(dispatch, getState, 'get', URLS.GET_ORDERS, {}, {}, onSuccess, onFail); }, onSuccess: (response) => { console.log('주문 정보 조회 성공'); dispatch(pushPanelQueued({ name: panel_names.ORDER_LIST, orders: response.data.data })); }, onFail: (error) => { console.error('주문 정보 조회 실패:', error); // 실패 시 시퀀스 중단 } } ])); ``` ### 동작 흐름 ``` Action 1 실행 → 성공? → Action 2 실행 → 성공? → Action 3 실행 ↓ ↓ 실패 시 실패 시 중단 중단 ``` --- ## ⚙️ 미들웨어: panelQueueMiddleware ### 동작 원리 **파일**: `src/middleware/panelQueueMiddleware.js` ```javascript const panelQueueMiddleware = (store) => (next) => (action) => { const result = next(action); // 큐에 액션이 추가되면 자동으로 처리 시작 if (action.type === types.ENQUEUE_PANEL_ACTION) { console.log('[panelQueueMiddleware] 🚀 ACTION_ENQUEUED', { action: action.payload.action, queueId: action.payload.id, }); // setTimeout을 사용하여 현재 액션이 완전히 처리된 후에 큐 처리 시작 setTimeout(() => { const currentState = store.getState(); if (currentState.panels) { // 이미 처리 중이 아니고 큐에 액션이 있으면 처리 시작 if (!currentState.panels.isProcessingQueue && currentState.panels.panelActionQueue.length > 0) { console.log('[panelQueueMiddleware] ⚡ STARTING_QUEUE_PROCESS'); store.dispatch({ type: types.PROCESS_PANEL_QUEUE }); } } }, 0); } // 큐 처리가 완료되고 남은 큐가 있으면 계속 처리 if (action.type === types.PROCESS_PANEL_QUEUE) { setTimeout(() => { const currentState = store.getState(); if (currentState.panels) { // 처리 중이 아니고 큐에 남은 액션이 있으면 계속 처리 if (!currentState.panels.isProcessingQueue && currentState.panels.panelActionQueue.length > 0) { console.log('[panelQueueMiddleware] 🔄 CONTINUING_QUEUE_PROCESS'); store.dispatch({ type: types.PROCESS_PANEL_QUEUE }); } } }, 0); } return result; }; ``` ### 주요 특징 1. ✅ **자동 시작**: 큐에 액션 추가 시 자동으로 처리 시작 2. ✅ **연속 처리**: 한 액션 완료 후 자동으로 다음 액션 처리 3. ✅ **중복 방지**: 이미 처리 중이면 새로 시작하지 않음 4. ✅ **로깅**: 모든 단계에서 로그 출력 --- ## 📊 리듀서 상태 구조 ### panelReducer.js의 큐 관련 상태 ```javascript { panels: [], // 실제 패널 스택 lastPanelAction: 'push', // 마지막 액션 타입 // 큐 관련 상태 panelActionQueue: [ // 처리 대기 중인 큐 { id: 'queue_item_1_1699999999999', action: 'PUSH_PANEL', panel: { name: 'SEARCH_PANEL' }, duplicatable: false, timestamp: 1699999999999 }, // ... ], isProcessingQueue: false, // 큐 처리 중 여부 queueError: null, // 큐 처리 에러 queueStats: { // 큐 통계 totalProcessed: 0, // 총 처리된 액션 수 failedCount: 0, // 실패한 액션 수 averageProcessingTime: 0 // 평균 처리 시간 (ms) }, // 비동기 액션 상태 asyncActions: { // 실행 중인 비동기 액션들 'async_action_1': { id: 'async_action_1', status: 'pending', // 'pending' | 'success' | 'failed' timestamp: 1699999999999 } }, completedAsyncActions: [ // 완료된 액션 ID들 'async_action_1', 'async_action_2' ], failedAsyncActions: [ // 실패한 액션 ID들 'async_action_3' ] } ``` --- ## 🎯 실제 사용 시나리오 ### 시나리오 1: 검색 플로우 ```javascript export const performSearch = (keyword) => (dispatch) => { // 1. 로딩 패널 열기 dispatch(pushPanelQueued({ name: panel_names.LOADING })); // 2. 검색 API 호출 후 결과 표시 dispatch(createApiWithPanelActions({ apiCall: (dispatch, getState, onSuccess, onFail) => { TAxios(dispatch, getState, 'post', URLS.SEARCH, {}, { keyword }, onSuccess, onFail); }, panelActions: [ popPanelQueued(panel_names.LOADING), (response) => pushPanelQueued({ name: panel_names.SEARCH_RESULT, results: response.data.results }) ] })); }; ``` ### 시나리오 2: 다단계 결제 프로세스 ```javascript export const processCheckout = (orderInfo) => createAsyncPanelSequence([ // 1단계: 주문 검증 { asyncAction: (dispatch, getState, onSuccess, onFail) => { TAxios(dispatch, getState, 'post', URLS.VALIDATE_ORDER, {}, orderInfo, onSuccess, onFail); }, onSuccess: () => { dispatch(updatePanelQueued({ name: panel_names.CHECKOUT, panelInfo: { step: 1, status: 'validated' } })); } }, // 2단계: 결제 처리 { asyncAction: (dispatch, getState, onSuccess, onFail) => { TAxios(dispatch, getState, 'post', URLS.PROCESS_PAYMENT, {}, orderInfo, onSuccess, onFail); }, onSuccess: (response) => { dispatch(updatePanelQueued({ name: panel_names.CHECKOUT, panelInfo: { step: 2, paymentId: response.data.data.paymentId } })); } }, // 3단계: 주문 확정 { asyncAction: (dispatch, getState, onSuccess, onFail) => { const state = getState(); const paymentId = state.panels.panels.find(p => p.name === panel_names.CHECKOUT) .panelInfo.paymentId; TAxios( dispatch, getState, 'post', URLS.CONFIRM_ORDER, {}, { ...orderInfo, paymentId }, onSuccess, onFail ); }, onSuccess: (response) => { dispatch(popPanelQueued(panel_names.CHECKOUT)); dispatch(pushPanelQueued({ name: panel_names.ORDER_COMPLETE, orderId: response.data.data.orderId })); } } ]); ``` --- ## ✅ 장점 1. **완벽한 순서 보장**: 큐 시스템으로 100% 순서 보장 2. **자동 처리**: 미들웨어가 자동으로 큐 처리 3. **비동기 지원**: API 호출 등 비동기 작업 완벽 지원 4. **타임아웃**: 응답 없는 작업 자동 처리 5. **에러 복구**: 에러 발생 시에도 다음 액션 계속 처리 6. **통계**: 큐 처리 통계 자동 수집 ## ⚠️ 주의사항 1. **미들웨어 등록**: store에 panelQueueMiddleware 등록 필요 2. **리듀서 확장**: panelReducer에 큐 관련 상태 추가 필요 3. **기존 코드**: 기존 pushPanel 등과 병행 사용 가능 --- **다음**: [사용 패턴 및 예제 →](./05-usage-patterns.md)