웹 애플리케이션에서 안전하고 효율적인 API 요청 관리는 매우 중요합니다. 이 글에서는 Axios와 Zustand를 사용하여 인증 토큰 관리, API 요청 인터셉터 설정, 에러 처리, 그리고 상태 관리를 구현한 사례를 소개합니다.
Axios를 활용해 ShopbyAxiosInstance와 KasinAxiosInstance라는 두 개의 인스턴스를 생성하고, 각각의 API 요청에 필요한 헤더 추가, 응답 처리, 에러 처리, 재시도 로직을 구현했습니다. 아래는 코드와 함께 각 기능의 역할과 부가 설명입니다.
주요 역할
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 요청 설정
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 추가
};
};
주요 역할
const responseHandler = async (response: any) => response;
주요 역할
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); // 처리되지 않은 에러를 거부
};
주요 역할
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); // 재시도 실패 시 에러 반환
}
}
};
Axios와 Zustand를 활용한 이 구현은 다음과 같은 장점을 제공합니다:
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 };
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;
}
};
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;