본문 바로가기

👩‍💻 Project

프로젝트에서 Route Handler 안 쓰고 Server Action만 쓴 이유 (feat. Supabase)

Next.js로 모하루(개인 프로젝트)를 진행하면서, 모든 서버 통신은 Server Action을 이용해 구현했습니다.

Route HandlerServer Action 중에서 Server Action을 선택한 이유는

  1. 비즈니스 로직을 클라이언트로부터 분리하고, 보안을 강화하기 위해서
    Server Action은 서버 환경에서만 실행되기 때문에, 민감한 로직이나 API 키, 인증 정보 등을 클라이언트에 노출시키지 않고 안전하게 처리할 수 있습니다.
  2. Supabase를 백엔드로 사용했기 때문입니다.
    Supabase에서 제공하는 메서드들은 fetch로 직접 호출하는 방식이 아니라, 서버 환경에서 동작하는 클라이언트를 통해 호출해야 하기 때문에, Server Action을 활용하는 것이 더 적합했습니다.
  3. 모든 서버 통신이 사용자의 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