Next.js 에서 Fetch를 활용해 커스텀 HTTP Client를 만들자(3)

MountionRiver·2024년 12월 10일

저번 글과 이어집니다.

저번에는 기본적인 에러처리를 통합시켰었다. 이어서 만들어보자

Intercepter 만들기

인터셉터 만들기

인터셉터는 두가지가 있다.

  • response intercepter
  • request intercepter

request intercepter

요청 인터셉터는 HTTP 요청이 발생하기 전에 실행된다. 주로 인증토큰 추가나, 공통헤더 설정 같은 곳에 사용한다.

ex) 인증 토큰 추가

client.interceptors.request.use({
  onFulfilled: (config) => {
    const token = localStorage.getItem('token');
    return {
      ...config,
      headers: {
        ...config.headers,
        Authorization: `Bearer ${token}`
      }
    };
  }
});

ex) 공통 헤더 설정

client.interceptors.request.use({
  onFulfilled: (config) => ({
    ...config,
    headers: {
      ...config.headers,
      'Accept-Language': 'ko',
      'Custom-Header': 'value'
    }
  })
});

response intercepter

서버로부터 응답을 받은 후 실행된다. 에러 처리, 응답 데이터 변환, 인터셉터 체이닝 같은 동작들을 할 수 있다.

ex) 에러처리

client.interceptors.response.use({
  onRejected: async (error) => {
    if (error instanceof APIError && error.status === 401) {
      const newToken = await refreshToken();
      return client.request(error.config);
    }
    throw error;
  }
});

ex)응답 데이터 변환

client.interceptors.response.use({
  onFulfilled: (response) => ({
    ...response,
    data: transformData(response.data)
  })
});

Intercepter 구조 생성하기

1단계: 상단에 Intercepter 정의


