Next.js로 모하루(개인 프로젝트)를 진행하면서, 모든 서버 통신은 Server Action을 이용해 구현했습니다.
Route Handler와 Server Action 중에서 Server Action을 선택한 이유는
- 비즈니스 로직을 클라이언트로부터 분리하고, 보안을 강화하기 위해서
Server Action은 서버 환경에서만 실행되기 때문에, 민감한 로직이나 API 키, 인증 정보 등을 클라이언트에 노출시키지 않고 안전하게 처리할 수 있습니다. - Supabase를 백엔드로 사용했기 때문입니다.
Supabase에서 제공하는 메서드들은 fetch로 직접 호출하는 방식이 아니라, 서버 환경에서 동작하는 클라이언트를 통해 호출해야 하기 때문에, Server Action을 활용하는 것이 더 적합했습니다. - 모든 서버 통신이 사용자의 UI 상의 직접적인 동작(예: 글 작성, 수정, 삭제, 로그인 등)에서 발생하기 때문입니다.
Route Handler는 주로 외부 API 요청, fetch 기반 통신, 다양한 HTTP 메서드 처리가 필요한 경우에 적합하지만, 제 프로젝트에서는 이러한 복잡한 구조가 필요하지 않았습니다.
서버 액션
"Server Functions allow Client Components to call async functions executed on the server."
비동기 함수에 'use server'를 추가하면 서버 함수로 만들어집니다.
클라이언트 -> 서버로 데이터를 보내는 구조 자체가 비동기이기 때문에, 서버 액션도 자연스럽게 비동기로 선언해야합니다.
서버액션은 결과를 캐싱하지 않는다
제 프로젝트에서는 유저의 개인화 데이터가 많고, 그 변화가 자주 일어나지 않아 캐싱이 필요했습니다.
하지만, Server Action은 dynamic 통신 방식이기 때문에, Next.js의 캐싱 전략과 호환되지 않습니다.
찾아보니 공식문서에서 친절하게 적혀있었습니다 ㅜㅜ
서버 함수는 서버 측 상태를 업데이트하는 Mutation을 위해 설계되었으며, 데이터 가져오기Fetching에는 권장하지 않습니다.
따라서, 서버 함수를 구현하는 프레임워크는 일반적으로 한 번에 하나의 작업만 처리하며, 반환 값을 캐시하는 방법을 제공하지 않습니다.
그래서 route handler를 사용하여 서버 액션을 호출하는 방식으로 캐싱하려했으나,
🐛supabase의 버그🐛로 인증 쿠키가 전달되지 않아 적용할 수 없었습니다 ㅠ
How to query a NextJS route handler from a server component and get data from Supabase
Whenever I try to fetch data from my route handler from within a server component in NextJS, the query is made by the server, therefor the route handler returns this error : { error: 'JSON object
stackoverflow.com
클라이언트 캐싱 적용 (Tanstack Query)
그래서 클라이언트 캐싱을 적용했습니다.
결국, 데이터 패칭은 Server Action으로, 캐싱은 TanStack Query로 분리해서 처리함으로써, 보안성과 성능을 모두 확보할 수 있었습니다.
이 때, 쿼리 키 관리를 체계적으로 하기 위해 아래와 같이 query key factory 패턴을 사용했습니다.
문자열로 직접 키를 쓰는 방식은 오타나 중복 같은 실수가 발생하기 쉬운데, 팩토리 패턴을 사용하면 이를 방지할 수 있습니다.
또한, 모든 키를 중앙에서 정의하므로 invalidateQueries 시 정확한 키를 참조할 수 있고, 동적 키를 함수 형태로 정의하면 자동완성과 타입 추론도 가능해 개발 생산성과 유지보수성이 높아집니다.
export const queryKeys = {
challeges: ['challenges', 'list'],
milestones: () => [...queryKeys.challeges, 'done'] as const,
challenge_detail: (id: string) => ['challenge', id] as const,
stickers: ['stickers'],
profile: ['profile'],
};
[ Server action ]
'use server'
/* 2. 챌린지 스티커 붙이기 */
export async function addStickerToChallenge(goalId: number, sticker: string) {
try {
const supabase = createClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) throw new Error('유저 없음');
const { data, error } = await supabase
.from('challenges')
.select('completed_days, period')
.eq('id', goalId)
.eq('user_id', user.id)
.single();
if (error) throw new Error(`챌린지 데이터 가져오기 실패: ${error.message}`);
if (!data) throw new Error('챌린지 데이터가 존재하지 않습니다.');
const [{ error: insertError }, { error: updateError }] = await Promise.all([
supabase.from('progress').insert({ challenge_id: goalId, sticker_img: sticker }),
supabase
.from('challenges')
.update({
last_updated: dayjs().format('YYYY-MM-DD'),
completed_days: (data['completed_days'] + 1) as number,
end_day: data['completed_days'] + 1 === data['period'] ? dayjs().format('YYYY-MM-DD') : null,
is_completed: data['completed_days'] + 1 === data['period'],
})
.eq('id', goalId),
]);
if (insertError) throw new Error(`진행 상태 삽입 실패: ${insertError.message}`);
if (updateError) throw new Error(`챌린지 업데이트 실패: ${updateError.message}`);
return { success: true };
} catch (e) {
console.error('스티커 등록 실패:', e);
return { success: false };
}
}
[ Query ]
const useChallenges = () => {
return useQuery({ queryKey: queryKeys.challeges, queryFn: async () => await fetchChallenges() });
};
[ Mutation ]
const useAddSticker = () => {
const { toast } = useToast();
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ goalId, selectedSticker }: { goalId: string; selectedSticker: string }) =>
addStickerToChallenge(Number(goalId), selectedSticker),
onSuccess: (data, variables) => {
if (data.success) {
queryClient.invalidateQueries({ queryKey: queryKeys.challenge_detail(variables.goalId) });
queryClient.invalidateQueries({ queryKey: queryKeys.challeges });
}
},
onError: () =>
toast({
title: '스티커 추가 실패',
description: '문제가 발생했습니다. 다시 시도해주세요.',
variant: 'destructive',
}),
});
};
참고자료
'use server' 지시어 – React
The library for web and native user interfaces
ko.react.dev
Data Fetching: Fetching, Caching, and Revalidating | Next.js
Learn how to fetch, cache, and revalidate data in your Next.js application.
nextjs.org