## 코드 수정 - store.js에 panelQueueMiddleware import 및 등록 - 큐 기반 패널 액션 시스템이 작동하기 위해 필수 - applyMiddleware에 panelQueueMiddleware 추가 ## 문서 수정 1. README.md - "설치 및 설정" 섹션 추가 - panelQueueMiddleware 등록 필수 사항 명시 2. 04-solution-queue-system.md - "사전 요구사항" 섹션 추가 - 미들웨어 등록 코드 예제 포함 3. 05-usage-patterns.md - "초기 설정 확인사항" 체크리스트 추가 - panelQueueMiddleware 등록 여부 확인 항목 추가 ## 이유 - panelQueueMiddleware가 등록되지 않으면 큐 시스템이 작동하지 않음 - ENQUEUE_PANEL_ACTION dispatch 시 자동으로 PROCESS_PANEL_QUEUE가 실행되지 않음 - 문서에 설정 방법이 명확하지 않아 사용자가 놓칠 수 있음 ## 관련 파일 - src/store/store.js (수정) - .docs/dispatch-async/README.md (수정) - .docs/dispatch-async/04-solution-queue-system.md (수정) - .docs/dispatch-async/05-usage-patterns.md (수정)
645 lines
16 KiB
Markdown
645 lines
16 KiB
Markdown
# 해결 방법 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)
|