- claude/ 브랜치에서 누락된 .docs 폴더 복원 완료 - dispatch-async 관련 문서 9개 파일 복원 * 01-problem.md, 02-solution-dispatch-helper.md * 03-solution-async-utils.md, 04-solution-queue-system.md * 05-usage-patterns.md, 06-setup-guide.md * 07-changelog.md, 08-troubleshooting.md, README.md - MediaPlayer.v2 관련 문서 4개 파일 복원 * MediaPlayer-v2-README.md, MediaPlayer-v2-Required-Changes.md * MediaPlayer-v2-Risk-Analysis.md, PR-MediaPlayer-v2.md - 기타 분석 문서 2개 파일 복원 * modal-transition-analysis.md, video-player-analysis-and-optimization-plan.md - .gitignore에서 .docs 항목 제거로 문서 추적 가능하도록 수정 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: GLM 4.6 <noreply@z.ai>
712 lines
17 KiB
Markdown
712 lines
17 KiB
Markdown
# 해결 방법 2: asyncActionUtils.js
|
||
|
||
## 📦 개요
|
||
|
||
**파일**: `src/utils/asyncActionUtils.js`
|
||
**작성일**: 2025-11-06
|
||
**커밋**: `f9290a1 [251106] fix: Dispatch Queue implementation`
|
||
|
||
Promise 기반의 비동기 액션 처리와 **상세한 성공/실패 기준**을 제공합니다.
|
||
|
||
## 🎯 핵심 개념
|
||
|
||
### 프로젝트 특화 성공 기준
|
||
|
||
이 프로젝트에서 API 호출 성공은 **2가지 조건**을 모두 만족해야 합니다:
|
||
|
||
1. ✅ **HTTP 상태 코드**: 200-299 범위
|
||
2. ✅ **retCode**: 0 또는 '0'
|
||
|
||
```javascript
|
||
// HTTP 200이지만 retCode가 1인 경우
|
||
{
|
||
status: 200, // ✅ HTTP는 성공
|
||
data: {
|
||
retCode: 1, // ❌ retCode는 실패
|
||
message: "권한이 없습니다"
|
||
}
|
||
}
|
||
// → 이것은 실패입니다!
|
||
```
|
||
|
||
### Promise 체인이 끊기지 않는 설계
|
||
|
||
**핵심 원칙**: 모든 비동기 작업은 **reject 없이 resolve만 사용**합니다.
|
||
|
||
```javascript
|
||
// ❌ 일반적인 방식 (Promise 체인이 끊김)
|
||
return new Promise((resolve, reject) => {
|
||
if (error) {
|
||
reject(error); // 체인이 끊김!
|
||
}
|
||
});
|
||
|
||
// ✅ 이 프로젝트의 방식 (체인 유지)
|
||
return new Promise((resolve) => {
|
||
if (error) {
|
||
resolve({
|
||
success: false,
|
||
error: { code: 'ERROR', message: '에러 발생' }
|
||
});
|
||
}
|
||
});
|
||
```
|
||
|
||
---
|
||
|
||
## 🔑 핵심 함수
|
||
|
||
1. `isApiSuccess` - API 성공 여부 판단
|
||
2. `fetchApi` - Promise 기반 fetch 래퍼
|
||
3. `tAxiosToPromise` - TAxios를 Promise로 변환
|
||
4. `wrapAsyncAction` - 비동기 액션을 Promise로 래핑
|
||
5. `withTimeout` - 타임아웃 지원
|
||
6. `executeParallelAsyncActions` - 병렬 실행
|
||
|
||
---
|
||
|
||
## 1️⃣ isApiSuccess
|
||
|
||
### 설명
|
||
|
||
API 응답이 성공인지 판단하는 **프로젝트 표준 함수**입니다.
|
||
|
||
### 구현
|
||
|
||
**파일**: `src/utils/asyncActionUtils.js:21-34`
|
||
|
||
```javascript
|
||
export const isApiSuccess = (response, responseData) => {
|
||
// 1️⃣ HTTP 상태 코드 확인 (200-299 성공 범위)
|
||
if (!response.ok || response.status < 200 || response.status >= 300) {
|
||
return false;
|
||
}
|
||
|
||
// 2️⃣ retCode 확인 - 0 또는 '0'이어야 성공
|
||
if (responseData && responseData.retCode !== undefined) {
|
||
return responseData.retCode === 0 || responseData.retCode === '0';
|
||
}
|
||
|
||
// retCode가 없는 경우 HTTP 상태 코드만으로 판단
|
||
return response.ok;
|
||
};
|
||
```
|
||
|
||
### 사용 예제
|
||
|
||
```javascript
|
||
// 성공 케이스
|
||
isApiSuccess(
|
||
{ ok: true, status: 200 },
|
||
{ retCode: 0, data: { ... } }
|
||
); // → true
|
||
|
||
isApiSuccess(
|
||
{ ok: true, status: 200 },
|
||
{ retCode: '0', data: { ... } }
|
||
); // → true
|
||
|
||
// 실패 케이스
|
||
isApiSuccess(
|
||
{ ok: true, status: 200 },
|
||
{ retCode: 1, message: "권한 없음" }
|
||
); // → false (retCode가 0이 아님)
|
||
|
||
isApiSuccess(
|
||
{ ok: false, status: 500 },
|
||
{ retCode: 0, data: { ... } }
|
||
); // → false (HTTP 상태 코드가 500)
|
||
|
||
isApiSuccess(
|
||
{ ok: false, status: 404 },
|
||
{ retCode: 0 }
|
||
); // → false (404 에러)
|
||
```
|
||
|
||
---
|
||
|
||
## 2️⃣ fetchApi
|
||
|
||
### 설명
|
||
|
||
**표준 fetch API를 Promise로 래핑**하여 프로젝트 성공 기준에 맞춰 처리합니다.
|
||
|
||
### 핵심 특징
|
||
|
||
- ✅ 항상 `resolve` 사용 (reject 없음)
|
||
- ✅ HTTP 상태 + retCode 모두 확인
|
||
- ✅ JSON 파싱 에러도 처리
|
||
- ✅ 네트워크 에러도 처리
|
||
- ✅ 상세한 로깅
|
||
|
||
### 구현
|
||
|
||
**파일**: `src/utils/asyncActionUtils.js:57-123`
|
||
|
||
```javascript
|
||
export const fetchApi = (url, options = {}) => {
|
||
console.log('[asyncActionUtils] 🌐 FETCH_API_START', { url, method: options.method || 'GET' });
|
||
|
||
return new Promise((resolve) => { // ⚠️ 항상 resolve만 사용!
|
||
fetch(url, {
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
...options.headers
|
||
},
|
||
...options
|
||
})
|
||
.then(response => {
|
||
// JSON 파싱
|
||
return response.json()
|
||
.then(responseData => {
|
||
console.log('[asyncActionUtils] 📊 API_RESPONSE', {
|
||
status: response.status,
|
||
ok: response.ok,
|
||
retCode: responseData.retCode,
|
||
success: isApiSuccess(response, responseData)
|
||
});
|
||
|
||
// ✅ 성공/실패 여부와 관계없이 항상 resolve
|
||
resolve({
|
||
response,
|
||
data: responseData,
|
||
success: isApiSuccess(response, responseData),
|
||
error: !isApiSuccess(response, responseData) ? {
|
||
code: responseData.retCode || response.status,
|
||
message: responseData.message || getApiErrorMessage(responseData.retCode || response.status),
|
||
httpStatus: response.status
|
||
} : null
|
||
});
|
||
})
|
||
.catch(parseError => {
|
||
console.error('[asyncActionUtils] ❌ JSON_PARSE_ERROR', parseError);
|
||
|
||
// ✅ JSON 파싱 실패도 resolve로 처리
|
||
resolve({
|
||
response,
|
||
data: null,
|
||
success: false,
|
||
error: {
|
||
code: 'PARSE_ERROR',
|
||
message: '응답 데이터 파싱에 실패했습니다',
|
||
originalError: parseError
|
||
}
|
||
});
|
||
});
|
||
})
|
||
.catch(error => {
|
||
console.error('[asyncActionUtils] 💥 FETCH_ERROR', error);
|
||
|
||
// ✅ 네트워크 에러 등도 resolve로 처리
|
||
resolve({
|
||
response: null,
|
||
data: null,
|
||
success: false,
|
||
error: {
|
||
code: 'NETWORK_ERROR',
|
||
message: error.message || '네트워크 오류가 발생했습니다',
|
||
originalError: error
|
||
}
|
||
});
|
||
});
|
||
});
|
||
};
|
||
```
|
||
|
||
### 사용 예제
|
||
|
||
```javascript
|
||
import { fetchApi } from '../utils/asyncActionUtils';
|
||
|
||
// 기본 사용
|
||
const result = await fetchApi('/api/products/123', {
|
||
method: 'GET'
|
||
});
|
||
|
||
if (result.success) {
|
||
console.log('성공:', result.data);
|
||
// HTTP 200-299 + retCode 0/'0'
|
||
} else {
|
||
console.error('실패:', result.error);
|
||
// error.code, error.message 사용 가능
|
||
}
|
||
|
||
// POST 요청
|
||
const result = await fetchApi('/api/cart', {
|
||
method: 'POST',
|
||
body: JSON.stringify({ productId: 123 })
|
||
});
|
||
|
||
// 헤더 추가
|
||
const result = await fetchApi('/api/user', {
|
||
method: 'GET',
|
||
headers: {
|
||
'Authorization': 'Bearer token123'
|
||
}
|
||
});
|
||
```
|
||
|
||
### 반환 구조
|
||
|
||
```javascript
|
||
// 성공 시
|
||
{
|
||
response: Response, // fetch Response 객체
|
||
data: { ... }, // 파싱된 JSON 데이터
|
||
success: true, // 성공 플래그
|
||
error: null // 에러 없음
|
||
}
|
||
|
||
// 실패 시 (HTTP 에러)
|
||
{
|
||
response: Response,
|
||
data: { retCode: 1, message: "권한 없음" },
|
||
success: false,
|
||
error: {
|
||
code: 1,
|
||
message: "권한 없음",
|
||
httpStatus: 200
|
||
}
|
||
}
|
||
|
||
// 실패 시 (네트워크 에러)
|
||
{
|
||
response: null,
|
||
data: null,
|
||
success: false,
|
||
error: {
|
||
code: 'NETWORK_ERROR',
|
||
message: '네트워크 오류가 발생했습니다',
|
||
originalError: Error
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 3️⃣ tAxiosToPromise
|
||
|
||
### 설명
|
||
|
||
프로젝트에서 사용하는 **TAxios를 Promise로 변환**합니다.
|
||
|
||
### 구현
|
||
|
||
**파일**: `src/utils/asyncActionUtils.js:138-204`
|
||
|
||
```javascript
|
||
export const tAxiosToPromise = (
|
||
TAxios,
|
||
dispatch,
|
||
getState,
|
||
method,
|
||
baseUrl,
|
||
urlParams,
|
||
params,
|
||
options = {}
|
||
) => {
|
||
return new Promise((resolve) => {
|
||
console.log('[asyncActionUtils] 🔄 TAXIOS_TO_PROMISE_START', { method, baseUrl });
|
||
|
||
const enhancedOnSuccess = (response) => {
|
||
console.log('[asyncActionUtils] ✅ TAXIOS_SUCCESS', { retCode: response?.data?.retCode });
|
||
|
||
// TAxios 성공 콜백도 성공 기준 적용
|
||
const isSuccess = response?.data && (
|
||
response.data.retCode === 0 ||
|
||
response.data.retCode === '0'
|
||
);
|
||
|
||
resolve({
|
||
response,
|
||
data: response.data,
|
||
success: isSuccess,
|
||
error: !isSuccess ? {
|
||
code: response.data?.retCode || 'UNKNOWN_ERROR',
|
||
message: response.data?.message || getApiErrorMessage(response.data?.retCode || 'UNKNOWN_ERROR')
|
||
} : null
|
||
});
|
||
};
|
||
|
||
const enhancedOnFail = (error) => {
|
||
console.error('[asyncActionUtils] ❌ TAXIOS_FAIL', error);
|
||
|
||
resolve({ // ⚠️ reject가 아닌 resolve
|
||
response: null,
|
||
data: null,
|
||
success: false,
|
||
error: {
|
||
code: error.retCode || 'TAXIOS_ERROR',
|
||
message: error.message || 'API 호출에 실패했습니다',
|
||
originalError: error
|
||
}
|
||
});
|
||
};
|
||
|
||
try {
|
||
TAxios(
|
||
dispatch,
|
||
getState,
|
||
method,
|
||
baseUrl,
|
||
urlParams,
|
||
params,
|
||
enhancedOnSuccess,
|
||
enhancedOnFail,
|
||
options.noTokenRefresh || false,
|
||
options.responseType
|
||
);
|
||
} catch (error) {
|
||
console.error('[asyncActionUtils] 💥 TAXIOS_EXECUTION_ERROR', error);
|
||
|
||
resolve({
|
||
response: null,
|
||
data: null,
|
||
success: false,
|
||
error: {
|
||
code: 'EXECUTION_ERROR',
|
||
message: 'API 호출 실행 중 오류가 발생했습니다',
|
||
originalError: error
|
||
}
|
||
});
|
||
}
|
||
});
|
||
};
|
||
```
|
||
|
||
### 사용 예제
|
||
|
||
```javascript
|
||
import { tAxiosToPromise } from '../utils/asyncActionUtils';
|
||
import { TAxios } from '../utils/TAxios';
|
||
|
||
export const getProductDetail = (productId) => async (dispatch, getState) => {
|
||
const result = await tAxiosToPromise(
|
||
TAxios,
|
||
dispatch,
|
||
getState,
|
||
'get',
|
||
URLS.GET_PRODUCT_DETAIL,
|
||
{},
|
||
{ productId },
|
||
{}
|
||
);
|
||
|
||
if (result.success) {
|
||
dispatch({
|
||
type: types.GET_PRODUCT_DETAIL,
|
||
payload: result.data.data
|
||
});
|
||
} else {
|
||
console.error('상품 조회 실패:', result.error);
|
||
}
|
||
};
|
||
```
|
||
|
||
---
|
||
|
||
## 4️⃣ wrapAsyncAction
|
||
|
||
### 설명
|
||
|
||
비동기 액션 함수를 Promise로 래핑하여 **표준화된 결과 구조**를 반환합니다.
|
||
|
||
### 구현
|
||
|
||
**파일**: `src/utils/asyncActionUtils.js:215-270`
|
||
|
||
```javascript
|
||
export const wrapAsyncAction = (asyncAction, context = {}) => {
|
||
return new Promise((resolve) => {
|
||
const { dispatch, getState } = context;
|
||
|
||
console.log('[asyncActionUtils] 🎯 WRAP_ASYNC_ACTION_START');
|
||
|
||
// 성공 콜백 - 항상 resolve 호출
|
||
const onSuccess = (result) => {
|
||
console.log('[asyncActionUtils] ✅ WRAP_ASYNC_SUCCESS', result);
|
||
|
||
resolve({
|
||
response: result.response || result,
|
||
data: result.data || result,
|
||
success: true,
|
||
error: null
|
||
});
|
||
};
|
||
|
||
// 실패 콜백 - 항상 resolve 호출 (reject 하지 않음)
|
||
const onFail = (error) => {
|
||
console.error('[asyncActionUtils] ❌ WRAP_ASYNC_FAIL', error);
|
||
|
||
resolve({
|
||
response: null,
|
||
data: null,
|
||
success: false,
|
||
error: {
|
||
code: error.retCode || error.code || 'ASYNC_ACTION_ERROR',
|
||
message: error.message || error.errorMessage || '비동기 작업에 실패했습니다',
|
||
originalError: error
|
||
}
|
||
});
|
||
};
|
||
|
||
try {
|
||
// 비동기 액션 실행
|
||
const result = asyncAction(dispatch, getState, onSuccess, onFail);
|
||
|
||
// Promise를 반환하는 경우도 처리
|
||
if (result && typeof result.then === 'function') {
|
||
result
|
||
.then(onSuccess)
|
||
.catch(onFail);
|
||
}
|
||
} catch (error) {
|
||
console.error('[asyncActionUtils] 💥 WRAP_ASYNC_EXECUTION_ERROR', error);
|
||
onFail(error);
|
||
}
|
||
});
|
||
};
|
||
```
|
||
|
||
### 사용 예제
|
||
|
||
```javascript
|
||
import { wrapAsyncAction } from '../utils/asyncActionUtils';
|
||
|
||
// 비동기 액션 정의
|
||
const myAsyncAction = (dispatch, getState, onSuccess, onFail) => {
|
||
TAxios(dispatch, getState, 'get', URL, {}, {}, onSuccess, onFail);
|
||
};
|
||
|
||
// Promise로 래핑하여 사용
|
||
const result = await wrapAsyncAction(myAsyncAction, { dispatch, getState });
|
||
|
||
if (result.success) {
|
||
console.log('성공:', result.data);
|
||
} else {
|
||
console.error('실패:', result.error.message);
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 5️⃣ withTimeout
|
||
|
||
### 설명
|
||
|
||
Promise에 **타임아웃**을 적용합니다.
|
||
|
||
### 구현
|
||
|
||
**파일**: `src/utils/asyncActionUtils.js:354-373`
|
||
|
||
```javascript
|
||
export const withTimeout = (
|
||
promise,
|
||
timeoutMs,
|
||
timeoutMessage = '작업 시간이 초과되었습니다'
|
||
) => {
|
||
return Promise.race([
|
||
promise,
|
||
new Promise((resolve) => {
|
||
setTimeout(() => {
|
||
console.error('[asyncActionUtils] ⏰ PROMISE_TIMEOUT', { timeoutMs });
|
||
resolve({
|
||
response: null,
|
||
data: null,
|
||
success: false,
|
||
error: {
|
||
code: 'TIMEOUT',
|
||
message: timeoutMessage,
|
||
timeout: timeoutMs
|
||
}
|
||
});
|
||
}, timeoutMs);
|
||
})
|
||
]);
|
||
};
|
||
```
|
||
|
||
### 사용 예제
|
||
|
||
```javascript
|
||
import { withTimeout, fetchApi } from '../utils/asyncActionUtils';
|
||
|
||
// 5초 타임아웃
|
||
const result = await withTimeout(
|
||
fetchApi('/api/slow-endpoint'),
|
||
5000,
|
||
'요청이 시간초과 되었습니다'
|
||
);
|
||
|
||
if (result.success) {
|
||
console.log('성공:', result.data);
|
||
} else if (result.error.code === 'TIMEOUT') {
|
||
console.error('타임아웃 발생');
|
||
} else {
|
||
console.error('기타 에러:', result.error);
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 6️⃣ executeParallelAsyncActions
|
||
|
||
### 설명
|
||
|
||
여러 비동기 액션을 **병렬로 실행**하고 모든 결과를 기다립니다.
|
||
|
||
### 구현
|
||
|
||
**파일**: `src/utils/asyncActionUtils.js:279-299`
|
||
|
||
```javascript
|
||
export const executeParallelAsyncActions = (asyncActions, context = {}) => {
|
||
console.log('[asyncActionUtils] 🚀 EXECUTE_PARALLEL_START', { count: asyncActions.length });
|
||
|
||
const promises = asyncActions.map(action =>
|
||
wrapAsyncAction(action, context)
|
||
);
|
||
|
||
return Promise.all(promises)
|
||
.then(results => {
|
||
console.log('[asyncActionUtils] ✅ EXECUTE_PARALLEL_SUCCESS', {
|
||
successCount: results.filter(r => r.success).length,
|
||
failCount: results.filter(r => !r.success).length
|
||
});
|
||
return results;
|
||
})
|
||
.catch(error => {
|
||
console.error('[asyncActionUtils] ❌ EXECUTE_PARALLEL_ERROR', error);
|
||
return [];
|
||
});
|
||
};
|
||
```
|
||
|
||
### 사용 예제
|
||
|
||
```javascript
|
||
import { executeParallelAsyncActions } from '../utils/asyncActionUtils';
|
||
|
||
// 3개의 API를 동시에 호출
|
||
const results = await executeParallelAsyncActions([
|
||
(dispatch, getState, onSuccess, onFail) => {
|
||
TAxios(dispatch, getState, 'get', URL1, {}, {}, onSuccess, onFail);
|
||
},
|
||
(dispatch, getState, onSuccess, onFail) => {
|
||
TAxios(dispatch, getState, 'get', URL2, {}, {}, onSuccess, onFail);
|
||
},
|
||
(dispatch, getState, onSuccess, onFail) => {
|
||
TAxios(dispatch, getState, 'get', URL3, {}, {}, onSuccess, onFail);
|
||
}
|
||
], { dispatch, getState });
|
||
|
||
// 결과 처리
|
||
results.forEach((result, index) => {
|
||
if (result.success) {
|
||
console.log(`API ${index + 1} 성공:`, result.data);
|
||
} else {
|
||
console.error(`API ${index + 1} 실패:`, result.error);
|
||
}
|
||
});
|
||
```
|
||
|
||
---
|
||
|
||
## 📊 실제 사용 시나리오
|
||
|
||
### 시나리오 1: API 호출 후 후속 처리
|
||
|
||
```javascript
|
||
import { tAxiosToPromise } from '../utils/asyncActionUtils';
|
||
|
||
export const addToCartAndRefresh = (productId) => async (dispatch, getState) => {
|
||
// 1. 카트에 추가
|
||
const addResult = await tAxiosToPromise(
|
||
TAxios,
|
||
dispatch,
|
||
getState,
|
||
'post',
|
||
URLS.ADD_TO_CART,
|
||
{},
|
||
{ productId },
|
||
{}
|
||
);
|
||
|
||
if (addResult.success) {
|
||
// 2. 카트 추가 성공 시 카트 정보 재조회
|
||
dispatch({ type: types.ADD_TO_CART, payload: addResult.data.data });
|
||
|
||
const cartResult = await tAxiosToPromise(
|
||
TAxios,
|
||
dispatch,
|
||
getState,
|
||
'get',
|
||
URLS.GET_CART,
|
||
{},
|
||
{ mbrNo: addResult.data.data.mbrNo },
|
||
{}
|
||
);
|
||
|
||
if (cartResult.success) {
|
||
dispatch({ type: types.GET_CART, payload: cartResult.data.data });
|
||
}
|
||
} else {
|
||
console.error('카트 추가 실패:', addResult.error);
|
||
}
|
||
};
|
||
```
|
||
|
||
### 시나리오 2: 타임아웃이 있는 API 호출
|
||
|
||
```javascript
|
||
import { tAxiosToPromise, withTimeout } from '../utils/asyncActionUtils';
|
||
|
||
export const getLargeData = () => async (dispatch, getState) => {
|
||
const result = await withTimeout(
|
||
tAxiosToPromise(
|
||
TAxios,
|
||
dispatch,
|
||
getState,
|
||
'get',
|
||
URLS.GET_LARGE_DATA,
|
||
{},
|
||
{},
|
||
{}
|
||
),
|
||
10000, // 10초 타임아웃
|
||
'데이터 조회 시간이 초과되었습니다'
|
||
);
|
||
|
||
if (result.success) {
|
||
dispatch({ type: types.GET_LARGE_DATA, payload: result.data.data });
|
||
} else if (result.error.code === 'TIMEOUT') {
|
||
// 타임아웃 처리
|
||
dispatch({ type: types.SHOW_TIMEOUT_MESSAGE });
|
||
} else {
|
||
// 기타 에러 처리
|
||
console.error('조회 실패:', result.error);
|
||
}
|
||
};
|
||
```
|
||
|
||
---
|
||
|
||
## ✅ 장점
|
||
|
||
1. **성공 기준 명확화**: HTTP + retCode 모두 확인
|
||
2. **체인 보장**: reject 없이 resolve만 사용하여 Promise 체인 유지
|
||
3. **상세한 로깅**: 모든 단계에서 로그 출력
|
||
4. **타임아웃 지원**: 응답 없는 API 처리 가능
|
||
5. **에러 처리**: 모든 에러를 표준 구조로 반환
|
||
|
||
## ⚠️ 주의사항
|
||
|
||
1. **Chrome 68 호환**: async/await 사용 가능하지만 주의 필요
|
||
2. **항상 resolve**: reject 사용하지 않음
|
||
3. **success 플래그**: 반드시 `result.success` 확인 필요
|
||
|
||
---
|
||
|
||
**다음**: [해결 방법 3: 큐 기반 패널 액션 시스템 →](./04-solution-queue-system.md)
|