Back to posts

[트러블 슈팅] Nuxt.js 리프레시 토큰 권한 오류 수정

uxt 환경에서 토큰 만료 후 재요청 시 응답이 반환되지 않던 문제를 분석하고,

·5 min read
NuxtAccessTokenRefreshToken$fetchInterceptorSSR

문제 상황

엑세스 토큰이 만료되고 리프레시 토큰을 통해 재발급을 받아 API를 재요청할 때, 요청은 성공했지만 최종적으로 데이터가 반환되지 않는 문제가 발생함.

기대 동작

  • API 요청이 권한 오류 문제로 실패하면 리프레시 토큰을 통해 엑세스 토큰을 재발급
  • 엑세스 토큰이 정상적으로 재발급되면 실패했던 API 요청을 동일하게 재시도
  • 재시도한 API 요청이 성공하면 해당 API의 응답 데이터 반환
  • 실제 동작

  • API 요청이 권한 오류 문제로 실패하면 리프레시 토큰을 통해 엑세스 토큰을 재발급 받음 ✅
    • 권한 이유로 실패시 리프레시 토큰 재발급 로직 자동 호출 확인
  • 엑세스 토큰이 정상적으로 재발급되면 실패한 API 요청을 재시도함 ✅
    • 실패한 API 요청 재시도해 성공 응답까지 확인
  • 재시도한 API 요청이 성공했을 시 성공 데이터 반환 ❌
    • API 요청이 성공했으나 데이터가 반환되지 않는 오류 발생

  • 기존 로직

    Nuxt 프로젝트에서는 인증, 에러 처리, 공통 헤더 세팅 등의 API 요청 관련 로직을 $fetch.create로 커스텀한 HTTP 클라이언트에 통합하고, 이를 Nuxt 플러그인으로 등록해 전역에서 사용하고 있었다.

    해당 $fetch 인스턴스에는 onRequestonResponseonResponseError 인터셉터가 설정되어 있었고,

    그 중 onResponseError에 다음과 같은 재시도 로직이 구현되어 있었다.

    javascript
    // 기존 로직
    // 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 요청이 성공해도 그 결과가 최초 호출자에게 반환되지 않음
  • 결과적으로 API 요청은 성공하지만 최초 호출자 입장에선 undefined나 error를 받게 됨
  • 원인 분석

    $fetch.create의 onResponseError 내부에서 재시도 요청을 수행해도, 그 결과는 Nuxt의 useFetch 등 호출자에게 연결되지 않는다.
  • onResponseError는 본래 후속 처리를 위한 훅이며, 비동기 흐름을 제어하는 데 적합하지 않음
  • 내부에서 $fetch()를 재요청해도, 그 Promise는 호출 스택으로 전달되지 않음
  • 이로 인해 최종적으로 useFetch()나 useGET() 등에서 data가 null 또는 undefined로 남는 문제가 발생
  • 시도한 방법

    1. Proxy를 활용한 커스텀 API 클라이언트 구현

    이 문제를 해결하기 위해 Proxy를 사용하여 $fetch 호출 자체를 감싸고, 실패했을 경우 토큰 갱신 후 재요청하는 로직을 직접 삽입하는 방식으로 전환했다.

    typescript
    // 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란?

  • 대상 객체에 대한 기본 작업을 가로채고 재정의하는 객체
  • 매개변수로 프록시할 원본 객체와 핸들러를 받아 처리한다.
  • 핸들러의 메서드 중 apply는 함수 호출시 해당 작업을 가로채는 역할을 한다.
  • apply는 매개변수로 호출 대상 함수 객체, 호출 대상의 this, 호출에 대한 인수 목록을 받는다.
  • 코드 설명

    1. api 객체를 감싼 프록시 객체 생성

    typescript
    const authFetch = new Proxy(api, {});

    2.핸들러에 apply 메서드 정의

    typescript
    apply: async (target, thisArg, argArray) => {}
    // target: fetch.create로 만들어진 api 함수 객체
    // thisArg: api 객체의 this
    // argArray: api를 호출할때 전달된 매개변수 목록 배열

    3. api 요청 전 헤더 세팅

    typescript
    const [request, options = {}] = argArray; // 매개변수 배열 구조분해 할당
    const requestOptions = {
        ...options,
        headers: {
            ...options.headers,
            Authorization: 새로운토큰,
        },
    }; // 헤더에 토큰 세팅

    4. API 요청 및 만료시 재발급 후 재요청 로직

    typescript
    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라는 이름의 함수로 한번 감싸서 결과를 반환하는 형태로 변경했다.

    typescript
    // 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 훅에서 토큰을 읽어 동적으로 세팅하도록 변경.

    typescript
    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 하도록 변경.

    typescript
    // authApi 내부 로직
    
    try {
      // api 요청 시도
      return api 요청;
    } catch (error: any) {
      if (권한 오류라면) {
        (...)
      } else throw error;
      // 권한 오류가 아닐때는 발생한 에러 그대로 반환
    }

    최종 코드

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

    최종 결론

    리프레시 토큰을 활용한 인증 복원 로직은 다음과 같은 조건을 모두 만족해야 안정적으로 작동한다.

  • 실패한 요청과 완전히 동일하게 재시도할 수 있어야 함.
  • 재시도된 요청의 결과가 그대로 반환되어야 함.
  • 토큰 갱신은 클라이언트 환경 조건(SSR 여부, 쿠키 접근 가능 여부)을 고려해야 함.
  • 기존 코드의 실패 원인은 아래와 같다.

  • $fetch.create()의 onResponseError는 호출자의 응답 흐름과 무관하게 동작하므로, 재요청 결과를 호출자에게 연결할 수 없음.
  • 따라서 onResponseError에서 재요청하는 구조는 적절하지 않으며, $fetch 자체를 감싸는 wrapper 함수를 통해 재시도 및 최종 응답 반환 흐름을 제어하는 구조로 변경해야 함.
  • 이 과정에서 store 접근, SSR 타이밍, HMR 환경까지 고려한 로직 분리가 필요함.
  • 결과적으로, 다음 형태가 가장 안정적인 동작을 보장함.

    plain text
    1. authApi(wrapper 함수) → API 요청 & 권한 오류 시 토큰 재발급 후 재요청
    2. 토큰 세팅은 항상 onRequest 훅에서 수행
    3. 권한이 필요한 페이지는 ssr: false로 분기

    여담

    위 내용들을 작성하면서 찾아본 내용 중에 nuxt 전용 권한 라이브러리들이 존재하는 걸 알게 됐다. 이 라이브러리들을 사용했다면 여기까지의 노가다가 좀 줄지 않았을까?? 🤔 그리고 지금의 설계보다 더 나은 방법이 분명히 있을 것 같은데 도통 모르겠어서 답답하기도 하다. 부족한 부분이나 잘못된 부분에 대해 댓글로 의견 주시면 정말 감사하겠습니다!!! 저에게 깨달음을 주세요.


    참고자료

    Proxy - JavaScript | MDN

    Nuxt3 $fetch, useAsyncData, useFetch 의 차이

    © 2026 Bit by Bit Blog. All rights reserved.