10일차부터 구현했던 supabase 백엔드 연동을 위한 함수들에 대한 내용이다.
확장된 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 2
는 client
의 쿠키를 받지 못하는 문제가 있다. 이 때에 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
로 어떠한 콜백 함수를 감싸면 해당 콜백 함수는 서버 액션이 된다.