// API 기본 설정 const API_TIMEOUT = 10000; // HTTP 상태 코드 const HttpStatus = { BAD_REQUEST: 400, UNAUTHORIZED: 401, FORBIDDEN: 403, NOT_FOUND: 404, CONFLICT: 409, GONE: 410, PRECONDITION_FAILED: 412, PAYLOAD_TOO_LARGE: 413, LOCKED: 423, TOO_EARLY: 425, TOO_MANY_REQUESTS: 429, INTERNAL_SERVER_ERROR: 500, }; // 상태 코드별 기본 에러 메시지 const DEFAULT_ERROR_MESSAGE_BY_STATUS = { [HttpStatus.BAD_REQUEST]: '입력값이 잘못되었습니다.', [HttpStatus.UNAUTHORIZED]: '로그인에 실패했습니다.', [HttpStatus.FORBIDDEN]: '접근 권한이 없습니다.', [HttpStatus.NOT_FOUND]: '요청한 페이지 또는 리소스를 찾을 수 없습니다.', [HttpStatus.CONFLICT]: '입력값이 중복되었습니다.', [HttpStatus.GONE]: '이미 삭제된 리소스입니다.', [HttpStatus.PRECONDITION_FAILED]: '요청 조건을 만족하지 않습니다.', [HttpStatus.PAYLOAD_TOO_LARGE]: '요청 데이터가 너무 큽니다.', [HttpStatus.LOCKED]: '현재 리소스가 잠겨 있습니다.', [HttpStatus.TOO_EARLY]: '아직 처리할 수 없는 요청입니다.', [HttpStatus.TOO_MANY_REQUESTS]: '요청이 너무 많습니다. 잠시 후 다시 시도하세요.', [HttpStatus.INTERNAL_SERVER_ERROR]: '서버에 문제가 발생했습니다.', }; // SQL 에러나 치명적인 네트워크 에러 체크 (shouldFallbackToSql 대체 구현) const shouldFallbackToSql = (error) => { if (!error) { return; } // 예시: 특정 SQL 에러 문자열이나 아주 긴 타임아웃 에러 등을 체크 const msg = error.message || ''; return msg.includes('SQL') || msg.includes('Connection refused'); }; // 중도 요청 취소 컨트롤러 생성 const createController = (timeout = API_TIMEOUT) => { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), timeout); return { controller, timeoutId }; }; const createApiClient = () => { // noinspection JSUnusedGlobalSymbols const client = axios.create({ validateStatus: (status) => status < 500, }); // [Request Interceptor] client.interceptors.request.use( async (config) => { const { method, url, timeout, withCredentials, params, data } = config; console.log( `%c🚀 [REQUEST] ${method?.toUpperCase()} ${url}`, 'color: #42a5f5; font-weight: bold;', ); if (params) { console.log('params:', params); } if (data) { if (data instanceof FormData) { // 1. 안전한 순회를 위해 FormData의 항목들을 배열로 변환 Array.from(data.entries()).forEach(([key, value]) => { // 2. 해당 값이 파일(File) 객체인지 긍정문으로 확인 if (value instanceof File) { // 3. 파일 크기가 0바이트(빈 파일)인지 긍정문으로 확인 if (value.size === 0) { // 4. 빈 파일이면 FormData에서 완전히 삭제 data.delete(key); } } }); // 빈 파일이 싹 지워진 깔끔한 상태로 콘솔 출력 const formDataObj = Object.fromEntries(data.entries()); console.log('payload (FormData):', formDataObj); } else { console.log('payload:', data); } } // 요청 취소(Abort) 컨트롤러 연결 const { controller, timeoutId } = createController(timeout); config.signal = controller.signal; config.timeoutId = timeoutId; // config 객체에 timeoutId 저장 (response에서 해제용) config.withCredentials = withCredentials; return config; }, (error) => { console.error('❌ [REQUEST ERROR]', error); return Promise.reject(error); }, ); // [Response Interceptor] client.interceptors.response.use( (response) => { const config = response.config; console.log( `%c✅ [RESPONSE] ${config.method?.toUpperCase()} ${config.url}`, 'color: #66bb6a; font-weight: bold;', ); console.log('data:', response.data); // 타임아웃 타이머 해제 if (config.timeoutId) { clearTimeout(config.timeoutId); } return response; }, (error) => { const config = error.config; // 에러 로그 console.error( `%c🔥 [ERROR] ${config?.method?.toUpperCase() || ''} ${config?.url || ''} ${error?.status || ''}`, 'color: #ef5350; font-weight: bold;', ); console.log('data:', error.response?.data); // 타임아웃 타이머 해제 if (config?.timeoutId) { clearTimeout(config.timeoutId); } return Promise.reject(error); }, ); return client; }; // Axios 인스턴스 export const apiClient = createApiClient(); /** * API 에러를 처리하여 표준화된 객체로 반환 */ function handleApiError(error, path = undefined, statusMessages = {}) { const errorMessages = { ...DEFAULT_ERROR_MESSAGE_BY_STATUS, ...statusMessages, }; let messageByStatus = ''; let focusId = ''; if (error.response) { const status = error.response?.status || null; const data = error.response?.data; if (status && errorMessages[status]) { messageByStatus += `${messageByStatus ? '\n' : ''}${errorMessages[status]}`; } else if (data && data.message) { messageByStatus += data.message; } if (status === HttpStatus.BAD_REQUEST) { const fieldErrors = data?.fieldErrors; if (fieldErrors && fieldErrors.length > 0) { const fieldError = fieldErrors[0]; focusId = fieldError.fieldName; for (const field of fieldErrors) { messageByStatus += `${messageByStatus ? '\n' : ''}${field.fieldName}: ${field.errorMessage}.`; } } } else if (status === HttpStatus.CONFLICT) { const duplicateMapList = data?.duplicateMapList ?? []; for (const row of duplicateMapList) { const keys = Object.keys(row || {}); for (const key of keys) { if ($(`#${key}`)) { focusId = key; break; } } if (focusId) { break; } } } else if (shouldFallbackToSql(error)) { messageByStatus += `${messageByStatus ? '\n' : ''}시스템 오류가 발생했습니다.`; } else if (error.code === 'ECONNABORTED' || error.message?.includes('timeout')) { messageByStatus += `${messageByStatus ? '\n' : ''}요청 시간이 초과되었습니다.`; } else if (error.request && !error.response) { messageByStatus += `${messageByStatus ? '\n' : ''}서버와 통신할 수 없습니다. 네트워크를 확인해 주세요.`; } } else { console.error('[Non-Axios Error]', error); messageByStatus += `${messageByStatus ? '\n' : ''}내부 오류가 발생했습니다.`; } alert(messageByStatus); if (path) { window.location.href = path; } else if (focusId && $(`#${focusId}`)) { $(`#${focusId}`).focus(); } } /** * form 태그 전송 */ async function submitForm(form) { // noinspection JSDeprecatedSymbols event.preventDefault(); let confirmMessage = form.getAttribute('data-change-confirm'); for (const element of Array.from(form.elements)) { const changeConfirm = element.getAttribute('data-change-confirm'); const originalValue = element.getAttribute('data-original-value'); const currentValue = element.value; if (!changeConfirm || !originalValue || originalValue === currentValue) { continue; } confirmMessage += (confirmMessage && '\n') + changeConfirm; } let method = form.method; const dataMethod = form.getAttribute('data-method'); if (dataMethod) { method = dataMethod; } if (!method || !form.action) { throw new Error('폼 형식 오류'); } if (!form.checkValidity()) { form.reportValidity(); return; } let statusMessages = null; const rawMessages = form.getAttribute('data-status-messages'); if (rawMessages) { statusMessages = JSON.parse(rawMessages); } return await submit({ url: form.action, payload: new FormData(form), reload: JSON.parse(form.getAttribute('data-reload')), confirmMessage, method, statusMessages, }); } /** * form 데이터 전송 */ async function submit({ url, payload = undefined, reload = false, confirmMessage = '', method = 'post', statusMessages = undefined, completeAlert = true, completeMessage = '저장되었습니다.', headers = undefined, timeout = API_TIMEOUT, isLoading = true, errorAlert = true, withCredentials = true, }) { if (confirmMessage && !confirm(`${confirmMessage}`)) { return; } try { if (isLoading) { loading(); } const response = await apiClient.request({ method, url, data: payload, timeout, withCredentials, ...(headers && { headers }), }); if (response.status !== 200) { if (errorAlert) { handleApiError({ response }, undefined, statusMessages); } return response; } if (completeAlert) { alert(completeMessage); } resetChanged(); if (response.data && response.data.redirectUrl) { window.location.replace(response.data.redirectUrl); } else if (reload) { window.location.reload(); } else { return response; } } catch (error) { handleApiError(error, undefined, statusMessages); } finally { if (isLoading) { loading(false); } } }