
Axios의 Interceptor 기능을 활용하면 JWT 토큰을 쉽게 관리할 수 있습니다. 하지만 fetch에는 interceptor 기능이 없습니다.
Axios의 Interceptor 기능과 유사한 방식으로 JWT 토큰을 관리하는 Fetch API를 구현하려고합니다.
먼저 구현하기전 Axios와 Fetch API의 차이점에 간단하게 살펴보겠습니다.
| 항목 | Axios | Fetch API |
|---|---|---|
| 설치 여부 | 외부 라이브러리 설치 필요 (npm install axios) | 브라우저 내장, 추가 설치 불필요 |
| 자동 JSON 처리 | 요청 및 응답 데이터를 자동으로 JSON 변환 (response.data) | 응답 데이터를 .json()으로 수동 변환 필요 |
| 요청 취소 | AbortController 사용 가능 (CancelToken은 더 이상 권장되지 않음) | AbortController 사용 가능 |
| Interceptor 지원 | 요청과 응답의 흐름을 제어할 수 있는 Interceptor 기능 제공 | 기본적으로 지원하지 않음 (커스텀 래퍼 함수로 구현 가능) |
| 호환성 | 브라우저와 Node.js에서 모두 사용 가능 | 최신 Node.js(버전 18 이상) 및 브라우저에서 동작 |
| 에러 처리 | 상태 코드가 200-299 범위를 벗어나면 자동으로 catch로 이동 | 상태 코드 체크 필요 (response.ok 검사 후 throw 해야 함) |
| 기본 요청 방식 | axios.get(url, config) | fetch(url, options) |
| 타임아웃 설정 | timeout: 5000 옵션 제공 | setTimeout + AbortController로 구현 필요 |
| HTTP 오류(4xx, 5xx) 처리 | catch 블록에서 자동 감지 | response.ok를 직접 체크해야 함 |
| 네트워크 오류 처리 | catch에서 감지 가능 | catch에서 감지 가능 |
| 기능 확장성 | Interceptor, 요청 취소, 디폴트 설정 등 강력한 기능 제공 | 단순한 HTTP 요청에 적합하지만, 추가 기능은 커스텀 구현 필요 |
| 캐싱 및 ISR 지원 | ❌ ISR 미지원 | ✅ fetch만 Next.js ISR 지원 |
| 브라우저 기본 캐싱 지원 | ❌ 기본 캐싱 없음 | ✅ cache: "force-cache" 옵션 사용 가능 |
아래는 Axios Interceptor를 사용한 JWT 인증 처리 코드입니다.
import axios from "axios";
import { refreshToken } from "./api/token";
const BASE_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:3000";
// axios 인스턴스 생성
export const api = axios.create({
baseURL: BASE_URL,
});
// 요청 인터셉터
async (error: AxiosError) => {
const originalRequest = error.config;
if (isAxiosError<{ message:string }>(error)) {
// 토큰 만료 시 처리
if (
error.response?.status === 401 &&
error.response?.data.message.includes("expired token"
) {
try {
// 이전 요청의 쿠키를 그대로 사용
const cookies = originalRequest?.headers["Cookie"];
const response = await axios(`${BASE_URL}/api/auth/refresh-token`, {
headers: {
Cookie: cookies,
},
});
// 쿠키 갱신
const responseCookies = response.headers["set-cookie"];
if (responseCookies) {
originalRequest!.headers["Cookie"] = reposeCookies.join("; ");
}
// 기존 요청 다시 실행
if (originalRequest) return axios(originalRequest);
} catch (refreshError) {
if (isAxiosError<{ message: string }>(refreshError)) {
if (refreshError.response?.status === 401) {
if (typeof window !== "undefined") {
window.location.replace("/");
alert("세션이 만료되었습니다. 다시 로그인해주세요.");
}
}
return Promise.reject(refreshError);
}
}
}
}
return Promise.reject(error);
}
Fetch API는 기본적으로 baseURL과 headers 설정 기능이 없으므로, 이를 자동으로 적용하는 래퍼 함수를 생성합니다.
export const createFetch = (baseURL: string, defaultOptions?: RequestInit) => {
return async (url: string, withToken = false, options?: RequestInit) => {
const mergedOptions: RequestInit = {
...defaultOptions,
...options,
headers: {
...defaultOptions?.headers,
...options?.headers,
},
};
return fetch(new URL(url, baseURL).toString(), mergedOptions);
};
};
import { refreshToken } from "./api/token";
const fetchWithToken = async (
url: string,
options?: RequestInit
): Promise<Response> => {
// Fetch API 호출
const response = await fetch(url, {
...options,
headers: {
...options?.headers,
},
});
// 401 에러이면서 응답 메시지에 "expired token"이 포함된 경우만 실행
if (response.status === 401) {
const responseJson = await response.json().catch(() => ({}));
if (responseJson?.message?.includes("expired token")) {
try {
const refreshResponse = await fetch(
`${process.env.NEXT_PUBLIC_API_URL}/api/auth/refresh-token`,
{
method: "POST",
...options,
credentials: "include",
}
);
if (!refreshResponse.ok) {
throw refreshResponse;
}
const setCookieHeader = refreshResponse.headers.get("set-cookie") || "";
// 기존 요청 다시 실행
return fetchWithToken(url, {
...options,
headers: {
...options?.headers,
Cookie: setCookieHeader,
},
});
} catch (error) {
if (error instanceof Response) {
if (typeof window !== "undefined") {
if (error.status === 401) {
window.location.replace("/");
alert("세션이 만료되었습니다. 다시 로그인해주세요.");
}
}
}
throw error;
}
}
}
// 토큰 만료에러가 아닌 경우 reponse 객체 반환
return response;
};
API 요청에는 JWT 토큰이 필요한 요청과 필요하지 않은 요청이 있습니다.
이 둘을 구분하여 적절한 요청 방식을 선택할 수 있도록 래퍼 fetch 함수에 withToken 매개변수를 추가합니다.
export const createFetch = (baseURL: string, defaultOptions?: RequestInit) => {
return async (url: string, withToken = true, options?: RequestInit) => {
const fullURL = new URL(url, baseURL).toString();
const mergedOptions: RequestInit = {
...defaultOptions,
...options,
headers: {
...defaultOptions?.headers,
...options?.headers,
},
};
return withToken
? fetchWithToken(fullURL, mergedOptions)
: fetch(fullURL, mergedOptions);
};
};
import { refreshToken } from "./api/token";
const BASE_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:3000";
export const createFetch = (baseURL: string, defaultOptions?: RequestInit) => {
return async (url: string, withToken = false, options?: RequestInit) => {
const fullURL = new URL(url, baseURL).toString();
const mergedOptions: RequestInit = {
...defaultOptions,
...options,
headers: {
...defaultOptions?.headers,
...options?.headers,
},
};
return withToken
? fetchWithToken(fullURL, mergedOptions)
: fetch(fullURL, mergedOptions);
};
};
const fetchWithToken = async (
url: string,
options?: RequestInit
): Promise<Response> => {
// Fetch API 호출
const response = await fetch(url, {
...options,
headers: {
...options?.headers,
Authorization: accessToken ? `Bearer ${accessToken}` : "",
},
});
// 401 에러이면서 응답 메시지에 "expired token"이 포함된 경우만 실행
if (response.status === 401) {
const responseJson = await response.json().catch(() => ({}));
if (responseJson?.message?.includes("expired token")) {
try {
const refreshResponse = await fetch(
`${process.env.NEXT_PUBLIC_API_URL}/api/auth/refresh-token`,
{
method: "POST",
...options,
credentials: "include",
}
);
if (!refreshResponse.ok) {
throw refreshResponse;
}
const setCookieHeader = refreshResponse.headers.get("set-cookie") || "";
// 기존 요청 다시 실행
return fetchWithToken(url, {
...options,
headers: {
...options?.headers,
Cookie: setCookieHeader,
},
});
} catch (error) {
if (error instanceof Response) {
if (typeof window !== "undefined") {
if (error.status === 401) {
window.location.replace("/");
alert("세션이 만료되었습니다. 다시 로그인해주세요.");
}
}
}
throw error;
}
}
}
// 토큰 만료에러가 아닌 경우 reponse 객체 반환
return response;
};
export const customFetch = createFetch(BASE_URL, {
headers: { "Content-Type": "application/json" },
credentials: "include",
});
이번 구현을 통해 Axios의 Interceptor 기능과 유사한 방식으로 Fetch API를 활용하여 JWT 토큰을 관리하는 방법을 살펴보았습니다.
생각보다 어렵지 않게 구현할 수 있었으며, Axios Interceptor의 모든 기능을 완벽하게 대체할 필요 없이, JWT 토큰 관리 기능만을 Fetch API에 적용하는 것만으로도 충분한 해결책이 될 수 있습니다.