본문 바로가기

Front-End

Next.js에서 Axios Interceptor 작성하기 (feat. Zustand, localStorage)

Axios Interceptor 란?

HTTP 요청이나 응답을 가로채고 수정할 수 있는 중간 단계를 의미합니다. 이를 통해 전역적으로 요청과 응답을 처리하거나 특정 요청이나 응답에 대해 특별한 로직을 적용할 수 있습니다. Interceptor를 사용하면 다양한 용도로 활용할 수 있으며, 예를 들어 토큰을 자동으로 추가하거나 에러를 처리하는 등의 작업을 수행할 수 있습니다. 이를 통해 코드의 중복을 줄이고 유지보수성을 향상시킬 수 있습니다.

 

 

Axios 공식 문서도 참고해보자 ↓

https://www.npmjs.com/package/axios/v/1.4.0#interceptors

 

axios

Promise based HTTP client for the browser and node.js. Latest version: 1.6.8, last published: 7 days ago. Start using axios in your project by running `npm i axios`. There are 121723 other projects in the npm registry using axios.

www.npmjs.com

 

interceptor를 사용하는 이유는 Axios를 사용하여 API 요청을 보낼 때 인증 및 인증 오류 처리를 간편하게 관리하기 위함이다.

1. API요청 시 사용자 인증을 위한 토큰을 헤더에 담아줄 것이고,

2. 사용자 인증에 실패한다면 재인증을 위한 로직을 실행하도록 하고, (e.g. 토큰 재발행, 재로그인)

3. 토큰 발급이 새로 되었다면 이전 요청을 재시도하거나 / 토큰 발급 실패시 사용자에게 다시 로그인 할 것을 요청할 것이다.

 

import axios, { InternalAxiosRequestConfig, AxiosResponse } from 'axios'
import useAuthStore from '@/states/useAuthStore'
import { useRouter } from 'next/navigation'

const useAxiosWithAuth = () => {
  const accessToken = useAuthStore.getState().accessToken
  const router = useRouter()

  const axiosInstance = axios.create({
    baseURL: "base api url",
  })

  //무한 요청 방지 flag
  let isRefreshing = false


  // request interceptor
  axiosInstance.interceptors.request.use(
    (config: InternalAxiosRequestConfig) => {
      if (accessToken) {
        config.headers['Authorization'] = `Bearer ${accessToken}`
      }
      return config
    },
    (error) => {
      return Promise.reject(error)
    },
  )

  // response interceptor
  axiosInstance.interceptors.response.use(
    (response: AxiosResponse) => {
      return response
    },
    async (error) => {
      const currentPageUrl = window.location.pathname
      if (error.response?.status === 401) {
        // unauthorized 오류 받았을 때 = 로그인을 하지 않았거나 토큰 만료인 상태
        // 엑세스 토큰이 없거나(로그인x) 갱신을 위한 리프레시 토큰이 없거나 이미 토큰 재발급시도를 했었다면
        if (!accessToken || isRefreshing) {
          // 로그아웃 후 리디렉션
          useAuthStore.getState().logout(isRefreshing)
          // 로그인 후 사용자를 원래 페이지로 이동시켜주기 위함
          router.push('/login?redirect=' + currentPageUrl)
        } else {
          // 리프레시 토큰으로 엑세스 토큰 재발급 시도
          isRefreshing = true
          try {
            // accessToken 갱신 요청
            const response = await axiosInstance.get('재발급/api주소', {
              withCredentials: true,
            }) // 쿠키를 같이 전달할 수 있는 옵션
            const newAccessToken = response.data.accessToken
            // 새 엑세스 토큰을 받았다면 토큰을 새로 저장한다
            useAuthStore.getState().login(newAccessToken)
            // 헤더에 새 토큰을 넣어
            error.config.headers['Authorization'] = `Bearer ${newAccessToken}`
            // 이전 요청을 재시도
            return axios.request(error.config)
          } catch (refreshError) {
            // 리프레시토큰도 만료되었다는 응답을 받았다면
            // 로그아웃 후 로그인 페이지로
            isRefreshing = true
            useAuthStore.getState().logout(isRefreshing)
            router.push('/login?redirect=' + currentPageUrl)
          }
        }
      }

      return Promise.reject(error)
    },
  )

  return axiosInstance
}

export default useAxiosWithAuth

 

 

그리고 전역상태 관리를 위한 useAuthStore.tsx

Zustand 라이브러리를 사용했다. 

 

import { create } from 'zustand'
import LocalStorage from './localStorage'
import axios from 'axios'

interface IAuthStore {
  // 로그인 상태
  isLogin: boolean
  accessToken: string | null
  // 엑세스 토큰 저장 함수
  login: (accessToken: string) => void
  // 로그아웃 함수
  logout: (isRefreshing?: boolean) => void
}

const useAuthStore = create<IAuthStore>((set) => {
  // 로컬에 저장된 토큰 가져오기
  const authDataJSON = LocalStorage.getItem('authData')
  const authData = authDataJSON
    ? JSON.parse(authDataJSON)
    : { accessToken: null }

  const API_URL = process.env.NEXT_PUBLIC_CSR_API

  return {
    isLogin: !!authData.accessToken,
    accessToken: authData.accessToken,
    // 로그인 함수
    login: (accessToken) => {
      const authDataToSave = { accessToken }
      // 로컬 스토리지에 authData: { accessToken: `토큰`} 형태로 저장
      LocalStorage.setItem('authData', JSON.stringify(authDataToSave))
      // 전역 상태 업데이트
      set(() => ({
        isLogin: true,
        accessToken,
      }))
    },
    // 로그아웃 함수
    logout: (isRefreshing) => {
      // (사용자가 의도한 로그아웃일 때 로그아웃 api 호출)
      if (authData.accessToken && isRefreshing === undefined) {
        axios
          .get(`로그아웃/api/`, {
            headers: {
              Authorization: `Bearer ${authData.accessToken}`,
            },
          })
          .catch(() => {
            // console.log('만료된 토큰') -- do nothing
          })
      }
      // 로컬 스토리지에서 authData 삭제
      LocalStorage.removeItem('authData')
      // 전역 상태 업데이트
      set(() => ({
        isLogin: false,
        accessToken: null,
      }))
    },
  }
})

export default useAuthStore

 

 

여기서 LocalStorage를 보면 localStorage와 철자가 다른 것을 볼 수 있는데, 사실 이 클래스도 다시 만들었다.

Next.js환경에서 ssr(Server-Side-Rendering)되는 페이지의 localStorage를  감지할 수 없기 때문...

 

LocalStorage.ts

class LocalStorage {
  constructor() {}

  static setItem(key: string, value: string) {
    if (typeof window !== 'undefined') {
      localStorage.setItem(key, value)
    }
  }

  static getItem(key: string) {
    if (typeof window !== 'undefined') {
      return localStorage.getItem(key)
    }
    return null
  }

  static removeItem(key: string) {
    if (typeof window !== 'undefined') {
      localStorage.removeItem(key)
    }
  }
}

export default LocalStorage

이렇게 해주면 window객체 존재여부를 확인함으로써 서버 사이드인지 확인하고 오류를 방지할 수 있다.