이제까지 리액트 프로젝트를 하면서 가장 고정적으로 쓰였던 리액트 외의 라이브러리라고 한다면 단언컨데 axios일 것이다.

axios는  XMLHttpRequest를 기반으로 만들어진 라이브러리로, 우리가 이제까지 귀찮게 http요청을 보내면서 하던 인터셉터, timeout 등의 설정을 간단하게 구현할 수 있기 때문에 확실히 개발비용을 줄여주던 라이브러리이다.

하지만 이번에 Nextjs 프로젝트를 시작하면서 예상치 못하게 axios의 필요성에 대해서 다시금 생각해보았다. XMLHttpRequest의 경우 매우 오래된 http 요청이기 때문에 상대적으로 최근에 나온 fetch api의 성능이 좋아 굳이 XMLHttpRequest를 써야 하나?에 대한 의문을 가지는 사람이 많아졌다.

특징XMLHttpRequestfetch
작동 방식콜백 기반Promise 기반
코드 간결성상대적으로 복잡간단하고 직관적
Error Handling자동으로 에러 처리수동으로 response.ok 확인 필요
지원 범위오래된 브라우저도 지원최신 브라우저에서만 지원
스트림 처리제한적응답 데이터를 스트림으로 처리 가능
Progress 이벤트onprogress로 지원기본적으로 미지원 (대안: ReadableStream)
CORS제한적더 강력한 CORS 제약 조건
Request Cancellationabort 메서드 지원AbortController로 취소 가능
위와같이 XMLHttpRequest(XHR)은 상대적으로 오래된 웹 API인 만큼, 이에 대한 단점이 확실히 존재했다.

1. 요청과 응답의 강결합

XMLHttpRequest는 입력과 출력, 상태를 전부 하나의 객체로 다루고, 이벤트 기반으로 동작하는 모델이다. 이는 곧 응답과 요청부가 강하게 결합한다는 소리이다.

const xhr = new XMLHttpRequest();
xhr.open("GET", "https://example.com/data", true);
xhr.onreadystatechange = function () {
  if (xhr.readyState === 4 && xhr.status === 200) {
    console.log(xhr.responseText);
  }
};
xhr.send();
 

위 코드를 보면 정상적으로 XMLHttpRequest를 보내는 코드인데, 요청을 하는 부분과 응답을 받는 부분이 readyState인데, 숫자 코드로 구분한다.

  • UNSENT (숫자 0) : XMLHttpRequest 객체가 생성됨.  - OPENED (숫자 1) : open() 메소드가 성공적으로 실행됨.  - HEADERS_RECEIVED (숫자 2) : 모든 요청에 대한 응답이 도착함.  - LOADING (숫자 3) : 요청한 데이터를 처리 중임.  - DONE (숫자 4) : 요청한 데이터의 처리가 완료되어 응답할 준비가 완료됨. 이렇게 onreadystatechange에 이벤트를 등록해놓으면, 요청을 보낼 때와 요청에 대한 응답을 받을 때 0~4의 숫자 코드가 각 상태를 의미하기 때문에 이는 곧 해당 상태에 해당하는 변경이 이루어졌을 경우 무조건 실행된다. 즉, 무조건 요청 및 응답을 받는 과정에서 5번의 함수 호출이 이루어진다는 의미이다. 따라서 요청과 응답이 하나의 이벤트로 강결합되어있는 상태이기 때문에 이를 떼어놓을 방법이 없다.

2. 이벤트 기반 처리의 복잡성

이벤트 기반의 콜백 함수를 등록해놓는 방식은 현대의 Promise를 처리하는 방법과는 괴리가 있다. async/await 문법이나 then/catch와 같은 문법을 이용하지 않는 방식은 비동기 코드를 처리함에 있어 보다 가독성이 떨어질 수 있는 가능성이 있다.

또한 콜백을 넣어 해당 이벤트에 대해 감지하고 콜백함수를 실행하는 형태는 다중 요청이나 처리가 필요할 경우 콜백지옥, 즉 아도겐 코드가 만들어질 가능성이 있다.

이러한 문제를 axios라이브러리가 Promise 기반으로 사용할 수 있게 해주고는 있으나 솔직히 라이브러리를 안 쓰고 fetch를 쓰면 안되나? 하는 생각이 있다.

이 외에도 XMLHttpRequest는 브라우저 기반의 API이기 때문에 서버사이드에서는 작동할 수 없다는 점도 굳이 Nextjs 환경에서 XMLHttpRequest 기반의 axios를 사용하는 것에 대해 더 필요성을 못 느꼈던 것 같다.

