본문 바로가기

Front-end/Javascript

[TypeScript] API Fetch 하나로 합치는 작업 - 기록용

 

 

기존 Api호출 Fetch들이 나누어져있었다.
그거를 한개로 통합해야될 필요성이 있어서 한개로 합치게되었다.

먼저 예시로 어떻게 수정이되었는지 기록용으로 남긴다.
로그인부분으로 예를 들겠다.
OTP로 로그인시 

  const resp = await api.call(`auth`, `mfa`, otp.value);
이렇게 요청을 하는데 

✅
  /* Start: OTP입력 */
            if (!dialogsStore.alertFlag) {
                if (isNil(otp.value.otp)) {
                    pushNoti(`error`, `MGS.OTPINPUT`); /* OTP를 입력해 주세요 */
                    return;
                }
                loadingStore.increaseLoadingState();
                const resp = await api.call(`auth`, `mfa`, otp.value);
                if (resp.status) {
                    userClaim.value = await api.call(`auth`, `getUserClaim`);
                    await userStore.setPersonalizationByCommonFetch();
                    await systemConfigStore.setSystemConfig();
                    pushNoti(`success`, `MSG.LOGIN`); /*로그인 되었습니다*/
                    if (typeof window !== 'undefined') {
                        localStorage.setItem(
                            'platform-user-claim',
                            JSON.stringify(userClaim.value)
                        );
                        // loginStepStatus.value = 'id';
                    }
                    navigateTo('/');
                } else {
                    pushNoti(`error`, `MSG.OTPFAIL`); /*OTP 인증에 실패하였습니다.*/
                    loadingStore.resetLoadingState();
                }
            }
        }
        /* End: OTP입력 */

 

 

useCommonFetch.ts

import type { ApiInstance } from '~/plugins/api';
import { useRouter } from 'vue-router';

export default () => {
    const {
        $api,
        $i18n: { t },
    } = useNuxtApp();
    // const localPath = useLocalePath()
    const notificationsStore = useNotificationsStore();
    const loadingStore = useLoadingStore();
    const dialogStore = useDialogsStore();

    const router = useRouter();
    // get client cookies in server environment
    // if process.server === fales, cookies = {}
    const cookies = useRequestHeaders(['cookie']);
    let accessToken: string | null;
    if (typeof window !== 'undefined') {
        accessToken = localStorage.getItem('platform-dfocus-access-token');
    }

    type FunctionKeys<T> = {
        [K in keyof T]: T[K] extends (...args: any[]) => any ? K : never;
    }[keyof T];

    const commonFetch = async function <
        T extends keyof ApiInstance,
        U extends FunctionKeys<ApiInstance[T]>,
    >(
        module: T,
        method: U,
        ...args: ApiInstance[T][U] extends (...args: [any, ...infer P]) => any ? P : []
    ): Promise<ApiInstance[T][U] extends (...args: any[]) => Promise<infer R> ? R : void> {
        const firstArg = args[0] as { keepLogin?: boolean };
        const keepLogin = firstArg?.keepLogin ? 'Y' : null;

        //UserClaim이 없는 경우 실행하지 않음
        if (
            ![
                'getUserClaim',
                'getPersonalization',
                'login',
                'sendEmailToAdmin',
                'findId',
                'findPassword',
            ].includes(method.toString())
        ) {
            const storedData = localStorage.getItem('platform-user-claim');
            if (!storedData || !JSON.parse(storedData)) {
                console.log('>> storedData', storedData);
                console.log('>> UserClaim is null Aborting Request!!!');
                loadingStore.resetLoadingState();
                return Promise.reject(new Error('UserClaim is null'));
            }
        }

        try {
            const setHeaders = {
                ...cookies,
                ...{ dfocus_access_token: accessToken },
            };
            const resp = await ($api[module][method] as Function)(
                {
                    headers: setHeaders,
                },
                ...args
            );
            if (!accessToken) {
                const getToken = resp.headers?.get('dfocus_access_token');
                if (getToken) {
                    localStorage.setItem('platform-dfocus-access-token', getToken);
                }
            }
            if (method == 'logout') {
                accessToken = null;
            }
            if (resp._data) {
                const data = resp._data;
                // data.status > resp.status 변경(data에 status가 없는 경우가 있음)

                if (!data.status) {
                    notificationsStore.pushNotification({
                        type: `error`,
                        text: data.resultMsg,
                    });
                }

                return resp._data.map || resp._data.list || resp._data.result || resp?._data;
            }

            return resp;
        } catch (error: any) {
            loadingStore.resetLoadingState();
            if (error.response && error.response.status === 401) {
                // notificationsStore.pushNotification({
                //   type: `error`,
                //   text: t(
                //     "MSG.SESSION.EXPIRE2" /*세션이 만료되었습니다. 다시 로그인하세요.*/
                //   ),
                // });

                localStorage.removeItem('platform-user-claim');
                localStorage.removeItem('platform-dfocus-access-token');

                dialogStore.openDialog(
                    {
                        text: t(
                            'MSG.SESSION.EXPIRE' /*세션이 만료되었습니다. 로그인 페이지로 이동하시겠습니까?*/
                        ),
                    },
                    async (result: any) => {
                        if (result) {
                            router.push({
                                path: '/login',
                            });
                        }
                    }
                );
            } else {
                notificationsStore.pushNotification({
                    type: `error`,
                    text: `${error}`,
                });
                // await navigateTo(`login`);
                // navigateTo 호출 후 store reset됨 ??
                // 일단 navigateTo 대신 router.push 사용
                // router.push(`login`);
                // throw error 안 하면, useAsyncData client단에서 retry ??
            }
            throw error;
        }
    };

    return { commonFetch };
};

 

 

