Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions src/app/components/feature/editor/ContentEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -88,9 +88,13 @@ const ContentEditor = ({
: '프로필 내용이 저장되었습니다.',
'success',
);
// 낙관적 업데이트: 저장된 콘텐츠를 즉시 반영
setEditedContentJson(data.json);
setIsEditing(false);
setEditedContentJson(null);
router.refresh();
// 서버 컴포넌트가 있는 경우에만 refresh (선택적)
// 낙관적 업데이트로 대부분의 경우 불필요하지만,
// 서버 컴포넌트에서 데이터를 가져오는 경우를 위해 주석 처리
// router.refresh();
Comment on lines +91 to +97
Copy link

Copilot AI Dec 2, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ContentEditor는 useFormConfigData 훅을 사용하지 않고 직접 update 함수를 호출하므로, router.refresh() 제거 시 별도의 낙관적 업데이트 메커니즘이 필요합니다.

현재 구현은 setEditedContentJson(data.json)으로 로컬 상태만 업데이트하고 있지만, 이 컴포넌트가 React Query를 사용하는 다른 컴포넌트와 데이터를 공유하는 경우 캐시 불일치가 발생할 수 있습니다.

확인 사항:

  1. 이 컴포넌트가 서버 컴포넌트로부터 initialHtmlContent를 받는 경우, router.refresh()를 유지하거나
  2. React Query를 사용하여 콘텐츠를 가져오는 다른 컴포넌트가 있다면, 해당 쿼리도 무효화해야 합니다.

로컬 상태만 사용하는 경우라면 현재 구현이 적절합니다.

Copilot uses AI. Check for mistakes.
} catch (error) {
console.error('내용 저장 중 오류 발생:', error);
showToast('저장 중 오류가 발생했습니다.', 'error');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,12 +67,9 @@ const ProjectEditModal = ({
);
if (redirectPath) {
router.push(redirectPath);
} else {
router.refresh();
}
} else {
router.refresh();
}
// 낙관적 업데이트가 적용되었으므로 refresh 불필요
Copy link

Copilot AI Dec 2, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

router.refresh() 호출을 제거했지만, invalidateQueries 옵션이 useFormConfigData 훅에 전달되지 않아 낙관적 업데이트가 실제로 작동하지 않습니다. 이로 인해 프로젝트 생성/수정 후 UI가 자동으로 업데이트되지 않습니다.

이 문제는 useFormConfigData 호출 시 invalidateQueries 옵션을 추가하여 해결해야 하지만, 해당 코드는 변경 범위 밖에 있습니다. 또는 router.refresh()를 유지해야 합니다.

Copilot uses AI. Check for mistakes.
} else {
const errorMessage = formatErrorMessage(result.message, 'project');
showToast(errorMessage, 'error', 2000, '오류');
Expand All @@ -99,9 +96,8 @@ const ProjectEditModal = ({
if (config.redirect?.deletePath) {
const redirectPath = config.redirect.deletePath(variables);
router.push(redirectPath);
} else {
router.refresh();
}
// 낙관적 업데이트가 적용되었으므로 refresh 불필요
Copy link

Copilot AI Dec 2, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

router.refresh() 호출을 제거했지만, invalidateQueries 옵션이 useFormConfigData 훅에 전달되지 않아 낙관적 업데이트가 실제로 작동하지 않습니다. 이로 인해 프로젝트 삭제 후 UI가 자동으로 업데이트되지 않습니다.

이 문제는 useFormConfigData 호출 시 invalidateQueries 옵션을 추가하거나, 삭제 핸들러에서 수동으로 쿼리를 무효화하거나 router.refresh()를 유지해야 합니다.

Copilot uses AI. Check for mistakes.
} catch (err) {
const errorMessage = err instanceof Error ? err.message : '삭제 중 오류가 발생했습니다.';
showToast(errorMessage, 'error', 3000, '오류');
Expand Down
10 changes: 3 additions & 7 deletions src/app/components/ui/profile/portfolio/ProfileEditModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -82,11 +82,7 @@ const ProfileEditModal = ({
: null;

if (redirectPath) {
if (mode === 'create' && !isTeamValue) {
setTimeout(() => {
window.location.reload();
}, 100);
}
// 낙관적 업데이트가 적용되었으므로 reload 대신 push만 사용
router.push(redirectPath);
}

Expand Down Expand Up @@ -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 : '삭제 중 오류가 발생했습니다.';
Expand Down
181 changes: 137 additions & 44 deletions src/utils/hook/useFormConfigData.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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<string, MultiInputItem[][] | string[] | boolean | File | string | null>
>({});
const [isLoading, setIsLoading] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [error, setError] = useState<string | null>(null);


const queryClient = useQueryClient();
const router = useRouter();
const dataService = getGraphQLDataService();
const autoLoad = options?.autoLoad !== false;

Expand Down Expand Up @@ -69,68 +85,144 @@ export function useFormConfigData(
}, [formConfig, variables, dataService, shouldLoadData]);

/**
* 데이터 저장
* Mutation을 사용한 데이터 저장 (낙관적 업데이트 포함)
Copy link

Copilot AI Dec 2, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR 설명과 코드 주석에서 "낙관적 업데이트(Optimistic Update)"라고 설명하고 있지만, 실제 구현은 mutation 완료 후 쿼리를 무효화하는 방식입니다. 이는 낙관적 업데이트가 아니라 **자동 쿼리 무효화(Automatic Query Invalidation)**입니다.

낙관적 업데이트는 서버 응답을 기다리지 않고 즉시 UI를 업데이트하는 것이고, 쿼리 무효화는 mutation 완료 후 서버에서 최신 데이터를 다시 가져오는 것입니다.

현재 구현은 후자이므로, 사용자는 여전히 mutation이 완료될 때까지 기다려야 하며 UX 개선 효과가 제한적입니다. PR 제목과 설명을 실제 구현에 맞게 수정하거나, 진정한 낙관적 업데이트를 구현해야 합니다.

Suggested change
* Mutation을 사용한 데이터 저장 (낙관적 업데이트 포함)
* Mutation을 사용한 데이터 저장 (자동 쿼리 무효화 방식)

Copilot uses AI. Check for mistakes.
*/
const saveData = useCallback(
async (
const mutation = useMutation({
mutationFn: async ({
formData,
additionalVariables,
}: {
formData: Record<
string,
MultiInputItem[][] | string[] | boolean | File | null | string
>,
additionalVariables?: Record<string, unknown>,
): Promise<Result> => {
>;
additionalVariables?: Record<string, unknown>;
}) => {
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<string, unknown>();

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 };
},
Comment on lines +135 to +155
Copy link

Copilot AI Dec 2, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

낙관적 업데이트가 실제로 캐시를 업데이트하지 않습니다. onMutate에서 이전 데이터를 저장만 하고 새로운 데이터로 캐시를 업데이트하지 않아, "낙관적 업데이트"의 핵심 기능(mutation 완료 전 UI 즉시 업데이트)이 작동하지 않습니다.

해결 방법: onMutate에서 쿼리 캐시를 새 데이터로 업데이트해야 합니다:

onMutate: async ({ formData, additionalVariables }) => {
  const invalidateKeys = options?.invalidateQueries;
  
  if (!invalidateKeys || invalidateKeys.length === 0) {
    return { previousDataMap: null, invalidateKeys: [] };
  }
  
  const previousDataMap = new Map<string, unknown>();
  
  for (const key of invalidateKeys) {
    await queryClient.cancelQueries({ queryKey: key });
    const previousData = queryClient.getQueryData(key);
    if (previousData) {
      previousDataMap.set(JSON.stringify(key), previousData);
    }
    
    // 낙관적 업데이트: 캐시를 새 데이터로 즉시 업데이트
    // 데이터 구조에 맞게 업데이트 로직 구현 필요
    // 예: queryClient.setQueryData(key, (old) => ({ ...old, ...formData }));
  }

  return { previousDataMap, invalidateKeys };
},

또는 현재 구현이 의도한 것이라면, "낙관적 업데이트" 대신 "자동 쿼리 무효화"라고 명명하는 것이 더 정확합니다.

Copilot uses AI. Check for mistakes.
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 });
});
}
Comment on lines +189 to +195
Copy link

Copilot AI Dec 2, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

onSuccessonSettled 모두에서 동일한 쿼리 무효화가 중복 실행됩니다. 이는 불필요한 중복 작업이며 성능에 영향을 줄 수 있습니다.

해결 방법: onSettled에서의 무효화를 제거하고 onSuccess에서만 수행하거나, onSettled에서만 수행하도록 변경하세요. React Query 공식 문서에서는 일반적으로 onSettled에서 무효화를 권장하지만, 성공 시에만 무효화가 필요한 경우 onSuccess만 사용해도 됩니다.

// 옵션 1: onSuccess에서만 무효화 (현재 로직 유지)
onSuccess: (result, _variables, context) => {
  const invalidateKeys = context?.invalidateKeys;
  if (invalidateKeys && invalidateKeys.length > 0) {
    invalidateKeys.forEach((key) => {
      queryClient.invalidateQueries({ queryKey: key });
    });
  }
  // ... 나머지 코드
},
// onSettled 제거 또는 다른 정리 작업만 수행

// 옵션 2: onSettled에서만 무효화 (성공/실패 모두)
onSettled: (_data, _error, _variables, context) => {
  const invalidateKeys = context?.invalidateKeys;
  if (invalidateKeys && invalidateKeys.length > 0) {
    invalidateKeys.forEach((key) => {
      queryClient.invalidateQueries({ queryKey: key });
    });
  }
},
Suggested change
// 쿼리 키가 제공된 경우에만 무효화
const invalidateKeys = context?.invalidateKeys;
if (invalidateKeys && invalidateKeys.length > 0) {
invalidateKeys.forEach((key) => {
queryClient.invalidateQueries({ queryKey: key });
});
}
// 기타 정리 작업이 필요한 경우 여기에 추가

Copilot uses AI. Check for mistakes.
},
});

/**
* 데이터 저장 (기존 API 호환성 유지)
*/
const saveData = useCallback(
async (
formData: Record<
string,
MultiInputItem[][] | string[] | boolean | File | null | string
>,
additionalVariables?: Record<string, unknown>,
): Promise<Result> => {
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],
);

/**
Expand All @@ -154,12 +246,13 @@ export function useFormConfigData(
return {
initialValues,
isLoading,
isSaving,
isSaving: mutation.isPending,
error,
saveData,
reloadData,
loadData,
mode,
canSave,
mutation, // mutation 객체도 반환하여 직접 사용 가능
};
}