Files
shoptime/.docs/dispatch-async/04-solution-queue-system.md
Claude c12cc91a39 [수정] panelQueueMiddleware 등록 및 문서 업데이트
## 코드 수정
- 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 (수정)
2025-11-10 09:30:22 +00:00

16 KiB

해결 방법 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

import panelQueueMiddleware from '../middleware/panelQueueMiddleware';

export const store = createStore(
  rootReducer,
  applyMiddleware(thunk, panelHistoryMiddleware, autoCloseMiddleware, panelQueueMiddleware)
);

미들웨어를 등록하지 않으면 큐에 액션이 추가되어도 자동으로 처리되지 않습니다!

🎯 핵심 개념

왜 큐 시스템이 필요한가?

패널 관련 액션들은 특히 순서가 중요합니다:

// 문제 상황
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

패널을 큐에 추가하여 순차적으로 열기

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

패널을 큐를 통해 제거

import { popPanelQueued } from '../actions/queuedPanelActions';

// 마지막 패널 제거
dispatch(popPanelQueued());

// 특정 패널 제거
dispatch(popPanelQueued(panel_names.SEARCH_PANEL));

3. updatePanelQueued

패널 정보를 큐를 통해 업데이트

import { updatePanelQueued } from '../actions/queuedPanelActions';

dispatch(updatePanelQueued({
  name: panel_names.SEARCH_PANEL,
  panelInfo: {
    results: [...],
    totalCount: 100
  }
}));

4. resetPanelsQueued

모든 패널을 초기화

import { resetPanelsQueued } from '../actions/queuedPanelActions';

// 빈 패널로 초기화
dispatch(resetPanelsQueued());

// 특정 패널들로 초기화
dispatch(resetPanelsQueued([
  { name: panel_names.HOME }
]));

5. enqueueMultiplePanelActions

여러 패널 액션을 한 번에 큐에 추가

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

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

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: '검색에 실패했습니다'
    }));
  }
}));

사용 예제: 상품 검색

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

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

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의 큐 관련 상태

{
  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: 검색 플로우

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: 다단계 결제 프로세스

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 등과 병행 사용 가능

다음: 사용 패턴 및 예제 →