프로젝트를 진행하면서 매번 API 호출을 작성할 때 반복되는 부분을 customFetch함수로 구현했습니다.
credentials, BASE_URL, JSON 파싱, 에러 메시지 처리 같은 공통 로직을 중복해서 쓰다 보면 유지보수가 어렵고 비효율적인 구조가 되기 때문에 customFetch라는 fetch 래퍼 함수를 만들었습니다.
이 글에서는 그 배경과 구조, 그리고 활용 방식을 정리해보겠습니다.
1. customFetch 적용 배경
이전 프로젝트를 진행할때 다음과 같은 코드들이 반복되어 불편함이 있었습니다. 따라서 이번 프로젝트에서는 처음 설정단계에 미리 구현을 하고 모든 팀원이 활용해 이런 일을 피하도록 했습니다.
// 매번 반복되는 코드들
const response = await fetch(`${process.env.NEXT_PUBLIC_BASE_URL}/api/user`, {
credentials: 'include',
headers: {
'Content-Type': 'application/json',
},
});
if (!response.ok) {
throw new Error('Something went wrong');
}
const data = await response.json();
- 반복되는 기본 설정: 모든 요청마다 credentials: 'include', 헤더 설정 반복
- 산발적 에러 핸들링: 각 컴포넌트마다 다른 방식의 에러 처리
- BASE_URL 중복 작성: 환경변수를 매번 수동으로 조합
- JSON 파싱 로직의 반복: .json() 호출과 예외 처리가 곳곳에 산재
우선 모든 요청에 쿠키 자동 포함, 에러 처리 통일, JSON 파싱 옵션화, FormData 예외 처리를 포함한 함수를 만들고자 했습니다.
2. customFetch 구조
핵심 구현은 다음과 같습니다
- 모든 API 요청에 credentials: 'include'가 자동 적용되어 HttpOnly 쿠키를 보냅니다.
- BASE_URL 자동 prepend (env 설정 필요)
- status code 에 따른 에러 메시지를 통일적으로 처리합니다.
- 응답은 기본적으로 .json()으로 파싱되며, 필요 시 skipJsonParse 옵션으로 원본 Response도 받을 수 있습니다.
const BASE_URL = process.env.NEXT_PUBLIC_BASE_URL;
const ERROR_MESSAGES = {
401: "인증이 필요합니다.",
403: "접근이 거부되었습니다.",
429: "잠시 후 다시 시도해주세요.",
default: "처리 중 오류가 발생했습니다.",
} as const;
export async function customFetch<T = unknown>(
input: RequestInfo,
init?: RequestInit & { skipJsonParse?: boolean },
): Promise<T> {
const url =
typeof input === "string" && !input.startsWith("http")
? `${BASE_URL}${input}`
: input;
const headers: Record<string, string> = {};
if (!(init?.body instanceof FormData)) {
headers["Content-Type"] = "application/json";
}
const res = await fetch(url, {
...init,
credentials: "include",
headers: {
...headers,
...(init?.headers || {}),
},
});
if (!res.ok) {
const statusCode = res.status as keyof typeof ERROR_MESSAGES;
const errorMessage = ERROR_MESSAGES[statusCode] || ERROR_MESSAGES.default;
throw new Error(errorMessage);
}
if (init?.skipJsonParse) {
return res as unknown as T;
}
try {
return await res.json();
} catch {
throw new Error("응답 데이터를 처리할 수 없습니다.");
}
}
3. 서버와 클라이언트 데이터 패칭/캐싱 전략
개인화된 요청은 원격 서버에 캐싱 되어서는 안되기 때문에 fetch를 사용할 때 no-store 옵션을 사용하나, 이 경우 새로고침 및 라우트 캐시가 만료될 때마다 API 호출이 발생하므로 client-side에서 tanstack query를 사용해 브라우저 메모리에 개인화된 요청에 대한 응답을 캐싱 하고 queryKey와 staleTime으로 캐시를 관리합니다.
개인화 되지 않은 요청은 server-side에서 force-cache 옵션을 사용해 API를 호출하고 적절한 방법을 통해 갱신합니다.
| 상황/기능 | Server Component | fetchClient + TanStack Query |
| SEO 필요 | ✅ | ❌ |
| 비개인화 데이터 | ✅ (force-cache) | ❌ |
| 개인화 데이터 | ❌ | ✅ (캐싱, 상태관리) |
| 상호작용 많음 | ❌ | ✅ (mutation, optimistic) |
즉, 정리하면 다음과 같습니다.
- 인증이 필요한가? → customFetch 사용
- 개인화된 데이터인가? → Client에서 TanStack Query로 캐싱
- 상호작용이 많은가? → TanStack Query로 상태 관리
- SEO가 필요한가? → Server Component에서 초기 렌더링(fetch 사용)
4. 사용 예시
1) 서버 컴포넌트에서 순수 fetch
const notices = await fetch('/notices', { cache: 'force-cache' });
2) customFetch + TanStack Query (Client Component)
const mutation = useMutation({
mutationFn: (newPost) =>
customFetch('/posts', {
method: 'POST',
body: JSON.stringify(newPost),
}),
onSuccess: () => queryClient.invalidateQueries(['posts']),
});
3) customFetch + useMutation (Client Component)
const mutation = useMutation({
mutationFn: (newPost) =>
customFetch('/posts', {
method: 'POST',
body: JSON.stringify(newPost),
}),
onSuccess: () => queryClient.invalidateQueries(['posts']),
});
이후 SSR에서 fetch를 쓸때 에러가 나서 customFetch를 사용했는데, 그 과정에서 아래 코드가 추가되었습니다,, 자세한 내용은 이전 글을 참고해주세요.
// 서버 사이드 렌더링(SSR) 시 쿠키를 직접 헤더에 추가
if (typeof window === "undefined") {
const { cookies } = await import("next/headers");
const cookieStore = await cookies();
const cookieHeader = cookieStore.toString();
if (cookieHeader) {
headers.Cookie = cookieHeader;
}
}'FE' 카테고리의 다른 글
| [Next.js] SSR 전환 과정에서 인증과 중복 페칭 문제 (0) | 2025.08.03 |
|---|---|
| [FE] FSD 적용 전 고려해봐야 할 것들 (0) | 2024.07.07 |