2026, 새로운 도약을 시작합니다.

Ajax 전송 / 콜백 / 렌더링까지 통합 처리

주말을 맞아 주로 사용하는 코드를 좀 정리해서 쉽게 사용할 수 있도록 수정하였습니다.
그누보드나 영카트에서 쉽게 ajax 전송, 결과 처리 페이지 랜더링을 좀 더 쉽게 사용할 수 있습니다.

[code]

const WwizFrontCore = (() => {
    const API_BASE_URL = window.API_FULL_BASE_URL || '/api/v1';
    let cachedCsrfToken = null;
    const callbacks = {};
    const renderers = {};

    // 설정 가능한 옵션
    const config = {
        timeout: 30000, // 30초 기본 타임아웃
        errorHandler: null, // 커스텀 에러 핸들러
        progressElement: null, // 커스텀 진행률 엘리먼트
        showProgress: null, // 커스텀 진행률 표시 함수
    };

    // 디바운스 유틸리티
    const debounce = (func, wait) => {
        let timeout;
        return function executedFunction(...args) {
            const later = () => {
                clearTimeout(timeout);
                func(...args);
            };
            clearTimeout(timeout);
            timeout = setTimeout(later, wait);
        };
    };

    // 쓰로틀 유틸리티
    const throttle = (func, limit) => {
        let inThrottle;
        return function(...args) {
            if (!inThrottle) {
                func.apply(this, args);
                inThrottle = true;
                setTimeout(() => inThrottle = false, limit);
            }
        };
    };

    // -----------------------------------
    // 초기화 (Progress 스타일 및 버튼 감지)
    // -----------------------------------
    function init() {
        addProgressStyles();
        addProgressElement();
        document.addEventListener('click', handleButtonClick);
    }

    // Progress 로딩 스타일 추가
    function addProgressStyles() {
        const style = document.createElement('style');
        style.textContent = `
            #progress { display:none; position:fixed; top:0; left:0; right:0; bottom:0; z-index:9999; background:#000; opacity:.1; }
            #progress:after { content:""; position:fixed; top:calc(50% - 30px); left:calc(50% - 30px); border:6px solid #60718b; border-top-color:#fff; border-bottom-color:#fff; border-radius:50%; width:60px; height:60px; animation:spin 1s linear infinite; }
            @keyframes spin { 0%{transform:rotate(0);}100%{transform:rotate(360deg);} }
        `;
        document.head.appendChild(style);
    }

    // Progress 요소 추가
    function addProgressElement() {
        if (!document.getElementById('progress') && !config.progressElement) {
            const el = document.createElement('div');
            el.id = 'progress';
            document.body.appendChild(el);
        }
    }

    // Progress 표시/숨김
    function toggleProgress(show = true) {
        // 커스텀 진행률 표시 함수가 있으면 우선 사용
        if (config.showProgress && typeof config.showProgress === 'function') {
            config.showProgress(show);
            return;
        }

        // 커스텀 진행률 엘리먼트가 있으면 사용
        if (config.progressElement) {
            config.progressElement.style.display = show ? 'block' : 'none';
            return;
        }

        // 기본 진행률 표시
        let el = document.getElementById('progress');
        if (!el) addProgressElement();
        el = document.getElementById('progress');
        el.style.display = show ? 'block' : 'none';
    }

    // -----------------------------------
    // CSRF 토큰 관리
    // - const res = await fetch(`${API_BASE_URL}/token/getUserCsrfToken`);
    // -- 위 API 가 없다면 생략 또는 API 경로 변경
    // -- TOKEN API 형식 { token: '1dafarertasasf' }
    // -----------------------------------
    async function getCsrfToken() {
        if (cachedCsrfToken) return cachedCsrfToken;

        try {
            const res = await fetch(`${API_BASE_URL}/token/getUserCsrfToken`);
            if (!res.ok) throw new Error(`CSRF fetch failed: ${res.status}`);
            const data = await res.json();
            if (!data || !data.token) throw new Error('Invalid CSRF response (no token)');
            cachedCsrfToken = data.token;
            return cachedCsrfToken;
        } catch (err) {
            console.error('[WwizFrontCore] CSRF Token Fetch Error:', err);
            throw err; // sendAjaxRequest에서 처리됨
        }
    }

    function resetCsrfToken() {
        cachedCsrfToken = null;
    }

    // -----------------------------------
    // 에디터 내용 동기화 유틸리티 (그누보드/영카트/TinyMCE/CKEditor 지원)
    // -----------------------------------
    function syncAllEditors() {
        document.querySelectorAll('.editor-form, textarea[id^="wr_content"], textarea.smarteditor2').forEach(ed => {
            const editorId = ed.id;

            try {
                // SmartEditor2 (그누보드 기본)
                if (window.oEditors?.getById?.[editorId]) {
                    oEditors.getById[editorId].exec("UPDATE_CONTENTS_FIELD", []);
                    return;
                }

                // CKEditor
                if (window.CKEDITOR?.instances?.[editorId]) {
                    CKEDITOR.instances[editorId].updateElement();
                    return;
                }

                // TinyMCE
                if (window.tinymce?.get(editorId)) {
                    tinymce.get(editorId).save();
                    return;
                }
            } catch (e) {
                console.warn(`[Editor Sync Error] ${editorId}:`, e);
            }
        });
    }

    // -----------------------------------
    // 공통 Ajax 요청 처리
    // -----------------------------------
    async function sendAjaxRequest(method, url, data, isFormData = false, isLoading = false, retryCount = 0) {
        // AbortController for timeout
        const controller = new AbortController();
        const timeoutId = setTimeout(() => controller.abort(), config.timeout);

        try {
            const csrfToken = await getCsrfToken();
            const options = {
                method,
                headers: {
                    'X-CSRF-Token': csrfToken,
                    'X-Requested-With': 'XMLHttpRequest',
                },
                signal: controller.signal,
            };

            if (method.toUpperCase() !== 'GET') {
                if (isFormData && data instanceof FormData) {
                    options.body = data;
                } else if (!isFormData) {
                    options.headers['Content-Type'] = 'application/json';
                    options.body = JSON.stringify(data);
                } else {
                    throw new Error('Invalid data type for isFormData=true');
                }
            } else if (data) {
                url += (url.includes('?') ? '&' : '?') + new URLSearchParams(data).toString();
            }

            if (isLoading) toggleProgress(true);

            const response = await fetch(url, options);

            let json;
            try {
                json = await response.json();
            } catch {
                json = { result: false, message: 'Invalid JSON response' };
            }

            if (isLoading) toggleProgress(false);

            if (!response.ok) {
                if (response.status === 419 && retryCount < 3) {
                    resetCsrfToken();
                    return sendAjaxRequest(method, url, data, isFormData, isLoading, retryCount + 1);
                }
                throw new Error(json.error || 'Request failed');
            }

            return json;
        } catch (e) {
            if (e.name === 'AbortError') {
                handleError(new Error('요청 시간이 초과되었습니다.'));
            } else {
                handleError(e);
            }
            toggleProgress(false);
            throw e;
        } finally {
            clearTimeout(timeoutId);
        }
    }

    // -----------------------------------
    // 폼 기반 Ajax 요청
    // -----------------------------------
    async function handleAjaxFormSubmit(button) {
        const form = button.closest('form');
        const url = button.dataset.target;
        const beforeSubmit = button.dataset.beforeSubmit;
        const callback = button.dataset.callback;
        const containerId = button.dataset.container;
        const loading = button.dataset.loading === 'true';

        // 기본 검증
        if (!form) {
            alert('폼이 지정되지 않았습니다.');
            return;
        }
        if (!url) {
            alert('URL이 지정되지 않았습니다.');
            return;
        }

        // 전송 전 커스텀 훅
        if (beforeSubmit && typeof window[beforeSubmit] === 'function') {
            const proceed = await window[beforeSubmit]();
            if (!proceed) return;
        }

        // 에디터 내용 동기화
        syncAllEditors();

        // 폼 유효성 검사 (사용자 커스텀)
        if (typeof validateForm === 'function') {
            if (!validateForm(form)) return;
        } else if (window.Common?.validateForm) {
            if (!Common.validateForm(form)) return;
        }

        button.disabled = true;

        try {
            const formData = new FormData(form);
            const result = await sendAjaxRequest('POST', url, formData, true, loading);

            if (result.result && callback) {
                await executeCallback(callback, result, containerId);
            } else {
                alert(result.message || '처리 중 오류가 발생했습니다.');
            }
        } catch (e) {
            handleError(e);
        } finally {
            button.disabled = false;
        }
    }

    // 디바운스된 폼 제출 핸들러 (연속 클릭 방지)
    const debouncedHandleAjaxFormSubmit = debounce(handleAjaxFormSubmit, 300);

    // -----------------------------------
    // 커스텀 Ajax 호출 (폼 외부)
    // -----------------------------------
    async function sendCustomAjaxRequest(method, url, data, loading = false, callback = null, containerId = null) {
        try {
            const result = await sendAjaxRequest(method, url, data, false, loading);
            if (callback) {
                await executeCallback(callback, result, containerId);
            }
            return result;
        } catch (e) {
            handleError(e);
            throw e;
        }
    }

    // -----------------------------------
    // 공통 에러 핸들러
    // -----------------------------------
    function handleError(error) {
        const msg = error?.message || '요청 처리 중 오류가 발생했습니다.';
        console.error('[WwizFrontCore Error]', msg);
        
        // 커스텀 에러 핸들러가 있으면 사용
        if (config.errorHandler && typeof config.errorHandler === 'function') {
            config.errorHandler(error);
        } else {
            // 기본 동작
            alert(msg);
        }
    }

    // -----------------------------------
    // 콜백 & 렌더러 시스템
    // -----------------------------------
    function registerCallback(name, fn) {
        if (typeof fn === 'function') callbacks[name] = fn;
    }

    async function executeCallback(name, data, containerId) {
        if (callbacks[name]) return await callbacks[name](data, containerId);
        if (typeof window[name] === 'function') return await window[name](data, containerId);
        console.warn(`콜백 [${name}]를 찾을 수 없습니다.`);
    }

    function registerRenderer(name, fn) {
        if (typeof fn === 'function') renderers[name] = fn;
    }

    function render(name, container, data, ...args) {
        if (renderers[name]) return renderers[name](container, data, ...args);
        console.warn(`렌더러 [${name}]를 찾을 수 없습니다.`);
    }

    // -----------------------------------
    // 이벤트 위임 (폼 전송 버튼 감지)
    // -----------------------------------
    function handleButtonClick(e) {
        const btn = e.target.closest('.btn-form-submit-ajax');
        if (btn) {
            // 디바운스된 핸들러 사용 (연속 클릭 방지)
            debouncedHandleAjaxFormSubmit(btn);
        }
    }

    // -----------------------------------
    // 메모리 정리 함수
    // -----------------------------------
    function destroy() {
        document.removeEventListener('click', handleButtonClick);
        // 진행률 엘리먼트 제거
        const progressEl = document.getElementById('progress');
        if (progressEl) progressEl.remove();
        // 캐시된 토큰 정리
        cachedCsrfToken = null;
    }

    // -----------------------------------
    // 설정 변경 함수
    // -----------------------------------
    function configure(options) {
        Object.assign(config, options);
    }

    // -----------------------------------
    // 외부 노출 API
    // -----------------------------------
    return {
        init,
        sendAjaxRequest,
        sendCustomAjaxRequest,
        handleAjaxFormSubmit,
        registerCallback,
        executeCallback,
        registerRenderer,
        render,
        destroy,
        configure,
        debounce,
        throttle,
        syncAllEditors,
    };
})();

