잘가 Axios

기존 프로젝트에서는 공통 설정과 토큰 재발급을 간편하게 처리하기 위해 Axios를 사용하고 있었습니다. 요청 interceptor에서 토큰 만료 여부를 확인하고, 필요한 경우 토큰을 재발급한 뒤 실패했던 요청을 다시 보내는 방식입니다.
post_image
이 구조를 계속 사용해도 큰 문제는 없었습니다. 다만 App Router를 사용하면서 서버와 클라이언트의 쿠키, 헤더 처리 방식이 달라졌고 Next.js가 fetch에 추가한 캐싱 기능을 Axios에서는 활용하기 어렵다는 점이 아쉬웠습니다.
마침 API 요청부를 정리할 필요도 있었기 때문에 기존 Axios 로직을 fetch 기반으로 다시 구현해보기로 했습니다. Next.js 환경에 맞게 요청 구조를 정리하고, 이후 캐싱이나 재검증 같은 기능도 필요할 때 적용할 수 있도록 만드는 것이 목표였습니다.
이번 글에서는 Next.js의 fetch를 간단하게 살펴본 뒤, interceptor가 담당하던 토큰 재발급과 요청 재시도를 어떻게 옮겼는지 정리해보겠습니다. 요청 방식을 바꾸면서 기존 API의 동작이 달라지지 않았는지 확인하기 위해 작성한 테스트도 함께 살펴봅니다.

어서와 Fetch

fetch는 브라우저와 Node.js 환경에서 사용할 수 있는 표준 Web API입니다. HTTP 요청을 보낼 수 있으며 Request, Response, Headers와 같은 웹 표준 인터페이스를 사용합니다.
Next.js는 서버 환경에서 이 fetch를 확장해 요청별 캐시 정책이나 재검증 방식을 설정할 수 있도록 지원합니다.
1fetch("/api/posts", {
2  cache: "force-cache",
3  next: {
4    revalidate: 3600,
5    tags: ["posts"],
6  },
7});
8
9{
10  cache: "force-cache", // Data Cache에 저장하고 재사용
11  cache: "no-store" // 캐시 사용 x
12  next: {
13    tags: ["posts"], // 태그를 통해 연결
14    revalidate: false, // 장기간 캐시
15    revalidate: 0,     // 캐시하지 않음
16    revalidate: 3600,  // 3,600초마다 재검증
17  }
18}
19
20// 게시글 수정 시 posts 태그와 연결된 데이터만 갱신
21import { revalidateTag } from "next/cache";
22
23revalidateTag("posts");
  • cache: "force-cache": 응답을 Next.js Data Cache에 저장하고 재사용
  • cache: "no-store": 캐시를 사용하지 않고 요청할 때마다 새로운 데이터 조회
    • "no-store" 사용 시 revalidate와 서로 충돌
  • next.revalidate: 캐시된 데이터를 다시 검증할 주기를 초 단위로 설정
  • next.tags: 캐시에 태그를 지정해 필요한 데이터만 무효화
cache로 요청 결과를 캐시할지 정하고, next.revalidate로 캐시를 다시 검증할 시점을 지정합니다. 여기에 next.tags를 사용하면 특정 데이터와 관련된 캐시만 골라서 갱신하는 것도 가능합니다.
이번 리팩토링에서는 캐시 정책까지 변경하지 않았습니다.
우선 API 요청을 fetch 기반으로 전환하고, 캐싱과 재검증은 이후 각 요청의 특성에 맞게 적용할 예정입니다.

공통 request 구성하기

Axios에서는 인스턴스를 만들면서 baseURL이나 공통 헤더를 한 번에 설정할 수 있었습니다.
1const api = axios.create({
2  baseURL,
3  headers: {
4    "Content-Type": "application/json",
5  },
6});
fetch에서는 이런 처리를 직접 구성해야 합니다. 요청할 때마다 URL을 만들어야 하고, 400이나 500 응답도 정상적으로 완료된 Promise를 반환하기 때문에 response.ok도 직접 확인해야 합니다. 응답을 사용할 때도 형식에 따라 json(), text(), blob() 등을 호출해야 합니다.
이 작업을 API 함수마다 반복하고 싶지는 않았기 때문에 공통 request 함수를 만들었습니다.
1const request = async <T = unknown>(
2  path: string,
3  { responseType = "json", ...options }: RequestOptions = {},
4) => {
5  const url = getUrl(path);
6
7  const headers = await createRequestHeaders(options.headers);
8
9  if (options.body instanceof FormData) {
10    headers.delete("Content-Type");
11  }
12
13  const response = await fetch(url, {
14    ...options,
15    headers,
16  });
17
18  if (!response.ok) {
19    const data = await response.json();
20
21    throw new ApiError(data.message ?? response.statusText, response.status);
22  }
23
24  return getResponseBody<T>(response, responseType);
25};
getUrl은 환경 변수에 등록된 API 주소와 전달받은 경로를 조합합니다. API 주소가 설정되어 있는지 확인하고, 전달받은 경로가 하나의 /로 시작하도록 정리하는 작업도 여기에서 처리합니다.
createRequestHeaders는 기본 헤더와 요청에서 전달받은 헤더를 합칩니다. 서버에서 요청할 때는 next/headers를 이용해 요청 IP를 가져와 함께 전달합니다.
각 API 함수에서는 필요한 경로와 옵션만 넘겨주면 됩니다.
1const getPost = async (nid: number) => {
2  return request<IPost>(`/posts/${nid}`, {
3    method: "GET",
4  });
5};
6
7const createPost = async (data: ICreatePostInput) => {
8  return request<IPost>("/posts", {
9    method: "POST",
10    body: JSON.stringify(data),
11  });
12};
URL 생성부터 기본 헤더 설정, 오류 처리, 응답 변환까지 request에서 처리하면서 각 API 함수에는 실제 요청에 필요한 내용만 남길 수 있었습니다. 기존 API 함수의 반환 타입도 그대로 유지했습니다.