하지만 이중에도 가장 중요한 것은.. XMLHttpRequestStreaming을 지원하지 않는다(두둥) ai 소설 생성은 ai에게 이전 소설과 다음 소설의 내용을 이어지게 하면서 전개를 할 수 있도록 프롬프트를 쓰고 ai에게 받아온 데이터를 바로 반영해야 한다. 요청이 끝난 후 ui상의 업데이트가 일어난다면 유저는 소설 전개 버튼을 누르고 한참동안 로딩중 상태를 봐야한다.

그렇기 때문에 ai API를 사용하다보면 http streaming의 형태로 많이 주는 것을 볼 수 있는데, 막상 axios로 사용하려고 보니 스트리밍이 되질 않았다. 찾아보니 XMLHttpRequest에서는 원래 지원되지 않는다고 한다… 이제는 정말 떠나 보내야 하는가… XHR..

아무튼 이러한 이유로 나는 fetch API를 활용해보자! 라는 생각을 하게 되었다. 그러면서 API 요청을 어떻게 하면 조금 더 쉽고 잘 만들 수 있을까에 대한 고민을 하게 되었다.

fetch 기반의 라이브러리, ky

XMLHttpRequest기반의 http 요청에서 axios가 대표적인 라이브러리라면, fetch 기반의 요청에서는 ky가 요즘 점점 많이 사용하는 추세인 것 같다.

무려 위클리 다운로드 240만이라는 엄청난 인기를 가지고 있다.

그렇다면 이 ky는 왜 이렇게 인기가 많은걸까?

Ky is a tiny and elegant HTTP client based on the Fetch API

npm에서 Ky를 찾아보면 Ky는 라이브러리의 정체성을 위와 같이 정의했다. 여기서 중요하게 볼 점은 tiny, 즉 fetch 기반의 요청 라이브러리임에도 작은 크기를 가진다는 점을 중요시 여기는 것 같다.

실제로도 파일 사이즈부터 axios와는 매우 큰 차이를 가진다.

라이브러리크기
axios2.14MB
ky158KB

사용법 또한 tiny하다(간편하다는 뜻)

class HTTPError extends Error {}
 
const response = await fetch('https://example.com', {
	method: 'POST',
	body: JSON.stringify({foo: true}),
	headers: {
		'content-type': 'application/json'
	}
});
 
if (!response.ok) {
	throw new HTTPError(`Fetch error: ${response.statusText}`);
}
 
const json = await response.json();
 
console.log(json);
//=> {data: '🦄'}

원래대로라면 fetch에 대해서 오는 json 데이터를 다시금 변환해주고 에러의 처리가 한 단계 더 필요하지만, ky는 이러한 과정을 간편하게 사용할 수 있도록 사용성을 높였다.

import ky from 'ky';
 
const json = await ky.post('https://example.com', {json: {foo: true}}).json();
 
console.log(json);
//=> {data: '🦄'}

열 줄이 넘던 데이터 페칭이 한 줄로 줄었기에 훨씬 보기 좋은 코드를 작은 크기의 라이브러리 하나로 처리할 수 있다는 점은 굉장한 메리트로 다가온다고 생각한다. 이 외에도 retry, hooks와 같은 기능들은 데이터 페칭 과정에서 조금 더 유연하고 쉽게 처리하기 때문에 사실 안 쓸 이유가 없다.

나 또한 이러한 ky를 해당 프로젝트에 도입해보는건 어떨까 생각했었다. 하지만 이를 도입하기에는 굳이?라는 마음이 들기도 했다. 왜냐하면

  1. 해당 프로젝트에서는 서버 상태 관리를 위해 react-query(Tanstack Query)를 도입했는데, 서버 상태 관리를 query가 도와줄 수 있었다. 따라서 ky에서 사용하는 retry와 같은 기능들이 react-query에도 존재했기 때문에 굳이 같은 기능을 가지는 라이브러리를 여러개 두는 것은 오히려 사이즈만 키울 뿐이라고 생각했다.
  2. fetch API를 사용할 때의 사용성 같은 부분은 기존의 fetch API를 이용하여 개선은 해도 크게 시간적 낭비가 이루어질 정도의 시간은 아니라고 생각했다. 또한 직접 필요한 옵션들만 넣은 코드를 작성하면 사용성을 올리면서도 사이즈는 더 줄일 수 있겠다고 생각했다. 와 같은 이유가 있었다.