기존 패치 로직이다

 

그런데 저런 패치들이 각각상황별로 패치들이 엄청 많았다

 

 

// API 모듈 호출
  const callApiModule = async <T extends keyof ApiInstance, U extends keyof ApiInstance[T]>(
    moduleKey: T,
    method: U,
    ...args: any[]
  ) => {
    // 예외 API는 UserClaim 없어도 호출 가능
      const exceptionMethods = ['getUserClaim', 'getPersonalization', 'login', 'mfa'];
    if (!exceptionMethods.includes(String(method)) && !checkUserClaim()) {
      loadingStore.resetLoadingState();
      return Promise.reject();
    }

    const moduleFn = $api[moduleKey][method] as Function;
    const resp = await moduleFn(
      {
        headers: {
          ...cookies,
          ...(getAccessToken() ? { [CONST.HEADER_KEYS.ACCESS]: getAccessToken() } : {}),
        },
      },
      ...args
    );


    // MFA 처리 시 token 저장
    if (method === 'mfa') {
      const newToken = resp.headers?.get(CONST.HEADER_KEYS.ACCESS);
      if (newToken) setAccessToken(newToken);
    }

    // UserClaim 저장 (예: getUserClaim 호출 시)
    if (method === 'getUserClaim') {
      const userClaimData = resp?._data?.result || resp?._data;
      if (userClaimData) {
        localStorage.setItem('platform-user-claim', JSON.stringify(userClaimData));
      }
    }

    const newAccessToken = resp.headers?.get(CONST.HEADER_KEYS.ACCESS);
    const newRefreshToken = resp.headers?.get(CONST.HEADER_KEYS.REFRESH);
    if (newAccessToken) setAccessToken(newAccessToken); 
    if (newRefreshToken) setRefreshToken(newRefreshToken);

    if (method === 'logout') {
      clearTokens();
    }

    return resp?._data?.map || resp?._data?.list || resp?._data?.result || resp?._data || resp;
  };

 

 

이렇게 처리를하게되어서 모든 모듈을 통해서 api를 호출하는건

api.call(); 로 통일시켜서 호출시키게할수있게바꾸었다.

 

물론 get post put delete도 따로만들어서

 

전체코드를 기록하겠다 그래서 호출할때는 api.get()이런식으로호출해서 하드코딩을 할 이유가없어지고 

다양한 api fetch들을 하나로 합칠수 있게되었다.

 

/*
 * Copyright (c) 2024. Dfocus Co., Ltd . ALL RIGHT RESERVED.
 *
 * API 호출 공통 
 * @author : 윤성호
 * @date   : 2025.11.07
 *
 * @change history
 *******************************************************************************
 ** 2025.11.07 윤성호 - 최초 생성
 *******************************************************************************
 */

import type { ApiInstance } from '~/plugins/api';
import { useRouter } from 'vue-router';
import { ofetch } from 'ofetch';


