diff --git a/src/app/components/feature/editor/ContentEditor.tsx b/src/app/components/feature/editor/ContentEditor.tsx index 58e0bf0b..49388387 100644 --- a/src/app/components/feature/editor/ContentEditor.tsx +++ b/src/app/components/feature/editor/ContentEditor.tsx @@ -88,9 +88,13 @@ const ContentEditor = ({ : '프로필 내용이 저장되었습니다.', 'success', ); + // 낙관적 업데이트: 저장된 콘텐츠를 즉시 반영 + setEditedContentJson(data.json); setIsEditing(false); - setEditedContentJson(null); - router.refresh(); + // 서버 컴포넌트가 있는 경우에만 refresh (선택적) + // 낙관적 업데이트로 대부분의 경우 불필요하지만, + // 서버 컴포넌트에서 데이터를 가져오는 경우를 위해 주석 처리 + // router.refresh(); } catch (error) { console.error('내용 저장 중 오류 발생:', error); showToast('저장 중 오류가 발생했습니다.', 'error'); diff --git a/src/app/components/feature/project/components/ProjectEditModal.tsx b/src/app/components/feature/project/components/ProjectEditModal.tsx index 3cc533a8..215952ef 100644 --- a/src/app/components/feature/project/components/ProjectEditModal.tsx +++ b/src/app/components/feature/project/components/ProjectEditModal.tsx @@ -67,12 +67,9 @@ const ProjectEditModal = ({ ); if (redirectPath) { router.push(redirectPath); - } else { - router.refresh(); } - } else { - router.refresh(); } + // 낙관적 업데이트가 적용되었으므로 refresh 불필요 } else { const errorMessage = formatErrorMessage(result.message, 'project'); showToast(errorMessage, 'error', 2000, '오류'); @@ -99,9 +96,8 @@ const ProjectEditModal = ({ if (config.redirect?.deletePath) { const redirectPath = config.redirect.deletePath(variables); router.push(redirectPath); - } else { - router.refresh(); } + // 낙관적 업데이트가 적용되었으므로 refresh 불필요 } catch (err) { const errorMessage = err instanceof Error ? err.message : '삭제 중 오류가 발생했습니다.'; showToast(errorMessage, 'error', 3000, '오류'); diff --git a/src/app/components/ui/profile/portfolio/ProfileEditModal.tsx b/src/app/components/ui/profile/portfolio/ProfileEditModal.tsx index 3ce01792..95010575 100644 --- a/src/app/components/ui/profile/portfolio/ProfileEditModal.tsx +++ b/src/app/components/ui/profile/portfolio/ProfileEditModal.tsx @@ -82,11 +82,7 @@ const ProfileEditModal = ({ : null; if (redirectPath) { - if (mode === 'create' && !isTeamValue) { - setTimeout(() => { - window.location.reload(); - }, 100); - } + // 낙관적 업데이트가 적용되었으므로 reload 대신 push만 사용 router.push(redirectPath); } @@ -119,9 +115,9 @@ const ProfileEditModal = ({ is_team: isTeamValue, }); router.push(redirectPath); - } else { - router.refresh(); } + // 삭제 후 UI 갱신을 위해 router.refresh() 호출 + router.refresh(); } catch (err) { const errorMessage = err instanceof Error ? err.message : '삭제 중 오류가 발생했습니다.'; diff --git a/src/utils/hook/useFormConfigData.ts b/src/utils/hook/useFormConfigData.ts index 8a7ff1c3..8ccc4cd0 100644 --- a/src/utils/hook/useFormConfigData.ts +++ b/src/utils/hook/useFormConfigData.ts @@ -1,6 +1,8 @@ 'use client'; import { useState, useEffect, useCallback } from 'react'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { useRouter } from 'next/navigation'; import { FormConfig } from '@/app/components/ui/input/types/inputTypes'; import { MultiInputItem } from '@/utils/hook/useInputList'; import { @@ -22,15 +24,29 @@ export function useFormConfigData( autoLoad?: boolean; // 자동으로 데이터 로드 (기본: true) mode?: FormMode; // 모달의 동작 모드 (기본: 'create') isUpdate?: boolean; // 업데이트 모드 여부 (deprecated, mode 사용 권장) + onSuccess?: (result: Result) => void; // 성공 시 콜백 + onError?: (error: Error) => void; // 에러 시 콜백 + /** + * 무효화할 쿼리 키들 + * 제공되지 않으면 낙관적 업데이트를 건너뜀 + * 예: [['portfolios'], ['projects', profileName]] + */ + invalidateQueries?: string[][]; + /** + * 서버 컴포넌트를 사용하는 경우 refresh 여부 + * 기본값: false (낙관적 업데이트로 충분) + */ + shouldRefresh?: boolean; }, ) { const [initialValues, setInitialValues] = useState< Record >({}); const [isLoading, setIsLoading] = useState(false); - const [isSaving, setIsSaving] = useState(false); const [error, setError] = useState(null); - + + const queryClient = useQueryClient(); + const router = useRouter(); const dataService = getGraphQLDataService(); const autoLoad = options?.autoLoad !== false; @@ -69,68 +85,144 @@ export function useFormConfigData( }, [formConfig, variables, dataService, shouldLoadData]); /** - * 데이터 저장 + * Mutation을 사용한 데이터 저장 (낙관적 업데이트 포함) */ - const saveData = useCallback( - async ( + const mutation = useMutation({ + mutationFn: async ({ + formData, + additionalVariables, + }: { formData: Record< string, MultiInputItem[][] | string[] | boolean | File | null | string - >, - additionalVariables?: Record, - ): Promise => { + >; + additionalVariables?: Record; + }) => { if (!canSave) { - return { - success: false, - message: `Save not allowed in ${mode} mode`, - }; + throw new Error(`Save not allowed in ${mode} mode`); } - setIsSaving(true); - setError(null); + // 이미지 업로드 처리 (파일 크기 검증 포함) + const processedFormData = { ...formData }; + await Promise.all( + formConfig.fields.map(async (field) => { + if ( + field.type === 'picture' && + !field.multiple && + processedFormData[field.fieldName] instanceof File + ) { + processedFormData[field.fieldName] = await uploadProfileImage( + processedFormData[field.fieldName] as File, + field.bucket, + ); + } + }), + ); - try { - // 이미지 업로드 처리 (파일 크기 검증 포함) - await Promise.all( - formConfig.fields.map(async (field) => { - if ( - field.type === 'picture' && - !field.multiple && - formData[field.fieldName] instanceof File - ) { - formData[field.fieldName] = await uploadProfileImage( - formData[field.fieldName] as File, - field.bucket, - ); - } - }), - ); - - const result = await dataService.saveData( - formConfig, - formData, - { ...variables, ...additionalVariables }, - mode === 'update', - ); + const result = await dataService.saveData( + formConfig, + processedFormData, + { ...variables, ...additionalVariables }, + mode === 'update', + ); - if (!result.success) { - setError(result.message || 'Failed to save data'); + if (!result.success) { + throw new Error(result.message || 'Failed to save data'); + } + + return result; + }, + onMutate: async ({ formData, additionalVariables }) => { + // 쿼리 키가 제공되지 않으면 낙관적 업데이트를 건너뜀 + const invalidateKeys = options?.invalidateQueries; + + if (!invalidateKeys || invalidateKeys.length === 0) { + return { previousDataMap: null, invalidateKeys: [] }; + } + + // 낙관적 업데이트: 관련 쿼리들의 이전 데이터를 저장하여 롤백 가능하게 함 + const previousDataMap = new Map(); + + for (const key of invalidateKeys) { + await queryClient.cancelQueries({ queryKey: key }); + const previousData = queryClient.getQueryData(key); + if (previousData) { + previousDataMap.set(JSON.stringify(key), previousData); } + } + + return { previousDataMap, invalidateKeys }; + }, + onError: (error, _variables, context) => { + // 에러 발생 시 이전 데이터로 롤백 (쿼리 키가 제공된 경우에만) + if (context?.previousDataMap) { + context.previousDataMap.forEach((previousData, keyStr) => { + const key = JSON.parse(keyStr); + queryClient.setQueryData(key, previousData); + }); + } + + const errorMessage = error instanceof Error ? error.message : 'Failed to save data'; + setError(errorMessage); + options?.onError?.(error instanceof Error ? error : new Error(errorMessage)); + }, + onSuccess: (result, _variables, context) => { + // 성공 시 관련 쿼리들을 무효화하여 서버에서 최신 데이터를 가져오도록 함 + const invalidateKeys = context?.invalidateKeys; + + if (invalidateKeys && invalidateKeys.length > 0) { + // 모든 관련 쿼리 무효화 + invalidateKeys.forEach((key) => { + queryClient.invalidateQueries({ queryKey: key }); + }); + } + + // 서버 컴포넌트를 사용하는 경우에만 refresh (옵션) + if (options?.shouldRefresh) { + router.refresh(); + } + + setError(null); + options?.onSuccess?.(result); + }, + onSettled: (_data, _error, _variables, context) => { + // 쿼리 키가 제공된 경우에만 무효화 + const invalidateKeys = context?.invalidateKeys; + if (invalidateKeys && invalidateKeys.length > 0) { + invalidateKeys.forEach((key) => { + queryClient.invalidateQueries({ queryKey: key }); + }); + } + }, + }); + /** + * 데이터 저장 (기존 API 호환성 유지) + */ + const saveData = useCallback( + async ( + formData: Record< + string, + MultiInputItem[][] | string[] | boolean | File | null | string + >, + additionalVariables?: Record, + ): Promise => { + try { + const result = await mutation.mutateAsync({ + formData, + additionalVariables, + }); return result; } catch (err) { const errorMessage = err instanceof Error ? err.message : 'Failed to save data'; - setError(errorMessage); return { success: false, message: errorMessage, }; - } finally { - setIsSaving(false); } }, - [formConfig, variables, mode, canSave, dataService], + [mutation], ); /** @@ -154,12 +246,13 @@ export function useFormConfigData( return { initialValues, isLoading, - isSaving, + isSaving: mutation.isPending, error, saveData, reloadData, loadData, mode, canSave, + mutation, // mutation 객체도 반환하여 직접 사용 가능 }; }