export const createClient = (baseConfig: Config = {}): Client => {
  const requestInterceptors: Interceptor<Config>[] = [];
  const responseInterceptors: Interceptor<APIResponse>[] = [];

2단계: 요청 인터셉터 처리 추가

  const request = async <T>(config: Config): Promise<T> => {
  const finalConfig = await requestInterceptors.reduce(async (promise, interceptor) => {
    const conf = await promise;
    return interceptor.onFulfilled?.(conf) ?? conf;
  }, Promise.resolve(config));
};

3단계: 토큰 처리 로직 추가

  const request = async <T>(config: Config): Promise<T> => {
  const finalConfig = /* 인터셉터 처리 */;

  if (!finalConfig.url?.includes("/auth/refresh-token")) {
    const accessToken = await getAccessToken();
    finalConfig.headers = {
      ...finalConfig.headers,
      ...(accessToken && { Authorization: `Bearer ${accessToken}` }),
    };
  }
};

4단계: 타임아웃 처리 추가

  const request = async <T>(config: Config): Promise<T> => {
  const finalConfig = /* 인터셉터 처리 */;
  /* 토큰 처리 */

  const controller = new AbortController();
  const timeoutId = finalConfig.timeout
    ? setTimeout(() => controller.abort(), finalConfig.timeout)
    : undefined;
};

5단계: 실제 요청 로직 추가

  const request = async <T>(config: Config): Promise<T> => {
  const finalConfig = /* 인터셉터 처리 */;
  /* 토큰 처리 */
  /* 타임아웃 설정 */

  try {
    const url = createURL(config.url!, config.params);
    const init = createRequestInit({
      ...finalConfig,
      signal: controller.signal,
    });
    
    return await fetch(url, init).then(res => handleResponse<T>(res, finalConfig));
  }
};

6단계: 리소스 정리 로직 추가

const request = async <T>(config: Config): Promise<T> => {
  const finalConfig = /* 인터셉터 처리 */;
  /* 토큰 처리 */
  /* 타임아웃 설정 */

  try {
    /* 요청 처리 */
  } finally {
    if (timeoutId) clearTimeout(timeoutId);
  }
};

7단계: return 에 인터셉터 객체 넣어주기

  return {
    interceptors: {
      request: requestInterceptors,
      response: responseInterceptors,
    },

    get: (url, config = {}) => request({ ...config, url, method: "GET" }),
    post: (url, data, config = {}) => request({ ...config, url, method: "POST", body: data }),
    put: (url, data, config = {}) => request({ ...config, url, method: "PUT", body: data }),
    patch: (url, data, config = {}) => request({ ...config, url, method: "PATCH", body: data }),
    delete: (url, config = {}) => request({ ...config, url, method: "DELETE" }),
  };

자 이제 마무리 되었다. 아래는 코드의 전문이다.

// src/lib/HttpClient/HttpClient.ts
import { APIResponse, Client, Config, Interceptor } from "@/types/api/httpClient";
import { APIError } from "./error";

export const createClient = (baseConfig: Config = {}): Client => {
  const requestInterceptors: Interceptor<Config>[] = [];
  const responseInterceptors: Interceptor<APIResponse>[] = [];

  const getAccessToken = async () => {
    if (typeof window === "undefined") {
      try {
        // 동적 임포트
        const { cookies } = await import("next/headers");
        return cookies().get("accessToken")?.value;
      } catch {
        return undefined;
      }
    } else {
      const value = `; ${document.cookie}`;
      const parts = value.split(`; accessToken=`);
      if (parts.length === 2) return parts.pop()?.split(";").shift();
      return undefined;
    }
  };

  const createURL = (path: string, params?: Record<string, string>): string => {
    if (!path) throw new Error("URL path is required");

    const baseUrl = baseConfig.baseURL?.replace(/\/+$/, "") ?? "";
    const normalizedPath = path.replace(/^\/+/, "/");
    const fullUrl = `${baseUrl}${normalizedPath}`;

    if (!params) return fullUrl;

    try {
      const url = new URL(fullUrl);
      Object.entries(params)
        .filter(([_, value]) => value != null)
        .forEach(([key, value]) => url.searchParams.append(key, value));
      return url.toString();
    } catch (error) {
      throw new Error(`Invalid URL: ${fullUrl}`);
    }
  };

  const createRequestInit = (config: Config = {}): RequestInit => {
    const isFormData = config.body instanceof FormData;
    const headers = isFormData
      ? Object.fromEntries(
        Object.entries({ ...baseConfig.headers, ...config.headers }).filter(
          ([key]) => key.toLowerCase() !== "content-type",
        ),
      )
      : {
        "Content-Type": "application/json",
        ...baseConfig.headers,
        ...config.headers,
      };

    const init: RequestInit = {
      method: config.method,
      headers,
      credentials: config.credentials,
      signal: config.signal,
      cache: config.cache as RequestCache,
      next: config.next,
    };

    if (config.body != null) {
      init.body =
        config.body instanceof FormData ||
        config.body instanceof Blob ||
        config.body instanceof ArrayBuffer ||
        config.body instanceof URLSearchParams ||
        ArrayBuffer.isView(config.body) ||
        typeof config.body === "string"
          ? (config.body as BodyInit)
          : JSON.stringify(config.body);
    }

    return init;
  };

  const handleResponse = async <T>(response: Response, config: Config): Promise<T> => {
    if (!response.ok) {
      const errorData = await response.json().catch(() => null);

      const error = new APIError(
        response.status,
        config.body || null,
        errorData?.message || `Error ${response.status}`,
        config,
      );

      return responseInterceptors.reduce<Promise<T>>(async (promise, interceptor): Promise<T> => {
        try {
          const value = await promise;
          return value;
        } catch (err) {
          if (interceptor.onRejected) {
            return interceptor.onRejected(err) as Promise<T>;
          }
          throw err;
        }
      }, Promise.reject(error));
    }

    const data = await response.json();
    const apiResponse: APIResponse = {
      data,
      status: response.status,
      headers: response.headers,
    };

    const result = await responseInterceptors.reduce(async (promise, interceptor) => {
      const value = await promise;
      return interceptor.onFulfilled?.(value) ?? value;
    }, Promise.resolve(apiResponse));

    return result.data as T;
  };

 const request = async <T>(config: Config): Promise<T> => {
  const finalConfig = await requestInterceptors.reduce(async (promise, interceptor) => {
    const conf = await promise;
    return interceptor.onFulfilled?.(conf) ?? conf;
  }, Promise.resolve(config));

  // 토큰을 가져와서 헤더에 추가
  if (!finalConfig.url?.includes("/auth/refresh-token")) {
    const accessToken = await getAccessToken();
    finalConfig.headers = {
      ...finalConfig.headers,
      ...(accessToken && { Authorization: `Bearer ${accessToken}` }),
    };
  }

  const controller = new AbortController();
  const timeoutId = finalConfig.timeout
    ? setTimeout(() => controller.abort(), finalConfig.timeout)
    : undefined;

  try {
    const url = createURL(config.url!, config.params);
    const init = createRequestInit({
      ...finalConfig,
      signal: controller.signal,
    });
    
    return await fetch(url, init).then(res => handleResponse<T>(res, finalConfig));
  } finally {
    if (timeoutId) clearTimeout(timeoutId);
  }
};

  return {
    interceptors: {
      request: requestInterceptors,
      response: responseInterceptors,
    },

    get: (url, config = {}) => request({ ...config, url, method: "GET" }),
    post: (url, data, config = {}) => request({ ...config, url, method: "POST", body: data }),
    put: (url, data, config = {}) => request({ ...config, url, method: "PUT", body: data }),
    patch: (url, data, config = {}) => request({ ...config, url, method: "PATCH", body: data }),
    delete: (url, config = {}) => request({ ...config, url, method: "DELETE" }),
  };
};

0개의 댓글