문제 상황
엑세스 토큰이 만료되고 리프레시 토큰을 통해 재발급을 받아 API를 재요청할 때, 요청은 성공했지만 최종적으로 데이터가 반환되지 않는 문제가 발생함.
기대 동작
실제 동작
- 권한 이유로 실패시 리프레시 토큰 재발급 로직 자동 호출 확인
- 실패한 API 요청 재시도해 성공 응답까지 확인
- API 요청이 성공했으나 데이터가 반환되지 않는 오류 발생
기존 로직
Nuxt 프로젝트에서는 인증, 에러 처리, 공통 헤더 세팅 등의 API 요청 관련 로직을 $fetch.create로 커스텀한 HTTP 클라이언트에 통합하고, 이를 Nuxt 플러그인으로 등록해 전역에서 사용하고 있었다.
해당 $fetch 인스턴스에는 onRequest, onResponse, onResponseError 인터셉터가 설정되어 있었고,
그 중 onResponseError에 다음과 같은 재시도 로직이 구현되어 있었다.
// 기존 로직
// HTTP 클라이언트 객체의 onResponseError 인터셉터에서 권한 로직 구현
(...)
async onResponseError ({ response, request, options }) {
// API 에러 처리하기
if (에러 원인이 권한 문제일 경우) {
try {
// 1. 엑세스 토큰 리프레시 로직 실행
const 새로운토큰 = await 토큰재발급받기();
// 2. 실패한 API 재요청하기
return await $fetch(request, {
...options,
headers: {
...options.headers,
Authorization: 새로운토큰 // 헤더에 새로 발급된 토큰 세팅하기
}
});
} catch (e) {
await logOut();
}
}
},이 방식의 한계
onResponseError가 내부에서 return해도 호출자(useFetch, 등)는 이를 직접 기다리지 않음$fetch 요청이 성공해도 그 결과가 최초 호출자에게 반환되지 않음원인 분석
$fetch.create의 onResponseError 내부에서 재시도 요청을 수행해도, 그 결과는 Nuxt의 useFetch 등 호출자에게 연결되지 않는다.
onResponseError는 본래 후속 처리를 위한 훅이며, 비동기 흐름을 제어하는 데 적합하지 않음$fetch()를 재요청해도, 그 Promise는 호출 스택으로 전달되지 않음useFetch()나 useGET() 등에서 data가 null 또는 undefined로 남는 문제가 발생시도한 방법
1. Proxy를 활용한 커스텀 API 클라이언트 구현
이 문제를 해결하기 위해 Proxy를 사용하여 $fetch 호출 자체를 감싸고, 실패했을 경우 토큰 갱신 후 재요청하는 로직을 직접 삽입하는 방식으로 전환했다.
// api = $fetch.create로 만들어진 HTTP 클라이언트 객체
const authFetch = new Proxy(api, {
apply: async (target, thisArg, argArray) => {
const [request, options = {}] = argArray;
const requestOptions = {
...options,
headers: {
...options.headers,
Authorization: 기존토큰,
},
};
try {
// 1차 요청
return await target(request, requestOptions);
} catch (error: any) {
if (권한 오류라면) {
try {
// 토큰 갱신 시도
const 새토큰 = await 토큰재발급받기();
requestOptions.headers.Authorization = 새토큰;
// 재시도 요청
return await target(request, requestOptions);
} catch (refreshError) {
await logOut();
}
}
throw createError({
statusCode: 401,
statusMessage: '토큰 재발급 실패',
});
}
},
});코드 상세 설명 ▼
Proxy란?
코드 설명
1. api 객체를 감싼 프록시 객체 생성
const authFetch = new Proxy(api, {});2.핸들러에 apply 메서드 정의
apply: async (target, thisArg, argArray) => {}
// target: fetch.create로 만들어진 api 함수 객체
// thisArg: api 객체의 this
// argArray: api를 호출할때 전달된 매개변수 목록 배열3. api 요청 전 헤더 세팅
const [request, options = {}] = argArray; // 매개변수 배열 구조분해 할당
const requestOptions = {
...options,
headers: {
...options.headers,
Authorization: 새로운토큰,
},
}; // 헤더에 토큰 세팅4. API 요청 및 만료시 재발급 후 재요청 로직
try {
// 1차 요청
return await target(request, requestOptions);
} catch (error: any) {
// API 요청 중 오류 발생시 오류 원인 검사
if (권한오류라면) {
try {
// 토큰 갱신 시도
const 새토큰 = await 토큰재발급받기();
// 헤더에 세팅
requestOptions.headers.Authorization = 새토큰;
// 재시도 요청
return await target(request, requestOptions);
} catch (refreshError) {
// 토큰 갱신 중 오류 발생시 로그아웃 로직 호출
await logOut();
throw refreshError;
}
}
throw error;
}분석
☑️ 장점재요청의 결과를 호출자에게 그대로 반환할 수 있음
⚠️ 단점Proxy 문법의 복잡성과 타입 안정성 문제로 코드 가독성이 떨어짐
2. Proxy에서 wrapper 함수로 변경
proxy객체를 사용하지 않고 , api 함수 객체를 authApi라는 이름의 함수로 한번 감싸서 결과를 반환하는 형태로 변경했다.
// HTTP 클라이언트 객체
const api = $fetch.create({
onRequest ({ options }) {
options.headers = {
...options.headers,
};
},
});
// wrapper 함수
const authApi = async (request, options) => {
const requestOptions = {
...options,
headers: {
...options.headers,
Authorization: 기존토큰,
},
};
try {
return await api(request, {...requestOptions});
} catch (error: any) {
if (권한 오류라면) {
try {
// 토큰 업데이트 시도
const 새토큰 = await 토큰재발급받기();
requestOptions.headers = {
...requestOptions.headers,
Authorization: 새토큰,
};
// 재 요청 시도
return await api(request, { ...requestOptions });
} catch (refreshError) {
await logOut();
}
}
throw createError({
statusCode: 401,
statusMessage: '토큰 재발급 실패',
});
}
};분석
☑️ 장점코드 구조 단순화, 타입 안정성 확보
⚠️ 단점비동기 타이밍 문제로 토큰이 헤더에 정상적으로 세팅되지 않는 경우 발생
3. 토큰 세팅 위치 수정
처음엔 wrapper 함수에서 options.header에 토큰을 세팅해 매개변수로 전달해 호출하는 형태로 했으나, 비동기 타이밍 문제로 인해 종종 매개변수로 넘겨도 api 객체의 onResponse 인터셉터에서는 값이 비어있는 현상 발생.
wrapper 함수에서 토큰을 직접 세팅하는 대신, 클라이언트 인스턴스의 onRequest 훅에서 토큰을 읽어 동적으로 세팅하도록 변경.
const api = $fetch.create({
onRequest ({ options }) {
const { token } = useAuthStore();
options.headers = {
...options.headers,
Authorization: 토큰,
};
},
});분석
☑️ 장점토큰 변경 시점과 API 호출 시점의 정합성 확보
⚠️ 단점SSR 시점이나 HMR 초기화 시점에서는 store 값 접근이 불안정하여 undefined 토큰으로 호출되는 경우 발생
4. SSR 설정 변경
토큰이 필요한 페이지에 대해서는 SSR을 비활성화함.
SSR 중 store가 초기화되지 않거나, 토큰 세팅 타이밍 문제로 인해 token이 undefined로 인식되어 인증 오류가 발생했던 문제를 해결.
5. 에러 반환 로직 수정
기존에는 권한 오류가 아닌 경우에도 커스텀 에러를 생성하여 강제로 throw했기 때문에, 비정상적인 에러 흐름이 발생. 권한 오류가 아닌 경우는 원래의 에러를 그대로 throw 하도록 변경.
// authApi 내부 로직
try {
// api 요청 시도
return api 요청;
} catch (error: any) {
if (권한 오류라면) {
(...)
} else throw error;
// 권한 오류가 아닐때는 발생한 에러 그대로 반환
}최종 코드
const api = $fetch.create({
credentials: 'include',
baseURL: useRuntimeConfig().public.apiBase,
onRequest ({ options }) {
const { token } = useAuthStore();
options.headers = {
...options.headers,
...(getType(options.body) !== 'formdata' && defaultHeader),
...(token && {
Authorization: `Bearer ${token}`,
}),
};
},
});
const authApi = async (
request: NitroFetchRequest,
options: NitroFetchOptions<any> = {}) => {
try {
return await api(request, { ...options });
} catch (error: any) {
if (error.response?._data && error.response?._data.code === 'JWT001') {
try {
// 토큰 업데이트 시도
await updateToken();
// 재 요청 시도
return await api(request, { ... options });
} catch (refreshError) {
logOut();
}
} else throw error;
}
};최종 결론
리프레시 토큰을 활용한 인증 복원 로직은 다음과 같은 조건을 모두 만족해야 안정적으로 작동한다.
기존 코드의 실패 원인은 아래와 같다.
$fetch.create()의 onResponseError는 호출자의 응답 흐름과 무관하게 동작하므로, 재요청 결과를 호출자에게 연결할 수 없음.onResponseError에서 재요청하는 구조는 적절하지 않으며, $fetch 자체를 감싸는 wrapper 함수를 통해 재시도 및 최종 응답 반환 흐름을 제어하는 구조로 변경해야 함.결과적으로, 다음 형태가 가장 안정적인 동작을 보장함.
1. authApi(wrapper 함수) → API 요청 & 권한 오류 시 토큰 재발급 후 재요청
2. 토큰 세팅은 항상 onRequest 훅에서 수행
3. 권한이 필요한 페이지는 ssr: false로 분기여담
위 내용들을 작성하면서 찾아본 내용 중에 nuxt 전용 권한 라이브러리들이 존재하는 걸 알게 됐다. 이 라이브러리들을 사용했다면 여기까지의 노가다가 좀 줄지 않았을까?? 🤔 그리고 지금의 설계보다 더 나은 방법이 분명히 있을 것 같은데 도통 모르겠어서 답답하기도 하다. 부족한 부분이나 잘못된 부분에 대해 댓글로 의견 주시면 정말 감사하겠습니다!!! 저에게 깨달음을 주세요.