기존 프로젝트에서는 공통 설정과 토큰 재발급을 간편하게 처리하기 위해 Axios를 사용하고 있었습니다. 요청 interceptor에서 토큰 만료 여부를 확인하고, 필요한 경우 토큰을 재발급한 뒤 실패했던 요청을 다시 보내는 방식입니다.
이 구조를 계속 사용해도 큰 문제는 없었습니다. 다만 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});89{10 cache:"force-cache",// Data Cache에 저장하고 재사용11 cache:"no-store"// 캐시 사용 x12 next:{13 tags:["posts"],// 태그를 통해 연결14 revalidate:false,// 장기간 캐시15 revalidate:0,// 캐시하지 않음16 revalidate:3600,// 3,600초마다 재검증17}18}1920// 게시글 수정 시 posts 태그와 연결된 데이터만 갱신21import{ revalidateTag }from"next/cache";2223revalidateTag("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,3headers:{4"Content-Type":"application/json",5},6});
fetch에서는 이런 처리를 직접 구성해야 합니다. 요청할 때마다 URL을 만들어야 하고, 400이나 500 응답도 정상적으로 완료된 Promise를 반환하기 때문에 response.ok도 직접 확인해야 합니다. 응답을 사용할 때도 형식에 따라 json(), text(), blob() 등을 호출해야 합니다.
이 작업을 API 함수마다 반복하고 싶지는 않았기 때문에 공통 request 함수를 만들었습니다.
request에서 발생한 오류가 직접 구현한 ApiError인지 먼저 확인하고, 상태 코드와 서버에서 전달한 메시지를 함께 비교해 토큰 만료 여부를 판단했습니다.
토큰 만료가 아니거나 이미 한 번 재시도한 요청이라면 재발급하지 않고 기존 오류를 그대로 전달합니다.
서버와 클라이언트의 토큰 재발급
여기서 조금 까다로웠던 부분은 실행 환경에 따라 토큰을 가져오고 전달하는 방식이 달라진다는 점이었습니다.
클라이언트에서는 브라우저가 refresh token 쿠키를 함께 전송하도록 credentials: "include"를 설정하면 됩니다. 재발급에 성공하면 브라우저에 저장된 새로운 access token을 다시 가져옵니다.
서버에서는 같은 방법을 사용할 수 없습니다.
1constgetServerToken=async(name: TokenName)=>{2const cookies =awaitimport("next/headers").then((module)=> module.cookies());34return cookies.get(name)?.value;5};67constgetToken=(name: TokenName)=>{8...910returnPromise.resolve(getServerToken(name));11};1213// 서버 사이드라면14if(IS_SERVER){15const refreshToken =awaitgetToken("refresh-token");1617if(!refreshToken){18return;19}2021// 가져온 refresh token을 헤더에 직접 저장22 headers.set("Cookie",`refresh-token=${refreshToken}`);23}
서버 사이드에서는 credentials: "include"를 설정해도 현재 요청의 쿠키가 자동으로 전달되지 않습니다. next/headers의 cookies()를 이용해 refresh token을 읽은 뒤 Cookie 헤더에 직접 넣어줬습니다.
이 코드는 클라이언트와 서버 양쪽에서 사용하기 때문에 next/headers의 cookies를 바로 import하면 문제가 발생합니다.
서버에서 실행될 때만 await import("next/headers")로 모듈을 불러온 뒤 cookies()를 실행하도록 처리했습니다.
재발급에 성공한 뒤에도 환경에 따른 차이가 있습니다. 서버에서는 브라우저처럼 Set-Cookie가 자동으로 반영되지 않기 때문에 응답의 Set-Cookie 헤더에서 새로운 access token을 가져옵니다. 이 토큰은 실패했던 요청을 다시 보낼 때 바로 사용합니다.
재귀 호출을 이용한 요청 재시도
새로운 access token을 가져왔다면 기존 path와 options를 그대로 사용해 authRequest를 다시 호출합니다.
처음에는 Axios를 fetch로 바꾸는 정도의 작업이라고 생각했습니다. 막상 시작해보니 단순히 요청 라이브러리 하나를 제거하는 것으로 끝나지는 않았습니다.
Axios interceptor 안에 묶여 있던 역할을 하나씩 꺼내면서 공통 요청과 인증 요청의 경계를 다시 정리해야 했고, 서버와 클라이언트에서 쿠키를 다루는 방식이 어떻게 다른지도 다시 확인하게 됐습니다.
결과적으로 Axios 의존성을 제거했고, API 요청 구조도 Next.js에서 제공하는 기능을 활용하기 편한 형태로 바꿀 수 있었습니다. 아직 캐싱이나 재검증 정책까지 적용하지는 않았지만, 필요할 때 기존 구조를 크게 건드리지 않고 적용할 수 있는 정도의 기반은 만들어졌다고 생각합니다.
이번 작업은 AI와 함께 진행한 첫 리팩토링이기도 했습니다. 반복되는 API 코드를 옮기거나 테스트할 항목을 정리할 때는 확실히 편했습니다. 리팩토링을 진행하며 학습을 목표로 했기 때문에 AI에게 직접적으로 코드를 물어보지 않고 해당 방향이 맞는지, 구현한 코드는 어떤지를 중점적으로 사용했습니다.
덕분에 Axios를 fetch로 옮기는 것보다 기존 API 요청 구조를 다시 살펴보는 데 더 많은 시간을 썼습니다. 그래도 테스트를 추가하면서 기존 동작을 하나씩 확인할 수 있었고, 앞으로 요청 로직을 수정할 때도 이전보다는 부담이 줄 것 같아 꽤 만족스러운 시간이었습니다.