Axios와 Zustand를 활용한 인증 및 API 관리
Axios와 Zustand를 활용한 인증 및 API 관리
웹 애플리케이션에서 안전하고 효율적인 API 요청 관리는 매우 중요합니다. 이 글에서는 Axios와 Zustand를 사용하여 인증 토큰 관리, API 요청 인터셉터 설정, 에러 처리, 그리고 상태 관리를 구현한 사례를 소개합니다.
Axios 인스턴스 커스터마이징: 구현 코드와 상세 설명
Axios를 활용해 ShopbyAxiosInstance와 KasinAxiosInstance라는 두 개의 인스턴스를 생성하고, 각각의 API 요청에 필요한 헤더 추가, 응답 처리, 에러 처리, 재시도 로직을 구현했습니다. 아래는 코드와 함께 각 기능의 역할과 부가 설명입니다.
1. 동적으로 헤더 추가 (Request Interceptor)
주요 역할
- 각 요청마다 API가 요구하는 공통 헤더를 추가합니다.
- 인증이 필요한 경우, 로컬 저장소에서 액세스 토큰을 가져와 헤더에 포함합니다.
- createHeaders: 요청의 config와 accessToken을 기반으로 API에 필요한 동적 헤더를 생성합니다.
- isNeedToken: 토큰이 필요한 요청인지 확인하여 필요 시 헤더에 추가합니다.
const createHeaders = (config: AxiosRequestConfig, accessToken: string | null): Record<string, any> => { const company = 'Kasina/Request'; // 고정된 회사 정보 const platform: Platform = config.data?.platform || deviceType(); // 요청의 플랫폼 정보 (디바이스 기준) const version: Version = config.headers?.version || '1.0'; // API 버전 const isNeedToken = !config.data?.noAccessToken && (config.data?.isAuth ?? true); // 토큰 필요 여부 확인 const headers: any = { company, clientid: process.env.SHOPBY_CLIENT_ID, // 환경 변수로 클라이언트 ID 설정 platform, version }; if (isNeedToken && accessToken) { headers.accesstoken = accessToken; // 인증 토큰 추가 } return headers; };
Shopby와 Kasina 요청 설정
- ShopbyRequestHandler: Shopby API 요청에 토큰과 헤더를 추가하며, 요청 URL을 동적으로 구성합니다.
- KasinaRequestHandler: Kasina API 요청에 추가 헤더(Device-Uuid 등)를 설정하고 요청 URL을 설정합니다.
const ShopbyRequestHandler = async (config: AxiosRequestConfig) => { const accessToken = isLocalStorageAvailable() ? localStorage.getItem('accessToken') : null; const headers: any = createHeaders(config, accessToken); return { ...config, headers, url: `${process.env.API_BASE_URL}${config.url}` // Shopby API Base URL 추가 }; }; const KasinaRequestHandler = async (config: AxiosRequestConfig) => { const regExp = /^(https|http)/g.test(config.url || ''); const headers: any = { ...config.headers }; const deviceUuid = useAuthStore.getState().deviceUuid; // Zustand에서 디바이스 UUID 가져오기 if (isApp()) { headers['Device-Uuid'] = deviceUuid; // 앱 요청의 경우 Device-Uuid 추가 } return { ...config, headers, url: `${regExp ? '' : process.env.KASINA_BASE_API}${config.url}` // Kasina API Base URL 추가 }; };
2. 응답 처리 (Response Handler)
주요 역할
- 기본적으로 API 응답을 반환합니다.
- 에러 핸들러와 연결되며, 특별한 로직이 필요할 경우 추가 처리가 가능합니다.
const responseHandler = async (response: any) => response;
- 단순히 응답 객체를 반환하는 함수로, 특별한 전처리 없이 요청 성공 시 데이터를 반환합니다.
3. 에러 처리 및 토큰 갱신 (Error Handler)
주요 역할
- 에러 발생 시 적절히 처리하고, 401 Unauthorized 에러가 발생하면 토큰을 갱신합니다.
- 갱신 실패 시 로그아웃 및 리다이렉트 처리.
const errorHandler = async (error: AxiosError & { response: { data: CommonApiErrorResponse } }) => { if (error === null) throw new Error('Unrecoverable error!! Error is null!'); if (error.response && error.response.status !== 200) { await reportErrorToSlack(error); // Slack으로 에러 알림 전송 // 401 에러 발생 시 토큰 갱신 시도 if (error.response?.status === 401 && error.response?.data.code !== 'C999') { if (!isTokenRefreshing) { isTokenRefreshing = true; const authStoreData = useAuthStore.getState(); if (authStoreData.memberAuthToken) { // Refresh 토큰 만료 여부 확인 if (isValidateMemberAuthToken(authStoreData.memberAuthToken.refreshToken)) { logoutAndRedirect(); // 만료 시 로그아웃 return; } await refreshAuthToken(authStoreData); // 유효한 경우 토큰 갱신 isTokenRefreshing = false; } } return errorAxiosRetry(error); // 요청 재시도 } } return Promise.reject(error); // 처리되지 않은 에러를 거부 };
- reportErrorToSlack: 에러 정보를 Slack에 전송하여 실시간 모니터링을 지원합니다.
- refreshAuthToken: 토큰 만료 여부를 확인하고 갱신합니다.
- logoutAndRedirect: 토큰 갱신 실패 시 로그아웃 처리 후 홈으로 리다이렉트합니다.
4. 요청 재시도 로직
주요 역할
- 요청 실패 시 조건부로 재시도하여 API 요청의 안정성을 보장합니다.
- 재시도 횟수를 제한하고, 토큰이 갱신될 때까지 대기합니다.
const errorAxiosRetry = async (error: any): Promise<any> => { const authStoreData = useAuthStore.getState(); const shopbyApiCondition = error.config?.url?.includes(`${process.env.API_BASE_URL}`); const kasinaApiCondition = error.config?.url?.includes(`${process.env.KASINA_BASE_API}`); if (shopbyApiCondition) { error.config.headers['accesstoken'] = authStoreData.token?.accessToken; } else if (kasinaApiCondition) { error.config.headers['Authorization'] = `Bearer ${authStoreData.memberAuthToken?.accessToken}`; } if (!error.config.retryCount || error.config.retryCount === 0) { error.config.retryCount = 0; } try { if (isTokenRefreshing && error.config.retryCount < 2) { error.config.retryCount = 1; // 재시도 횟수 증가 await new Promise((resolve)=> setTimeout(resolve, 500)); // 0.5초 대기 return errorAxiosRetry(error); // 재귀 호출 } const response= await axios(error.config); // 재시도 요청 return responseHandler(response); } catch (retryError) { if (retryError) { return Promise.reject(retryError); // 재시도 실패 시 에러 반환 } } };
- retryCount: 재시도 횟수를 관리하여 무한 루프를 방지합니다.
- isTokenRefreshing: 토큰 갱신이 진행 중인 경우 대기 후 재시도를 수행합니다
5. 결론 및 요약
Axios와 Zustand를 활용한 이 구현은 다음과 같은 장점을 제공합니다:
- 재사용성: 공통된 로직을 Axios 인스턴스와 인터셉터에 담아 재사용 가능.
- 유연성: 동적으로 헤더를 생성하고, 요청 URL을 조합하여 다양한 API 환경에 대응.
- 안정성: 토큰 갱신 및 재시도 로직을 통해 사용자 경험을 개선. 위 코드를 통해 안전하고 효율적인 API 관리 시스템을 구축할 수 있습니다. 필요한 경우, responseHandler나 errorHandler를 커스터마이징하여 프로젝트에 맞는 로직을 추가할 수도 있습니다.
6. 코드 전체 (인스턴스 생성)
import axios, { AxiosError, AxiosRequestConfig } from 'axios'; import useAuthStore from '@/stores/auth'; // or 'react-native' import api from '@/api'; import { isMainPage } from '@/providers/AuthProvider'; import { CommonApiErrorResponse } from '@/types/common/commonApiResponse'; import { Platform, Version } from '../types/common/commonApiHeader'; import { appInterface, deviceType } from './common'; import { isApp } from './device'; import { isLocalStorageAvailable } from './storage'; import { isValidateMemberAuthToken } from './token'; let isTokenRefreshing = false; const createHeaders = (config: AxiosRequestConfig, accessToken: string | null): Record<string, any> => { const company = 'Kasina/Request'; const platform: Platform = config.data?.platform || deviceType(); const version: Version = config.headers?.version || '1.0'; const isNeedToken = !config.data?.noAccessToken && (config.data?.isAuth ?? true); const headers: any = { company, clientid: process.env.SHOPBY_CLIENT_ID, platform, version }; if (isNeedToken && accessToken) { headers.accesstoken = accessToken; } return headers; }; const ShopbyRequestHandler = async (config: AxiosRequestConfig) => { const accessToken = isLocalStorageAvailable() ? localStorage.getItem('accessToken') : null; const headers: any = createHeaders(config, accessToken); return { ...config, headers, url: `${process.env.API_BASE_URL}${config.url}` }; }; const KasinaRequestHandler = async (config: AxiosRequestConfig) => { const regExp = /^(https|http)/g.test(config.url || ''); const headers: any = { ...config.headers }; const deviceUuid = useAuthStore.getState().deviceUuid; if (isApp()) { headers['Device-Uuid'] = deviceUuid; } return { ...config, headers, url: `${regExp ? '' : process.env.KASINA_BASE_API}${config.url}` }; }; const responseHandler = async (response: any) => response; const logoutAndRedirect = async () => { const authStoreData = useAuthStore.getState(); await authStoreData.logoutToken(); window.location.replace('/'); // const returnUrl = location.href.replace(location.origin, ''); // if (!isMainPage()) { // const redirectUrl = returnUrl ? `/login?returnUrl=${returnUrl}` : '/login'; // window.location.replace(redirectUrl); // } }; const refreshAuthToken = async (authStoreData: any) => { try { const bufferDuration = 10; // 10 minutes //카시나 토큰 let memberAuthToken = authStoreData.memberAuthToken; //카시나 엑세스토큰 만료 10분 전 이하일때 카시나 토큰 갱신 if (isValidateMemberAuthToken(memberAuthToken.accessToken, bufferDuration)) { const refreshToken = memberAuthToken.refreshToken; const { data: newMemberAuthToken } = await api.kasina.postRefreshAccessToken({ refreshToken }); isApp() && appInterface('onRefreshedToken', JSON.stringify(newMemberAuthToken)); memberAuthToken = newMemberAuthToken; } //샵바이 토큰 재발급 & 스토어 저장 await authStoreData.loginToken({ memberAuthToken }); } catch (e) { console.error(e); isTokenRefreshing = false; logoutAndRedirect(); return; } }; const errorHandler = async (error: AxiosError & { response: { data: CommonApiErrorResponse } }) => { if (error === null) throw new Error('Unrecoverable error!! Error is null!'); if (error.response && error.response.status !== 200) { await reportErrorToSlack(error); //샵바이,카시나 api 401 에러시 토큰갱신 ( 카시나 refresh api error code 'C999' 제외 ) if (error.response?.status === 401 && error.response?.data.code !== 'C999') { if (!isTokenRefreshing) { isTokenRefreshing = true; const authStoreData = useAuthStore.getState(); if (authStoreData.memberAuthToken) { if (isValidateMemberAuthToken(authStoreData.memberAuthToken.refreshToken)) { logoutAndRedirect(); return; } await refreshAuthToken(authStoreData); isTokenRefreshing = false; } } return errorAxiosRetry(error); } } return Promise.reject(error); }; const reportErrorToSlack = async (error: AxiosError) => { const ErrorHookAPI = `${process.env.slackNotification}/T0103N621AN/B05Q629DEAW/maeCyKZQed58UnKnLMlQv6Fe`; // const { data } = await KasinAxiosInstance.get('https://api64.ipify.org/?format=json'); // [IpAddress]: ${data.ip} \n const authStoreData = useAuthStore.getState(); if (error.response) { const payload = { blocks: [ { type: 'header', text: { type: 'plain_text', text: `[API][${error.response.status}][${error.response.config.method?.toLocaleUpperCase()}]${error.response.config.url}` } }, { type: 'section', block_id: 'details', text: { type: 'mrkdwn', text: ` *[ENV]: ${process.env.NODE_ENV.toUpperCase()} / ${process.env.isEnv}* \n *[App]: ${isApp()}*\n\n[CurrentPage]: ${ typeof window != 'undefined' ? window.location.href : 'SSR' } \n\`\`\`${JSON.stringify({ errorData: error.response.data }, null, 2)}\`\`\`` } }, { type: 'section', block_id: 'authStore', text: { type: 'mrkdwn', text: `${ typeof window != 'undefined' ? `\`\`\`${JSON.stringify( { Token: { ...{ shopby: { accessToken: localStorage.getItem('accessToken') } }, ...{ accessToken: authStoreData.memberAuthToken?.accessToken, refreshToken: authStoreData.memberAuthToken?.refreshToken } } }, null, 2 )}\`\`\`\n` : '' }\`\`\`[UA]: ${typeof window != 'undefined' ? window.navigator.userAgent : 'SSR'}\n[AuthStore]${ typeof window != 'undefined' ? JSON.stringify( { isLogin: authStoreData.isLogin, profileInfo: { mallName: authStoreData.profileInfo?.mallName, memberNo: authStoreData.profileInfo?.memberNo, memberGradeName: authStoreData.profileInfo?.memberGradeName, memberGradeNo: authStoreData.profileInfo?.memberGradeNo, memberStatus: authStoreData.profileInfo?.memberStatus, memberType: authStoreData.profileInfo?.memberType, ...(process.env.isEnv === 'isDev' || process.env.isEnv === 'isStg' ? { memberId: authStoreData.profileInfo?.memberId } : {}) } }, null, 2 ) : '' }\`\`\`` } } ] }; try { if (process.env.isEnv !== 'isDev') { fetch(ErrorHookAPI, { method: 'POST', body: JSON.stringify(payload) }); } else { console.log(payload); } } catch (e) { console.log(e); } } }; const errorAxiosRetry = async (error: any): Promise<any> => { const authStoreData = useAuthStore.getState(); const shopbyApiCondition = error.config?.url?.includes(`${process.env.API_BASE_URL}`); const kasinaApiCondition = error.config?.url?.includes(`${process.env.KASINA_BASE_API}`); if (shopbyApiCondition) { error.config.headers['accesstoken'] = authStoreData.token?.accessToken; } else if (kasinaApiCondition) { error.config.headers['Authorization'] = `Bearer ${authStoreData.memberAuthToken?.accessToken}`; } if (!error.config.retryCount || error.config.retryCount === 0) { error.config.retryCount = 0; } try { if (isTokenRefreshing && error.config.retryCount < 2) { error.config.retryCount = 1; await new Promise((resolve)=> setTimeout(resolve, 500)); return errorAxiosRetry(error); } const response= await axios(error.config); return responseHandler(response); } catch (retryError) { if (retryError) { return Promise.reject(retryError); } } }; const ShopbyAxiosInstance= axios.create({}); const KasinAxiosInstance= axios.create({}); ShopbyAxiosInstance.interceptors.request.use(ShopbyRequestHandler); ShopbyAxiosInstance.interceptors.response.use(responseHandler, errorHandler); KasinAxiosInstance.interceptors.request.use(KasinaRequestHandler); KasinAxiosInstance.interceptors.response.use(responseHandler, errorHandler); export { ShopbyAxiosInstance, KasinAxiosInstance };
Utils
import * as jwt from 'jsonwebtoken'; const isTokenTimeExpired = (expirationTime: number, bufferDuration: number = 0) => { const currentTimestamp = Date.now(); const expirationTimestamp = expirationTime * 1000; const bufferTimestamp = bufferDuration * 60 * 1000; return currentTimestamp >= expirationTimestamp - bufferTimestamp; }; export const isValidateMemberAuthToken = (token: string, bufferDuration: number = 0) => { try { const decodedToken = jwt.decode(token) as jwt.JwtPayload; if (!decodedToken || !decodedToken.exp) { return true; } return isTokenTimeExpired(Number(decodedToken.exp), bufferDuration); } catch (e) { return true; } };
Zustand로 구현한 글로벌 상태 관리
Zustand는 React 애플리케이션에서 간단하고 효율적인 상태 관리를 가능하게 합니다. 위 코드는 useAuthStore라는 Zustand 스토어를 활용해 사용자 인증 상태 및 관련 데이터를 관리하는 방식을 보여줍니다. 주요 기능과 코드 구성은 다음과 같습니다.
import { create } from 'zustand'; import { devtools, persist, createJSONStorage } from 'zustand/middleware'; import useCartStore from '@/stores/order/cart'; import useGuestCart from '@/stores/order/guestCart'; import api from '@/api'; import { getOauthOpenId } from '@/api/auth'; import { getProfile } from '@/api/member'; import { OauthOpenIdResponse } from '@/types/auth'; import { ActionTokenType, KasinaOauthOpenIdResponse } from '@/types/kasina'; import { GetProfileResponse } from '@/types/member'; import { appInterface } from '@/utils/common'; import { isApp } from '@/utils/device'; import { zustandLogger } from '@/utils/zustand'; type State = { isLogin: boolean; token: OauthOpenIdResponse | null; memberAuthToken: KasinaOauthOpenIdResponse | null; memberActionToken: string | null; memberActionTokenType: ActionTokenType | null; profileInfo: GetProfileResponse | null; additionalInfo: any; deviceUuid: string; isTokenValid: boolean; }; type Action = { getProfile: () => Promise<void>; validateLogin: () => boolean; loginToken: (params: { memberAuthToken: KasinaOauthOpenIdResponse }) => Promise<void>; logoutToken: () => Promise<boolean>; setMemberActionToken: (params: { memberActionToken: string | null; actionTokenType: ActionTokenType | null }) => Promise<void>; resetMemberActionToken: () => Promise<void>; setProfileInfo: (profileInfo: any) => Promise<void>; setAdditionalInfo: (info: any) => void; setDeviceUuid: (info: string) => void; setIsTokenValid: (isTokenValid: boolean) => void; }; const DEFAULT_AUTH_STORE_STATE = { isLogin: false, token: null, memberAuthToken: null, memberActionTokenType: null, memberActionToken: null, isTokenValid: false }; const useAuthStore = create( persist( devtools( zustandLogger<State & Action>((set, get) => ({ isLogin: false, token: null, memberAuthToken: null, memberActionTokenType: null, memberActionToken: null, profileInfo: null, additionalInfo: null, deviceUuid: '', isTokenValid: false, validateLogin: () => !!get().token, loginToken: async ({ memberAuthToken }) => { //샵바이 로그인 const { data } = await getOauthOpenId({ openAccessToken: memberAuthToken.refreshToken }); window.localStorage.setItem('accessToken', data.accessToken); set({ isLogin: true, token: data, memberAuthToken }); await useAuthStore.getState().getProfile(); }, logoutToken: async () => { try { await api.kasina.deleteOauthToken(); window.localStorage.removeItem('accessToken'); set({ memberActionToken: null, memberActionTokenType: null, profileInfo: null, isLogin: false }); await set({ ...DEFAULT_AUTH_STORE_STATE }); isApp() && appInterface('onRefreshedToken', JSON.stringify({})); return true; } catch (e) { return false; } }, setMemberActionToken: async (params: { memberActionToken: string | null; actionTokenType?: ActionTokenType | null }) => set({ memberActionToken: params.memberActionToken, memberActionTokenType: params.actionTokenType ? params.actionTokenType : null }), resetMemberActionToken: async () => set({ memberActionToken: null, memberActionTokenType: null }), // setProfileInfo: async (data: GetProfileResponse | null) => set({ profileInfo: data }), setProfileInfo: async (data: any) => set((state) => ({ profileInfo: state.profileInfo ? { ...state.profileInfo, ...data } : data })), setAdditionalInfo: (info: any) => set({ additionalInfo: info }), setDeviceUuid: (uuid: string) => set({ deviceUuid: uuid }), setIsTokenValid: (isTokenValid: boolean) => set({ isTokenValid }), getProfile: async () => { try { const response = await getProfile(); if (response && response.data) { const { data } = response; try { const kasinaProfile = await api.kasina.getKasinaUserProfile({ memberAccessToken: useAuthStore.getState().memberAuthToken?.accessToken || '' }); const updatedData = { ...data, birthday: kasinaProfile?.data?.birthday || data.birthday, sex: kasinaProfile?.data?.sex || data.sex, principalCertificated: kasinaProfile?.data?.isPrivacyPolicyAgreed }; set({ profileInfo: updatedData }); } catch { set({ profileInfo: data }); } } else { console.error('Invalid response or missing data in getProfile'); } } catch (error) { console.error('Error in getProfile'); } } })) ), { name: 'authStore', storage: createJSONStorage(() => localStorage) } ) ); useAuthStore.subscribe(async (state, prevState) => { if (!prevState.token || !prevState.isLogin || !prevState.profileInfo) { return; } // if (state.token && state.isLogin && state.isLogin && !state.profileInfo) { // // useCartStore.getState().actions.getCartCount(); // useAuthStore.getState().getProfile(); // } if (prevState.isLogin && !state.isLogin) { useCartStore.getState().actions.setCartCount(0); useGuestCart.getState().actions.setCartCount(); } }); export default useAuthStore;