Axios와 Zustand를 활용한 인증 및 API 관리

Eddy·2024년 1월 9일
0

React

목록 보기
20/21

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를 활용한 이 구현은 다음과 같은 장점을 제공합니다:

  1. 재사용성: 공통된 로직을 Axios 인스턴스와 인터셉터에 담아 재사용 가능.
  2. 유연성: 동적으로 헤더를 생성하고, 요청 URL을 조합하여 다양한 API 환경에 대응.
  3. 안정성: 토큰 갱신 및 재시도 로직을 통해 사용자 경험을 개선.
    위 코드를 통해 안전하고 효율적인 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;

0개의 댓글