토큰 재발급은요..?

request만으로 게시글 조회 같은 공개 API는 처리할 수 있었습니다. 문제는 인증이 필요한 요청이었습니다.
인증 요청에서는 Authorization 헤더를 추가해야 하고, 토큰이 만료됐다면 재발급한 뒤 기존 요청을 다시 보내야 합니다. 기존 Axios 구조에서는 이 과정이 interceptor 하나에 들어 있었습니다.
처음에는 이 로직을 그대로 request에 넣는 방법도 생각했습니다. 하지만 그렇게 하면 인증이 필요하지 않은 요청도 토큰이나 재발급 로직을 알아야 합니다.
공통 요청과 인증 요청의 역할을 나누기로 했습니다. 기본 헤더 설정과 응답 변환, 오류 처리는 request에 남겨두고 access token 설정과 토큰 만료 확인, 재발급, 요청 재시도는 별도의 authRequest에서 처리합니다.

authRequest를 통한 토큰 재발급

authRequest는 앞에서 만든 request를 감싸는 함수입니다. 인증 요청에 필요한 access token을 설정하고, 토큰이 만료됐을 때 재발급을 시도합니다.
먼저 쿠키에서 access token을 가져와 Authorization 헤더에 추가한 뒤 request를 실행합니다.
1const authRequest = async <T = unknown>(
2  path: string,
3  options: RequestOptions = {},
4  retry = true,
5  accessToken?: string,
6) => {
7  const token = accessToken ?? (await getToken("access-token"));
8
9  const headers = new Headers(options.headers);
10
11  if (token) {
12    headers.set("Authorization", `Bearer ${token}`);
13  }
14
15  try {
16    return await request<T>(path, {
17      ...options,
18      headers,
19    });
20  } catch (error) {
21    // 토큰 만료 처리
22  }
23};

토큰 만료 확인

401 응답이 항상 토큰 만료를 의미하지는 않습니다. 권한이 없거나 올바르지 않은 토큰을 사용했을 때도 401을 받을 수 있습니다.
1const isExpiredToken =
2  error instanceof ApiError && error.status === 401 && error.message === AUTH_ERROR.EXPIRED_TOKEN;
3
4if (!retry || !isExpiredToken) {
5  throw error;
6}
request에서 발생한 오류가 직접 구현한 ApiError인지 먼저 확인하고, 상태 코드와 서버에서 전달한 메시지를 함께 비교해 토큰 만료 여부를 판단했습니다.
토큰 만료가 아니거나 이미 한 번 재시도한 요청이라면 재발급하지 않고 기존 오류를 그대로 전달합니다.

서버와 클라이언트의 토큰 재발급

여기서 조금 까다로웠던 부분은 실행 환경에 따라 토큰을 가져오고 전달하는 방식이 달라진다는 점이었습니다.
클라이언트에서는 브라우저가 refresh token 쿠키를 함께 전송하도록 credentials: "include"를 설정하면 됩니다. 재발급에 성공하면 브라우저에 저장된 새로운 access token을 다시 가져옵니다.
서버에서는 같은 방법을 사용할 수 없습니다.
1const getServerToken = async (name: TokenName) => {
2  const cookies = await import("next/headers").then((module) => module.cookies());
3
4  return cookies.get(name)?.value;
5};
6
7const getToken = (name: TokenName) => {
8  ...
9
10  return Promise.resolve(getServerToken(name));
11};
12
13// 서버 사이드라면
14if (IS_SERVER) {
15  const refreshToken = await getToken("refresh-token");
16
17  if (!refreshToken) {
18    return;
19  }
20
21  // 가져온 refresh token을 헤더에 직접 저장
22  headers.set("Cookie", `refresh-token=${refreshToken}`);
23}
서버 사이드에서는 credentials: "include"를 설정해도 현재 요청의 쿠키가 자동으로 전달되지 않습니다. next/headerscookies()를 이용해 refresh token을 읽은 뒤 Cookie 헤더에 직접 넣어줬습니다.
이 코드는 클라이언트와 서버 양쪽에서 사용하기 때문에 next/headerscookies를 바로 import하면 문제가 발생합니다.
서버에서 실행될 때만 await import("next/headers")로 모듈을 불러온 뒤 cookies()를 실행하도록 처리했습니다.
재발급에 성공한 뒤에도 환경에 따른 차이가 있습니다. 서버에서는 브라우저처럼 Set-Cookie가 자동으로 반영되지 않기 때문에 응답의 Set-Cookie 헤더에서 새로운 access token을 가져옵니다. 이 토큰은 실패했던 요청을 다시 보낼 때 바로 사용합니다.

