Fetch API JWT 관리 Interceptor 구현

JT·2025년 4월 11일
post-thumbnail

Axios의 Interceptor 기능을 활용하면 JWT 토큰을 쉽게 관리할 수 있습니다. 하지만 fetch에는 interceptor 기능이 없습니다.
Axios의 Interceptor 기능과 유사한 방식으로 JWT 토큰을 관리하는 Fetch API를 구현하려고합니다.


Axios와 Fetch API 차이점

먼저 구현하기전 Axios와 Fetch API의 차이점에 간단하게 살펴보겠습니다.

✅ Axios vs Fetch API

항목AxiosFetch 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 토큰 처리

아래는 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로 JWT 토큰 처리 Interceptor 구현

1. baseURL과 headers를 설정해주는 fetch 래퍼 함수

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);
  };
};

2. JWT 토큰 처리 Interceptor 구현

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;
};

3. 래퍼 fetch 함수에 fetchWithToken 추가

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);
  };
};

4. 최종코드

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에 적용하는 것만으로도 충분한 해결책이 될 수 있습니다.

profile
함께 개선하는 프론트엔드 개발자

0개의 댓글