그렇기 때문에 나는 react-qeury를 통해서 서버 상태를 조금 더 면밀하게 관리할 수 있도록 하는 동시에 fetch API의 사용성을 조금 늘리는 방식을 고민했는데, 이 때 생각이 났던 것이 필더 패턴이었다.

Builder 패턴을 이용해 API 클래스 만들기

fetch를 사용하여 http 요청을 보내려고 하는 만큼, 불편한 점이 있었다. 기존에 axios를 주로 쓰던 나는 간단하게 axios.create 메서드를 통해 간단하게 인스턴스를 만들어내고, 인터셉터를 적용하고, 헤더 설정을 이루어냈다. 이는 모두 axios가 이러한 XMLHttpRequest에 대해서 추상화하고 단순한 XHR 래퍼에서 더 개선을 이루어냈기에 편리함을 통해 데이터 페칭을 가독성있고 편하게 만들어낼 수 있었던 덕이었다.

기존의 fetch 또한 쉽게 만들어 낼 수 있었지만, 각각의 request에 대해 header를 바꾸어야 하고, 인터셉터와 같은 기능 또한 부재했기에 불편한 점이 있었다. 따라서 나는 기존의 axios를 떠나보내고 빌더 패턴을 사용하여 fetch를 해주는 클래스를 구성하려고 한다.

import { OutgoingHttpHeaders } from "http";
 
type HTTPParams = Array<string> | string;
 
class API {
  baseURL: string;
  headers?: OutgoingHttpHeaders;
  params?: HTTPParams;
  endPoint?: string;
  withCredentials?: boolean;
  constructor(baseURL: string) {
    this.baseURL = baseURL;
  }
 
  getParams() {
    if (this.params) {
      return Array.isArray(this.params)
        ? this.params.join("/")
        : `/${this.params}`;
    }
    return "";
  }
  getEndPoint() {
    if (this.endPoint) {
      return this.endPoint;
    }
    return "";
  }
  get() {
    const params = this.getParams();
    const url = this.baseURL + this.endPoint + params;
    const options: RequestInit = {
      credentials: this.withCredentials ? "include" : "omit",
      headers: this.headers as HeadersInit,
    };
 
    return fetch(url, options);
  }
  post(data: BodyInit) {
    const params = this.getParams();
    const url = this.baseURL + params;
    const options: RequestInit = {
      method: "POST",
      credentials: this.withCredentials ? "include" : "omit",
      headers: this.headers as HeadersInit,
      body: data,
    };
    return fetch(url, options);
  }
 
  delete() {
    const params = this.getParams();
    const url = this.baseURL + params;
    const options: RequestInit = {
      method: "DELETE",
      credentials: this.withCredentials ? "include" : "omit",
      headers: this.headers as HeadersInit,
    };
    return fetch(url, options);
  }
 
  put(data: BodyInit) {
    const params = this.getParams();
    const url = this.baseURL + params;
    const options: RequestInit = {
      method: "PUT",
      credentials: this.withCredentials ? "include" : "omit",
      headers: this.headers as HeadersInit,
      body: data,
    };
    return fetch(url, options);
  }
}

일단 API에 대한 인스턴스는 위와 같이 만들었다. 일단은 자주 쓰는 get, post, put, delete정도만 만들어 놓았고, 나중에 서버에서 http 메서드가 추가되면 그때 하나씩 추가할 예정이다.

일반적인 각 메서드는 axios와 비슷하게 쓸 수 있도록 하고 싶어 이와 같이 직접적으로 fetch함수에 빌더 패턴을 이용해서 만든 인스턴스의 설정값을 넣어 반환하게 해 주었다.

그렇다면 이를 만들어 주는 객체의 빌더는 어떻게 만들어야 할까?

class APIBuilder {
  private _instance: API;
 
  constructor(baseURL: string) {
    this._instance = new API(baseURL);
    return this;
  }
 
  headers(headerOptions: OutgoingHttpHeaders): APIBuilder {
    this._instance.headers = headerOptions;
    return this;
  }
 
  params(params: HTTPParams): APIBuilder {
    this._instance.params = params;
    return this;
  }
  endPoint(endPoint: string): APIBuilder {
    this._instance.endPoint = endPoint;
    return this;
  }
 
  withCredentials(withCredentials: boolean): APIBuilder {
    this._instance.withCredentials = withCredentials;
    return this;
  }
 
