THE DEVLOG

scribbly.

Next.js 블로그 프로젝트

2025.05.21 12:17:51

10일차부터 구현했던 supabase 백엔드 연동을 위한 함수들에 대한 내용이다.

main/utils/nextCache.ts

확장된 fetch

Web API에서 사용할 수 있는 fetch에서 Next.js의 서버사이드 렌더링에 사용할 수 있는 options 객체가 추가된 것이 next/fetch이다.

기능설명
캐싱 제어next: { revalidate } 옵션으로 SSG/ISR 캐싱
태그 캐시next: { tags: ['foo'] }로 특정 태그 단위로 캐싱
서버에서만 동작확장된 기능은 Server Components 또는 Route Handler에서만 사용 가능
CDN 캐싱과 연계Vercel에서 자동으로 Edge 캐시 연동

CDN 캐싱은 이 next/fetch의 옵션들을 기반으로 이루어진다.

// 서버 컴포넌트 or route handler에서
const data = await fetch('https://api.example.com/posts', {
  next: {
    revalidate: 60,      // 60초 후에 ISR 재검증
    tags: ['posts'],     // revalidateTag('posts')로 무효화 가능
  },
});

 

Tag를 기반으로 revalidate할 때에는 아래와 같다.

import { revalidateTag } from 'next/cache';

revalidateTag('posts'); // 해당 태그의 fetch 캐시 강제 갱신

 

Tag 팩토리

TanStack Query에서 자주 사용하던 Query Key Factory 패턴을 캐시 태그를 관리하는 패턴으로 아래와 같이 이용하였다.

export const CACHE_TAGS = {
  CATEGORY: { ALL: () => "categories" },
  SUBCATEGORY: {
    ALL: () => "subcategories",
    BY_CATEGORY_ID: (categoryId: string = "") =>
      "subcategories:by_category:" + categoryId,
    BY_RECOMMENDED: () => "subcategories:by_recommended",
    BY_URL_SLUG: (urlSlug: string = "") => "subcategory:by_url_slug:" + urlSlug,
  },
  AI_SUMMARY: {
    BY_POST_ID: (postId: string = "") => "ai_summary:by_post:" + postId,
  },
  POST: {
    ALL: () => "posts",
    BY_PAGE: (page: number) => "posts:by_page:" + page.toLocaleString(),
    BY_URL_SLUG: (urlSlug: string = "") => "post:by_url_slug:" + urlSlug,
    BY_SUBCATEGORY_ID: (subcategoryId: string = "") =>
      "posts:by_subcategory_id:" + subcategoryId,
  },
  SIDEBAR: {
    CATEGORY: () => "sidebar:category",
    SELECTED_BY_URL_SLUG: (urlSlug: string = "") =>
      "sidebar:selected:" + urlSlug,
  },
  CLUSTER: {
    ALL: () => "cluster",
    BY_ID: (clusterId: string) => "cluster:by_id:" + clusterId,
  },
} as const;

태그를 팩토리 패턴으로 관리하여야 fetch와 revalidateTag에서 동일한 태그로 캐싱하고 revalidate 할 수 있다.

endpoint 팩토리

Next.js의 라우트 핸들러의 단점 중 하나가 intelisense 적용이 잘 안된다는 것이다.

그래서 Tag 뿐 아니라 fetch 함수가 호출하는 url 주소도 아래와 같이 팩토리 형식으로 관리하며 주소가 자동완성이 될 수 있도록 하였다.

export const ENDPOINT = {
  map: {
    clusterData: "/api/map/clusters",
    clusterSimData: "/api/map/similarities",
    clusterWithPostsById: "/api/map/clusters/posts",
  },
  posts: {
    search: "/api/posts/search",
    byUrlSlug: "/api/posts/slug",
  },
  series: {
    list: "/api/series/list",
    postsBySeriesId: "/api/series/posts",
    seriesByUrlSlug: "/api/series",
  },
  categories: {
    list: "/api/categories",
  },
  ai: {
    summaryByPostId: "/api/ai/summary",
    recommendedByPostId: "/api/ai/recommended",
  },
  sidebar: {
    category: "/api/sidebar/categories",
    posts: "/api/sidebar/posts",
  },
};

  const fullUrl = `${process.env.NEXT_PUBLIC_BASE_URL}${endpoint}${
    query ? `?${query}` : ""
  }`;

 

Fetch Wrapper 함수