// -----------------------------------
// 자동 초기화
// -----------------------------------
document.addEventListener('DOMContentLoaded', () => WwizFrontCore.init());

[/code]

첨부파일

WwizFrontCore.js (19.5 KB) 7회 2025-10-26 16:26
|

댓글 2개

와.. 얼핏 보았는데 디바운스 설계까지 잘 되어있고 온리 바닐라 스크립트로만 짜여진 멋진 코드 같아 보입니다. 역시 숨은 고수님들이 많으시군요 ㅎㅎ 좋은 코드 공유 감사합니다.

댓글 작성

댓글을 작성하시려면 로그인이 필요합니다.

로그인하기

영카트5 팁자료실

번호 제목 글쓴이 날짜 조회
441 3주 전 조회 239
440 1개월 전 조회 189
439 1개월 전 조회 291
438 1개월 전 조회 459
437 2개월 전 조회 658
436 2개월 전 조회 270
435 2개월 전 조회 382
434 3개월 전 조회 536
433 3개월 전 조회 368
432 3개월 전 조회 338
431 3개월 전 조회 443
430 3개월 전 조회 406
429 3개월 전 조회 359
428 3개월 전 조회 367
427 4개월 전 조회 509
426 4개월 전 조회 536
425 4개월 전 조회 353
424 4개월 전 조회 628
423 4개월 전 조회 599
422 4개월 전 조회 520
421 5개월 전 조회 572
420 5개월 전 조회 489
419 5개월 전 조회 571
418 5개월 전 조회 514
417 5개월 전 조회 622
416 5개월 전 조회 428
415 6개월 전 조회 564
414 6개월 전 조회 567
413 6개월 전 조회 661
412 7개월 전 조회 551
🐛 버그신고