  build(): API {
    return this._instance;
  }
}
 
export default APIBuilder;
 

빌더의 경우 인스턴스를 private으로 지니고 있으며, 각 객체의 속성값을 메서드로 하여 하나씩 구성해나갈 수 있도록 구조를 짰다. 이렇게 다른 설정값을 넣고 마지막에 build()를 실행시키면 가지고 있던 API의 인스턴스를 반환하여 그대로 사용할 수 있도록 하였다.

개선하기 : 요청부의 반복되는 코드 줄이기와 인터셉터 구현하기

위 API 클래스의 코드를 보면 각 메서드마다 넣는 내용은 메서드를 제외하고 다르지 않다 그렇기 때문에 이러한 반복되는 부분을 request 메서드를 따로 둔 후에 http 메서드만 인자로 받아 요청을 보내는 식으로 개선했다.

이렇게 개선하던 중 http 요청을 하는 메서드가 따로 나눠지면서 여기에 인터셉터를 적용해봐도 괜찮지 않을까? 생각하여 인터셉터도 함께 구현하게 되었다.

type RequestBody = Record<string, unknown>;
type RequestFunction = () => Promise<Response>;
type ResponseMiddleware<T> = (
  data: T,
  request: RequestFunction
) => T | Promise<T>;
type RequestMiddleware<T> = (config: T) => T;
 
class API {
  baseURL: string;
  headers?: HeadersInit;
  params?: HTTPParams;
  timeOut?: number;
  endPoint?: string;
  withCredentials?: boolean;
  serveraction?: boolean;
  use: {
    request: RequestMiddleware<RequestInit>;
    response: ResponseMiddleware<Response>;
  };
 
  constructor(baseURL: string) {
    this.baseURL = baseURL;
    this.use = {
      request: (config) => config,
      response: async (response, requestConfig) => response,
    };
  }
 
  getParams() {
    if (this.params) {
      return Array.isArray(this.params)
        ? this.params.join("/")
        : `/${this.params}`;
    }
    return "";
  }
 