확장된 fetch를 그대로 사용하지 않고 조금 더 추상화하여 사용하기 위해 fetchWithCache라는 랩퍼 함수를 만들었다.

export const getRecommendedListByPostId = async (postId: string) => {
  return fetchWithCache<
    PostgrestResponse<
      Database["public"]["Views"]["post_similarities_with_target_info"]["Row"]
    >
  >({
    endpoint: ENDPOINT.ai.recommendedByPostId,
    params: { post_id: postId },
    tags: [CACHE_TAGS.AI_SUMMARY.BY_POST_ID(postId)],
    revalidate: 60 * 60 * 24 * 30,
  });
};

위와 같이 객체 형식으로 params와 tag, 주소 등을 넘겨주는 방식이다.

fetchWithCache의 파라미터는 아래와 같은 타입이다.

type FetchFromApiOptions = {
  endpoint: string;
  params?: QueryParams;
  revalidate?: number;
  tags?: string[];
  skipCache?: boolean;
  cookieStore?: ReadonlyRequestCookies;
};

params

params는 searchParams를 객체 형식으로 넘겨주기 위해 작성한 프로퍼티이다.

내부적으로 아래와 같이 ?key=value 형식으로 변환된다.

  const query = Object.entries(params ?? {})
    .filter(([, value]) => value !== undefined)
    .map(
      ([key, value]) =>
        `${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`
    )
    .join("&");

endpoint

endpoint와 params, 그리고 기본 url을 합쳐 get요청을 보낼 url이 만들어 진다.

  const fullUrl = `${process.env.NEXT_PUBLIC_BASE_URL}${endpoint}${
    query ? `?${query}` : ""
  }`;

revalidate, tags

이렇게 만들어진 url에 fetch 요청을 보내면서 revalidate와 tags를 next/fetch의 옵션에 넣는다.

    const res = await fetch(fullUrl, {
      headers,
      next: skipCache ? undefined : { revalidate, tags },
    });

    const result = await res.json();

제네릭

fetch로 받은 result의 응답값은 타입 추정이 되지 않는 문제가 있다.

제네릭을 이용하여 보완하였다.

export const fetchWithCache = async <T>(
  options: FetchFromApiOptions
): Promise<T>

skipCache, cookieStore

skipCache가 true일 때에는 캐시가 되지 않도록 한다. 비공개된 게시글 조회나 검색된 게시글 목록에서 해당 플래그를 true로 하는 식이다.

cookieStore는 서버에서 fetch함수를 호출할 때에 사용한다. 가령 client -> route handler 1 -> route handler 2와 같이 통신할 때에 route handler 2client의 쿠키를 받지 못하는 문제가 있다. 이 때에 route handler 1에서 클라이언트 쿠키를 수동으로 route handler 2로 넘겨주는 것이다. 

Server Action Wrapper 함수

Server Actions로 supabase를 호출해서 생성/수정/삭제를 시행한 경우 revalidateTag를 실행하게 된다.

supabase를 호출하는 로직서버 액션으로서의 로직을 시각적으로 분리하기 위해서 createWithInvalidation이라는 래퍼 함수를 만들었다.

const _createPost = async (
  payload: Omit<Database["public"]["Tables"]["posts"]["Insert"], "order">
): Promise<
  PostgrestSingleResponse<Database["public"]["Tables"]["posts"]["Row"]>
> => {
  const cookieStore = await cookies();
  const supabase = await createClient(cookieStore);
...

  // 새로운 게시글 생성
  const result = await supabase
    .from("posts")
    .insert({ ...payload }) // 자동 order 값 추가
    .select()
    .single();

  return result;
};

export const createPost = createWithInvalidation(
  _createPost,
  async (result) => {
    revalidateTag(
      CACHE_TAGS.POST.BY_SUBCATEGORY_ID(result.data?.subcategory_id)
    );
    revalidateTag(CACHE_TAGS.POST.BY_URL_SLUG(result.data?.url_slug));
    revalidateTag(CACHE_TAGS.POST.ALL());
  }
);
export const createWithInvalidation = <T, A extends unknown[]>(
  fn: (...args: A) => Promise<T>,
  invalidateFn: (result: T) => void
): ((...args: A) => Promise<T>) => {
  return async (...args: A) => {
    const result = await fn(...args);
    invalidateFn(result);
    return result;
  };
};

nextCache.ts파일 자체가 use server지시자로 구성되어 있는 파일이기에 createWithInvalidation로 어떠한 콜백 함수를 감싸면 해당 콜백 함수는 서버 액션이 된다.