재귀 호출을 이용한 요청 재시도

새로운 access token을 가져왔다면 기존 pathoptions를 그대로 사용해 authRequest를 다시 호출합니다.
1const refreshedToken = await refreshAccessToken();
2
3if (!refreshedToken) {
4  throw error;
5}
6
7return authRequest<T>(path, options, false, refreshedToken);
이때 갱신된 토큰을 직접 넘겨주고 retryfalse로 변경합니다. 다시 보낸 요청에서도 토큰 만료 오류가 발생한다면 더 이상 재발급하지 않고 오류를 그대로 전달합니다.
Axios에서 사용했던 _retry 속성이나 interceptor는 없어졌지만 흐름 자체는 크게 달라지지 않았습니다. 토큰이 만료되면 한 번 재발급하고, 새 토큰으로 기존 요청을 다시 실행합니다. retry는 이 과정이 반복되는 것을 막는 역할을 합니다.

한눈에 보기

post_image

테스트

이번 리팩토링에서 가장 신경 쓴 부분은 요청 방식을 바꾸더라도 기존 API의 동작은 달라지지 않아야 한다는 점이었습니다.
API 호출부를 수정하지 않고 내부 구현만 Axios에서 fetch로 바꾸는 작업이었기 때문에 각 API 함수가 기존과 같은 경로와 HTTP method, query parameter, request body를 사용하는지 확인했습니다.
반복되는 CRUD API는 기존 요청 계약이 유지되는지 위주로 확인했습니다. 대신 공통 요청을 담당하는 request와 인증 흐름이 들어 있는 authRequest는 조금 더 세분화해서 테스트했습니다.
  • request
    • 기본 헤더와 요청 옵션 전달
    • JSON과 text 응답 변환
    • FormData 요청의 Content-Type 처리
    • 실패 응답의 상태 코드와 메시지 처리
  • authRequest
    • access token을 Authorization 헤더에 추가
    • 토큰 만료 감지와 재발급 요청
    • 갱신된 토큰을 사용한 기존 요청 재시도
    • 재발급 실패 시 추가 재시도 방지
    • 서버와 클라이언트 환경에 따른 쿠키 처리
자세한 테스트 코드는 GitHub의 API 테스트 디렉터리에서 확인할 수 있습니다.

마치며

처음에는 Axios를 fetch로 바꾸는 정도의 작업이라고 생각했습니다. 막상 시작해보니 단순히 요청 라이브러리 하나를 제거하는 것으로 끝나지는 않았습니다.
Axios interceptor 안에 묶여 있던 역할을 하나씩 꺼내면서 공통 요청과 인증 요청의 경계를 다시 정리해야 했고, 서버와 클라이언트에서 쿠키를 다루는 방식이 어떻게 다른지도 다시 확인하게 됐습니다.
결과적으로 Axios 의존성을 제거했고, API 요청 구조도 Next.js에서 제공하는 기능을 활용하기 편한 형태로 바꿀 수 있었습니다. 아직 캐싱이나 재검증 정책까지 적용하지는 않았지만, 필요할 때 기존 구조를 크게 건드리지 않고 적용할 수 있는 정도의 기반은 만들어졌다고 생각합니다.
이번 작업은 AI와 함께 진행한 첫 리팩토링이기도 했습니다. 반복되는 API 코드를 옮기거나 테스트할 항목을 정리할 때는 확실히 편했습니다. 리팩토링을 진행하며 학습을 목표로 했기 때문에 AI에게 직접적으로 코드를 물어보지 않고 해당 방향이 맞는지, 구현한 코드는 어떤지를 중점적으로 사용했습니다.
덕분에 Axios를 fetch로 옮기는 것보다 기존 API 요청 구조를 다시 살펴보는 데 더 많은 시간을 썼습니다. 그래도 테스트를 추가하면서 기존 동작을 하나씩 확인할 수 있었고, 앞으로 요청 로직을 수정할 때도 이전보다는 부담이 줄 것 같아 꽤 만족스러운 시간이었습니다.