  async request(method: HttpMethod, data?: RequestBody) {
    const params = this.getParams();
    const fetchFunction = async () => {
      const url = this.baseURL + this.endPoint + params;
      let options: RequestInit = {
        method,
        credentials: this.withCredentials ? "include" : "omit",
        headers: this.headers as HeadersInit,
        ...(data && { body: JSON.stringify(data) }),
      };
 
      options = this.use.request(options);
 
      if (this.serveraction) {
        return callServerAction(url, options);
      }
      return fetch(url, options);
    };
 
    const response = await fetchFunction();
    return this.use.response(response, fetchFunction);
  }
  ...

인터셉터는 요청과 응답 과정에서 이를 가로채 중간에 추가적인 작업을 하는 함수를 이벤트처럼 등록해놓는다.

instance.interceptors.request.use(
  (config) => {
    // getToken() - 클라이언트에 저장되어 있는 액세스 토큰을 가져오는 함수
    const accessToken = getToken();
 
    config.headers['Content-Type'] = 'application/json';
    config.headers['Authorization'] = `Bearer ${accessToken}`;
 
    return config;
  },
  (error) => {
    console.log(error);
    return Promise.reject(error);
  }
);

그래서 대부분 401 에러가 떴을 때, 기존에 가지고 있던 액세스 토큰을 다시금 발급받아 넣는 로직을 인터셉터에 등록하여 계속해서 로그인이 유지될 수 있도록 하는 로직을 인터셉터로 많이 작성했었다. 이러한 인터셉터는 클라이언트 - 서버 / 서버 - 클라이언트의 요청/응답 중간에 끼어 있는 콜백함수이므로 미들웨어처럼 다룬다. 이를 어떻게 하면 비슷하게 구현할 수 있을까 생각하던 중, 요청과 응답에 하나씩 등록해놓을 수 있도록 use라는 속성 안에 request와 response를 하나씩 두어 콜백함수를 등록시킨 뒤, 해당 콜백함수로 요청의 경우엔 요청 데이터, 응답의 경우엔 기존 응답과 401 에러처럼 다시금 이전에 보냈던 요청을 다시금 보낼 수 있도록 http 요청 함수를 주입하여 다시 실행시킬 수 있도록 했다.

const novelAIServer = new APIBuilder(process.env.NEXT_PUBLIC_API_URL as string)
  .endPoint("/process-input")
  .withCredentials(true)
  .headers({
    "Content-type": "application/json",
  })
  .build();
 
novelAIServer.use.response = async (response, requestFunction) => {
  if (response.status === 401) {
    const supabase = createClient();
 
    console.warn("⚠️ 401 Unauthorized - Refreshing token...");
 
    const { data, error } = await supabase.auth.refreshSession();
    if (error || !data.session) throw new Error("세션 갱신 실패");
 
    const newAccessToken = data.session.access_token;
    novelAIServer.headers = {
      ...novelAIServer.headers,
      Authorization: `Bearer ${newAccessToken}`,
    };
    console.log("✅ 토큰 갱신 성공, 재시도");
 
    return requestFunction();
  }
  return response;
};
 

이렇게 만들어진 빌더와 해당 인스턴스에 인터셉터를 등록할 때는 이런 식으로 인스턴스의 속성에 접근해 각 인터셉터를 설정해주면 된다.

하지만 여기에서 잘못 생각한 점이 하나 있는데, 빌더 패턴을 통해 baseUrlendpoint를 함께 넣어주게 되면, 해당 baseUrl+endpoint에 요청을 보내는 인스턴스가 만들어지게 되고, 이 인스턴스에 대해 각 http 메서드를 제공하는 나의 방식은 조금 사용성에 문제가 있다.

빌더 패턴으로 각 인스턴스를 만들게 되면 하나의 http 요청을 위한 객체가 만들어지는 셈이다. 하지만 그럼에도 불구하고 하나의 엔드포인트, 즉 리소스에 대해서 많은 http 메서드를 사용하는 경우가 그렇게 많지는 않다. 그렇기에 각 엔드포인트마다 인스턴스를 만들고 해당 인스턴스에 대해서 http 요청만 하게 되면 하나의 요청을 하기 위해 하나의 인스턴스를 만드는 느낌이다. 배보다 배꼽이 더 큰 느낌이랄까.. 이러한 문제는 오히려 기존 fetch보다 사용성을 더 떨어뜨릴 뿐만 아니라 보일러 플레이트와 생성된 인스턴스가 오히려 필요없는 메모리를 너무 많이 잡아먹는 듯한 느낌 또한 준다.

그렇기에 기존의 코드에서 조금 더 개선하여 엔드포인트의 경우 나중에 http 메서드를 요청할 때 함께 넣는 axios의 방식과 유사하게 요청을 보낼 수 있도록 하면 어떨까 싶었다.

class API {
  baseURL: string;
  headers?: HeadersInit;
  timeOut?: number;
  withCredentials?: boolean;
  serveraction?: boolean;
  use: {
    request: RequestMiddleware<RequestInit>;
    response: ResponseMiddleware<Response>;
  };
 
  constructor(baseURL: string) {
    this.baseURL = baseURL;
    this.use = {
      request: (config) => config,
      response: async (response) => response,
    };
  }
 
  async request(method: HttpMethod, endpoint?: string, data?: RequestBody) {
    const fetchFunction = async () => {
      const url = this.baseURL + endpoint;
      let options: RequestInit = {
        method,
        credentials: this.withCredentials ? "include" : "omit",
        headers: this.headers as HeadersInit,
        ...(data && { body: JSON.stringify(data) }),
      };
 
      options = this.use.request(options);
 
      if (this.serveraction) {
        return callServerAction(url, options);
      }
      return fetch(url, options);
    };
 
    const response = await fetchFunction();
    return this.use.response(response, fetchFunction);
  }
 
