diff --git a/src/hooks/common/useCanvasPopupStatusController.js b/src/hooks/common/useCanvasPopupStatusController.js index a59b6e38..8f747bdf 100644 --- a/src/hooks/common/useCanvasPopupStatusController.js +++ b/src/hooks/common/useCanvasPopupStatusController.js @@ -1,7 +1,6 @@ 'use client' import { useRecoilState, useRecoilValue, useSetRecoilState } from 'recoil' -import useSWRMutation from 'swr/mutation' import { useAxios } from '../useAxios' import { unescapeString } from '@/util/common-utils' import { moduleSelectionDataState, selectedModuleState } from '@/store/selectedModuleOptions' @@ -9,7 +8,7 @@ import { compasDegAtom } from '@/store/orientationAtom' import { canvasState, currentCanvasPlanState } from '@/store/canvasAtom' import { POLYGON_TYPE } from '@/common/common' import { useCircuitTrestle } from '../useCirCuitTrestle' -import { useContext } from 'react' +import { useContext, useRef, useState } from 'react' import { addedRoofsState } from '@/store/settingAtom' import { roofsState } from '@/store/roofAtom' import { GlobalDataContext } from '@/app/GlobalDataProvider' @@ -91,21 +90,49 @@ export function useCanvasPopupStatusController(param = 1) { /** * 팝업 상태 저장 + * - 직전 in-flight 요청은 AbortController 로 취소 (slider/drag 연속 호출 시 마지막 값만 서버 도달) + * - isMutating: 진행 중 여부 (버튼 disabled / 스피너 바인딩용) + * - X-Idempotency-Key 는 useAxios 인터셉터에서 자동 부착 */ - const { trigger, isMutating } = useSWRMutation( - `/api/v1/canvas-popup-status?objectNo=${currentCanvasPlan.objectNo}&planNo=${currentCanvasPlan.planNo}&popupType=${popupType}`, - (url, { arg }) => { - const params = { - objectNo: currentCanvasPlan.objectNo, - planNo: parseInt(currentCanvasPlan.planNo), - popupType: popupType.toString(), - // popupStatus: popupType === 1 ? arg : JSON.stringify(arg).replace(/"/g, '\"'), - popupStatus: JSON.stringify(arg).replace(/"/g, '\"'), - //hajebichi: arg.roofConstructions?.[0]?.addRoof?.hajebichi || '', - } - postFetcher(`/api/v1/canvas-popup-status`, params) - }, - ) + const [isMutating, setIsMutating] = useState(false) + const abortRef = useRef(null) - return { getModuleSelection, handleModuleSelectionTotal, trigger } + // 언마운트 시 in-flight 는 의도적으로 abort 하지 않는다. + // 이유: 사용자가 trigger 직후 즉시 모달을 닫는 race 에서 저장이 누락되는 회귀가 있었다 (기존 SWR fire-and-forget 시절엔 백엔드까지 도달). + // in-flight Promise 는 다음 호출 시 새 controller 로 교체되거나, 자연 완료 후 GC 된다. + + const trigger = async (arg) => { + if (!currentCanvasPlan.objectNo || !currentCanvasPlan.planNo) return + + // 직전 in-flight 취소 + if (abortRef.current) { + abortRef.current.abort() + } + const controller = new AbortController() + abortRef.current = controller + + const params = { + objectNo: currentCanvasPlan.objectNo, + planNo: parseInt(currentCanvasPlan.planNo), + popupType: popupType.toString(), + popupStatus: JSON.stringify(arg).replace(/"/g, '\"'), + } + + setIsMutating(true) + try { + return await postFetcher(`/api/v1/canvas-popup-status`, params, { signal: controller.signal }) + } catch (e) { + // AbortController 로 인한 취소는 정상 흐름 + if (e?.name === 'CanceledError' || e?.code === 'ERR_CANCELED' || e?.name === 'AbortError') return + // 기존 SWR fire-and-forget 호환: 서버 에러도 호출자에게 throw 하지 않음 (호출자 8곳 중 6곳이 await/try 미사용) + console.error('🚀 ~ useCanvasPopupStatusController.trigger failed:', e) + } finally { + if (abortRef.current === controller) { + abortRef.current = null + setIsMutating(false) + } + } + } + + return { getModuleSelection, handleModuleSelectionTotal, trigger, isMutating } } diff --git a/src/hooks/option/useCanvasSetting.js b/src/hooks/option/useCanvasSetting.js index 8f339c47..49c7814a 100644 --- a/src/hooks/option/useCanvasSetting.js +++ b/src/hooks/option/useCanvasSetting.js @@ -582,10 +582,9 @@ export function useCanvasSetting(executeEffect = true) { /** 모듈 선택 데이터 초기화 */ resetModuleSelectionData() - //1번 초기화 - orientationTrigger({ compasDeg: 0, common: {}, module: {} }) - //2번 초기화 - moduleSelectedDataTrigger({ roofConstructions: [] }) + //1번/2번 초기화 — 동일 plan 의 popupType 다른 row 라도 같은 트랜잭션 가족 안에서 동시 UPDATE 가 SQL Server 데드락 victim 을 유발한 사례가 있어 직렬화한다. + await orientationTrigger({ compasDeg: 0, common: {}, module: {} }) + await moduleSelectedDataTrigger({ roofConstructions: [] }) const isModuleExist = canvas.getObjects().some((obj) => obj.name === POLYGON_TYPE.MODULE) if (!isModuleExist) { resetSelectedModules() diff --git a/src/hooks/useAxios.js b/src/hooks/useAxios.js index a2ddc1dc..3e6f2d33 100644 --- a/src/hooks/useAxios.js +++ b/src/hooks/useAxios.js @@ -1,10 +1,14 @@ import axios, { Axios } from 'axios' +import { v4 as uuidv4 } from 'uuid' const AxiosType = { INTERNAL: 'Internal', EXTERNAL: 'External', } +// WRITE 요청 식별용. 호출자가 option.headers['X-Idempotency-Key'] 를 직접 지정하면 그대로 사용. +const WRITE_METHODS = ['post', 'put', 'patch'] + /** * axios 인스턴스 생성 후 반환 * @param {String} lang @@ -26,10 +30,24 @@ export function useAxios(lang = '') { } url.startsWith('https') ? '' : (headerValue['lang'] = lang) - return axios.create({ + const instance = axios.create({ baseURL: type === AxiosType.INTERNAL ? process.env.NEXT_PUBLIC_API_SERVER_PATH : '', headers: headerValue, }) + + // WRITE 메서드에 X-Idempotency-Key 자동 부착 (override 가능) + instance.interceptors.request.use((config) => { + const method = (config.method || '').toLowerCase() + if (WRITE_METHODS.includes(method)) { + config.headers = config.headers || {} + if (!config.headers['X-Idempotency-Key']) { + config.headers['X-Idempotency-Key'] = uuidv4() + } + } + return config + }) + + return instance } // request 추가 로직 @@ -205,8 +223,8 @@ export function useAxios(lang = '') { return res } - const postFetcher = async (url, arg) => { - const res = await post({ url, data: arg }) + const postFetcher = async (url, arg, option = {}) => { + const res = await post({ url, data: arg, option }) return res } diff --git a/src/hooks/usePlan.js b/src/hooks/usePlan.js index fe9fa13b..150be261 100644 --- a/src/hooks/usePlan.js +++ b/src/hooks/usePlan.js @@ -32,6 +32,10 @@ import { QcastContext } from '@/app/QcastProvider' import { unescapeString } from '@/util/common-utils' import { useTrestle } from '@/hooks/module/useTrestle' +// putCanvasStatus 의 in-flight 요청 — module-scope 로 두어 usePlan() 인스턴스 간 공유. +// 큰 fabric JSON 의 동시 PUT 이 SQL Server 데드락 victim 을 양산해 온 이력 때문에 직전 호출은 abort. +const canvasStatusInflight = { controller: null } + /** * 플랜 처리 훅 * 플랜을 표시하는 탭 UI 전반적인 처리 로직 관리 @@ -342,16 +346,30 @@ export function usePlan(params = {}) { canvasStatus: canvasToDbFormat(canvasStatus), } - await promisePut({ url: '/api/canvas-management/canvas-statuses', data: planData }) + // 직전 in-flight 가 살아있다면 취소. abort 된 호출은 .catch 에서 silent return. + if (canvasStatusInflight.controller) { + canvasStatusInflight.controller.abort() + } + const controller = new AbortController() + canvasStatusInflight.controller = controller + + await promisePut({ url: '/api/canvas-management/canvas-statuses', data: planData, option: { signal: controller.signal } }) .then((res) => { setPlans((plans) => plans.map((plan) => (plan.id === currentCanvasPlan.id ? { ...plan, canvasStatus: canvasStatus } : plan))) if (saveAlert) swalFire({ text: getMessage('plan.message.save') }) rtn = true }) .catch((error) => { + // AbortController 로 인한 취소는 정상 흐름 — 새 호출이 진행 중이므로 UX 메시지/로딩 해제 안 함 + if (error?.name === 'CanceledError' || error?.code === 'ERR_CANCELED' || error?.name === 'AbortError') return swalFire({ text: error.message, icon: 'error' }) setIsGlobalLoading(false) }) + .finally(() => { + if (canvasStatusInflight.controller === controller) { + canvasStatusInflight.controller = null + } + }) return rtn }