export default function useApiFetch() {
  const { $api }: { $api: ApiInstance } = useNuxtApp();
  const { $i18n: { t }, $config } = useNuxtApp();

  const { CONST } = useConstants(t);
  const notificationsStore = useNotificationsStore();
  const loadingStore = useLoadingStore();
  const dialogStore = useDialogsStore();
  const router = useRouter();
  const cookies = useRequestHeaders(['cookie']);

  const REFRESH_URL =  `${$config.public.baseURL}/v2/api/common/token/refresh`;

  // 토큰 가져오기
  const getAccessToken = () => localStorage.getItem(CONST.TOKEN_KEYS.ACCESS);
  const getRefreshToken = () => localStorage.getItem(CONST.TOKEN_KEYS.REFRESH);

  //  토큰 저장
  const setAccessToken = (token: string) => localStorage.setItem(CONST.TOKEN_KEYS.ACCESS, token);
  const setRefreshToken = (token: string) => localStorage.setItem(CONST.TOKEN_KEYS.REFRESH, token);


  // 토큰 제거 (로그아웃 시)
  const clearTokens = () => {
    localStorage.removeItem(CONST.TOKEN_KEYS.ACCESS);
    localStorage.removeItem(CONST.TOKEN_KEYS.REFRESH);
    localStorage.removeItem('platform-user-claim');
  };

  

  // JWT 한글 파싱
  const parseJwt = (token: string) => {
    try {
      const base64Url = token.split('.')[1];
      const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
      const jsonPayload = decodeURIComponent(
        atob(base64)
          .split('')
          .map((c) => '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2))
          .join('')
      );
      return JSON.parse(jsonPayload);
    } catch {
      return null;
    }
  };

  // JWT 만료 체크
  const isTokenExpired = (token: string | null): boolean => {
    if (!token) return true;
    try {
      const payload = parseJwt(token);
      console.log('payload === >>', payload);
      if (!payload?.exp) return true;
      return payload.exp < Date.now() / 1000;
    } catch {
      return true;
    }
  };

  // AccessToken 갱신
  const refreshAccessToken = async (): Promise<string | null> => {
    const refreshToken = getRefreshToken();
    console.log('refreshAccessToken ==>>>' , refreshToken);

    if (!refreshToken) throw new Error('No refresh token found');

    try {
     const resp = await ofetch.raw(REFRESH_URL, {
          method: 'POST',
          headers: {
            'Content-Type': 'application/json',
            [CONST.HEADER_KEYS.REFRESH]: refreshToken,
          },
        });

      const newAccessToken = resp.headers.get(CONST.HEADER_KEYS.ACCESS);
      const newRefreshToken = resp.headers.get(CONST.HEADER_KEYS.REFRESH);
      if (newAccessToken) setAccessToken(newAccessToken);
      if (newRefreshToken) setRefreshToken(newRefreshToken);

       return newAccessToken;
    } catch (err) {
      console.error('Failed to refresh token', err);
      clearTokens();
       throw err;
    }

  };

  // UserClaim 체크
  const checkUserClaim = (): boolean => {
    const storedData = localStorage.getItem('platform-user-claim');
    return !!storedData && !!JSON.parse(storedData);
  };

  // 공통 요청
  const request = async (
    url: string,
    method: 'GET' | 'POST' | 'PUT' | 'DELETE',
    data?: any,
    options?: { fileFlag?: boolean; skipLoading?: boolean; keepLogin?: boolean; userClaimCheck?: boolean }
  ) => {
    const skipLoading = options?.skipLoading || url.includes('/common') || url.includes('/public');

    if (!skipLoading) loadingStore.increaseLoadingState();

    let accessToken = getAccessToken();
    let refreshToken = getRefreshToken();

    // 유저 클레임 검증
    if (options?.userClaimCheck && !checkUserClaim()) {
      loadingStore.resetLoadingState();
      return Promise.reject(new Error('UserClaim is null'));
    }

    // accessToken이 없거나 만료된 경우 → refresh 시도
    if (!accessToken || isTokenExpired(accessToken)) {
      try {
        // refreshToken이 존재하면 refresh 시도
        if (refreshToken) {
          const  newAccessToken  = await refreshAccessToken();
          if (newAccessToken) {
            accessToken = newAccessToken; // 🔹 로컬 변수에 갱신된 토큰 반영
          }
        } else {
          throw new Error('No refresh token');
        }
      } catch (err) {
        // refresh 실패 → 세션 만료 처리
        if (!skipLoading) loadingStore.resetLoadingState();
        clearTokens();
        dialogStore.openDialog({ text: t('MSG.SESSION.EXPIRE') }, () => router.push('/login'));
        return;
      }
    }

    try {
      const headers: Record<string, string> = {};


      // 파일 업로드/일반 요청 헤더 처리
      let bodyData = data;
      if (options?.fileFlag && data instanceof FormData) {
        // multipart/form-data인 경우 자동 설정되므로 Content-Type 제거
        delete headers['Content-Type'];
      } else if (!options?.fileFlag) {
        headers['Content-Type'] = 'application/json';
        bodyData = JSON.stringify(data);
      }


      // 공통 헤더 설정
      if (cookies?.cookie) headers['cookie'] = cookies.cookie;
      if (accessToken) headers[CONST.HEADER_KEYS.ACCESS] = accessToken;
      if (options?.keepLogin) headers[CONST.HEADER_KEYS.KEEP_LOGIN] = 'Y';


      //실제 요청
      const resp = await ofetch(url, {
        method,
        headers,
        body: method !== 'GET' ? bodyData : undefined,
        params: method === 'GET' ? data : undefined,
        onResponse({ response }) {
           // 백엔드가 새 토큰을 내려주는 경우 갱신
          const newAccessToken = response.headers.get(CONST.HEADER_KEYS.ACCESS);
          const newRefreshToken = response.headers.get(CONST.HEADER_KEYS.REFRESH);
          if (newAccessToken) setAccessToken(newAccessToken);
          if (newRefreshToken) setRefreshToken(newRefreshToken);
        },
      });

      if (!skipLoading) loadingStore.resetLoadingState();

      return resp?._data?.map || resp?._data?.list || resp?._data?.result || resp?._data || resp;
    } catch (error: any) {
      if (!skipLoading) loadingStore.resetLoadingState();
      const status = error?.response?.status;


       // 상태코드별 처리
      if (status === 401) {
        clearTokens();
        dialogStore.openDialog({ text: t('MSG.SESSION.EXPIRE') }, () => router.push('/login'));
      } else if (status === 403) {
          notificationsStore.pushNotification({ 
            type: 'error', 
            text: t('MSG.FUNC.NOT.AUTH') 
          });
      } else if (status === 413) {
          notificationsStore.pushNotification({
            type: 'error',
            text: t('MSG.FILE.LARGE'),
          });
      } else {
          notificationsStore.pushNotification({ 
            type: 'error', 
            text: `${error}` 
          });
      }

      throw error;
    }
  };

  // API 모듈 호출
  const callApiModule = async <T extends keyof ApiInstance, U extends keyof ApiInstance[T]>(
    moduleKey: T,
    method: U,
    ...args: any[]
  ) => {
    // 예외 API는 UserClaim 없어도 호출 가능
      const exceptionMethods = ['getUserClaim', 'getPersonalization', 'login', 'mfa'];
    if (!exceptionMethods.includes(String(method)) && !checkUserClaim()) {
      loadingStore.resetLoadingState();
      return Promise.reject();
    }

    const moduleFn = $api[moduleKey][method] as Function;
    const resp = await moduleFn(
      {
        headers: {
          ...cookies,
          ...(getAccessToken() ? { [CONST.HEADER_KEYS.ACCESS]: getAccessToken() } : {}),
        },
      },
      ...args
    );


    // MFA 처리 시 token 저장
    if (method === 'mfa') {
      const newToken = resp.headers?.get(CONST.HEADER_KEYS.ACCESS);
      if (newToken) setAccessToken(newToken);
    }

    // UserClaim 저장 (예: getUserClaim 호출 시)
    if (method === 'getUserClaim') {
      const userClaimData = resp?._data?.result || resp?._data;
      if (userClaimData) {
        localStorage.setItem('platform-user-claim', JSON.stringify(userClaimData));
      }
    }

    const newAccessToken = resp.headers?.get(CONST.HEADER_KEYS.ACCESS);
    const newRefreshToken = resp.headers?.get(CONST.HEADER_KEYS.REFRESH);
    if (newAccessToken) setAccessToken(newAccessToken); 
    if (newRefreshToken) setRefreshToken(newRefreshToken);

    if (method === 'logout') {
      clearTokens();
    }

    return resp?._data?.map || resp?._data?.list || resp?._data?.result || resp?._data || resp;
  };

  return {
    get: (url: string, params?: any, options?: any) => request(url, 'GET', params, options),
    post: (url: string, body?: any, options?: any) => request(url, 'POST', body, options),
    put: (url: string, body?: any, options?: any) => request(url, 'PUT', body, options),
    delete: (url: string, params?: any, options?: any) => request(url, 'DELETE', params, options),
    call: callApiModule,
  };
}