  get(endPoint?: string, data?: RequestBody) {
    return this.request("GET", endPoint, data);
  }
  post(endPoint?: string, data?: RequestBody) {
    return this.request("POST", endPoint, data);
  }
  delete(endPoint?: string, data?: RequestBody) {
    return this.request("DELETE", endPoint, data);
  }
  put(endPoint?: string, data?: RequestBody) {
    return this.request("PUT", endPoint, data);
  }
}

이런 식으로 엔드포인트를 나중에 주입하여 서버의 baseURL정도만 설정한 뒤 다른 해당 서버로 보내는 요청에 대하여 미리 만들어둔 baseURL을 가진 인스턴스를 활용하는 방식으로 개선해보았다.

export const novelAIServer = new APIBuilder(
  process.env.NEXT_PUBLIC_API_URL as string
)
  .withCredentials(true)
  .headers({
    "Content-type": "application/json",
  })
  .build();

그렇게 개선된 버전의 빌더는 이런 식으로 처음에 baseURL만 주입하여 객체를 생성하고, 이후 해당 baseURL에 http 메서드 인자로 엔드포인트를 넣어 다양한 요청에 넓게 활용할 수 있도록 하였다.

export function processNovel(
  session: Session | null,
  novelId: string,
  prompt: string
) {
  return novelAIServer.post("/process-input", {
    user_id: session?.user.id,
    novel_id: novelId,
    text: prompt,
  });
}
 

이렇게 만들어진 서버의 인스턴스는 가장 프로젝트에서 api를 담당하는 디렉토리의 색인파일로 넣어놓고 import해와서 사용하면 거의 axios를 쓰는 것처럼 조금 더 편하게 사용할 수 있었다.

let response = await fetch(`${API_URL}${input}`, {
    ...init,
    headers: {
      ...init?.headers,
      "Content-Type": "application/json",
      Authorization: `Bearer ${accessToken}`,
    },
    credentials: "include",
  });

이전에 사용하던 fetch API와 비교해보자. 나의 경우에는 fetch의 두번째 인자로 헤드를 포함한 모든 옵션들이 하나의 RequestInit이라는 타입으로 들어가 있었고, 여기에서 하나씩 설정해 주는 것보다는 빌더 패턴을 이용해 하나씩 필요한 부분을 메서드로 확실하게 구분하고 점층적으로 객체를 만들어 나간다는 개념 자체가 훨씬 확실하게 어떤 옵션을 추가적으로 주입하는지에 대해서 명확히 알 수 있었기 때문에 API를 관리할 수 있겠다라는 느낌을 받았다.

하지만 물론 빌더 패턴이 장점만 있는 것은 아니다. 빌더 클래스와 해당 빌더를 통해 만들어지는 클래스 또한 구현해야 하다보니 보일러플레이트가 꽤나 길어지는 문제가 있었고, 이러한 문제는 코드의 복잡성 증가로 이어질 수도 있겠다는 생각을 했었다.

import { createClient } from "@/utils/supabase/client";
 
export async function fetchWithAuth(input: RequestInfo, init?: RequestInit) {
  const API_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000";
  const supabase = createClient();
  const session = await supabase.auth.getSession();
 
  if (!session?.data?.session) throw new Error("로그인이 필요합니다.");
 
  const accessToken = session.data.session.access_token;
 
  let response = await fetch(`${API_URL}${input}`, {
    ...init,
    headers: {
      ...init?.headers,
      "Content-Type": "application/json",
      Authorization: `Bearer ${accessToken}`,
    },
    credentials: "include",
  });
 
  if (response.status === 401) {
    console.warn("401 Unauthorized - Refreshing token...");
 
    const { data, error } = await supabase.auth.refreshSession();
    if (error || !data.session) throw new Error("세션 갱신 실패");
 
    return fetch(`${API_URL}${input}`, {
      ...init,
      headers: {
        ...init?.headers,
        Authorization: `Bearer ${data.session.access_token}`,
      },
      credentials: "include",
    });
  }
  return response;
}
 

그래서 나는 처음에는 어차피 필요한 인터셉터는 현재 401 에러를 위한 리프레시 토큰 로직밖에 없을 것 같아 빌더 패턴이 아닌 고차함수 방식으로 API를 관리했었다. 현재 프로젝트의 사이즈를 고려한다면, 위와 같은 방식이 조금 더 맞을 수 있을 것 같다.

하지만 이전에 우아한 타입스크립트 with 리액트를 공부하면서 빌더 패턴을 이용한 api 관리 로직을 설계했었고(물론 해당 글은 axios 인스턴스에 대한 빌더였다) 이를 보면서 생각보다 쓸모가 있지만 정말 실용적일까라는 의문을 가졌다. 그러다보니 이번에 직접 빌더 패턴을 이용해 구현하면서 빌더 패턴의 장점과 단점에 대해서 조금 더 가까이 경험하고 느껴볼 수 있었고, 나중에 확장할 때도 현재 만들어놓은 빌더 패턴 자체가 사용성이 괜찮다고 생각하기 때문에 조금씩 더 보완하면서 사용을 해보고 이를 기록해볼 것 같다.

아무튼 패턴을 이용하여 API를 관리하면서 기존에 심리적으로 의존적이어서 사용하는게 당연하다고만 생각했던 http 요청 관리 라이브러리에 대해 다시금 생각해볼 기회가 된 것 같다.