easyfetch

강동욱·2024년 8월 19일
0

라이브러리 만든 계기

Next App Router를 이용해서 처음 팀프로젝트를 진행했는데 기술스택에 관해서 팀원들과 논의를 하던 중 이전 React로만 작업을 했을 때와 같이 Axios를 활용하려 했는데 Next App Router에서 적용하려고 보니 문제점이 있었다. 바로 Next App Router는 자체적으로 fetch를 확장에서 쓰고 있는데 그 예시는 다음과 같다.

const data = await fetch('~~', {
	next: {revalidate: 300}
})

위와 같은 Next 캐싱원리가 궁금하다면 이전에 쓴 글인 캐싱원리 블로그를 참조하면 좋다.

일단 그때는 프로젝트 구현이 우선이였기에 팀원 모두 fetch를 쓰기로 했다.
프로젝트를 구현하면서 fetch를 사용하다보니 다음과 같은 불편함이 존재했다.

  • 클라이언트 컴포넌트에서 interceptor 기능의 부재
  • res.json(), JSON.stringify 직렬화 역직렬화를 매번 사용하는 것
  • 400번 이상의 에러는 res.ok로 분기 처리
  • fetch에서는 사용할 수 없는 axios.post와 같은 API 메서드

interceptor 기능을 제외하고 위와 같은 불편한 사항들은 충분히 코드상에서 함수로 로직을 분리해서 해결할 수 있다. 일단은 위와 같은 불편함은 뒤로 한 채 팀 프로젝트를 완성한 뒤 각자 팀원들끼리 리팩토링 기간을 한달 갖기로 하고 다시 추가기능을 구현하기로 했다.

팀 프로젝트를 진행하면서 라이브러리를 사용할 때나 또는 팀원들의 도움을 받으면서 가장 멋있고 코딩에 매력을 느낀점이 딱 한가지 있다. 내 코드가 간결해지는 것이다. 누군가 또는 내 자신이 복잡한 로직에서 보기 쉬운 코드를 만들었을 때 가장 자극을 받고 코딩의 매력을 느끼는 것 같다. 즉 불편을 해소했을때 라고도 말할 수 있겠다.

그래서 이번 리팩토링 기간 한달동안 우리팀의 불편을 해소하고 한단계 성장하고 싶어서 라이브러리를 만들기로 결심했다.

자료조사

일단 내가 뭘 만들고 싶은지 명확히 정해야 했다.

Next App Router에서 Axios과 같이 사용하는 Fetch 라이브러리 만들기

명확히 정했으니 이와 비슷한 라이브러리를 누가 만들었는지 찾아봤다.
2가지가 있었는데 하나는 우리가 아는 Axios고 나머지 하나는 바로 return-fetch이다. 솔직히 return-fetch라는 라이브러리를 보면서
axios.get, axios.post와 같은 기능을 제외하면 내가 생각했던 문제들이 해결될 수 있었고 라이브러리를 만드려면 이정도까지 구현을 해야하구나 하면서 대단하신 분들은 정말 많다는것을 생각하며 살짝 벽을 느끼기도 했다. 하지만 이런데서부터 맘 약해지면 나의 성장은 끝날것 같았기 때문에 다시 정신을 차렸다!!! 이미 누가 기능을 구현해도 거기에서 도움을 받아 또 나만의 방식으로 코드를 구성하면 그것 또한 도움이 될 것 같아서 본격적으로 Axios, return-fetch, fetch, promise를 공부해 나갔다.

과정

이번 코드를 작성할 때는 Class를 이용했다.그 이유는 코드를 함수를 구성했을 때 보다 코드의 흐름이 직관적일 것 같아서 선택을 했다.

어떤 함수를 호출하면 해당 기능의 인스턴스를 제공해주는 함수를 만드려고했다. 라이브러리 이름은 easyFetch이므로 다음과 같이 사용하기로 했다.

const easy = easyFetch({
	baseUrl: 'https://attraction/'
  	headers: {}
})

easy.get('api/v1/user')
easy.post('api/v1/user')

easyFetch라는 함수는 default 설정을 인자로 받아 인스턴스에게 전달해주는 역할을 한다.

axios.get과 같은 API메서드 구현기

나는 솔직히 이 부분에서는 별거 없을 것 같았는데 되게 괜찮은 구현방법을 발견하게 된다. 원래 기본 생각은 다음과 같았다.

class EasyFetch{
  #baseUrl: string| URL | undefined;
  #headers: HeadersInit | undefined;
	constructor() {
    	this.#baseUrl = defaultConfig?.baseUrl;
    	this.#headers = defaultConfig?.headers;
    }

	get() {}
	delete() {}
	post() {}
	patch() {}
	put() {}
}

하지만 Axios 오픈소스 코드를 분석해본 결과 다음과 같은 코드를 발견할 수 있었다.

일단 설명하기전에 오픈소스코드 분석이 처음인 나에겐 누군가의 코드를 읽는다는 것이 쉽지 않았는데 이번 기회를 통해 오픈소스 코드들을 분석하면서 코드를 읽는 것이 쉬워졌고 무엇보다 위와 같은 코드를 접하면서 방구석에서만 코딩을 해도 코드를 생각하는 시야가 넓어졌다.

간단히 설명하자면 axios.get 과 같은 코드들은 클래스의 메서드로 직접 명시되는것이 아닌 prototype을 사용해서 동적으로 메서드 코드들을 간편하게 작성하고 있다.

프로토타입은 JS에서는 모든 객체들은 부모객체와 연결되어져있는데 객체 지향에서의 상속과 같이 부모의 메서드를 자식도 사용할 수 있다. 이때의 부모객체를 프로토타입이라고 한다. 그림으로 보면 더 쉽다.

const easy = new EasyFetch()

easy라는 객체에 console을 찍어보면 Protoype이라는 슬롯을 확인할 수 있다. 이러한 슬롯이 상위의 부모 프로토타입을 연결시켜주고 이것을 프로토타입 체이닝이라고 한다. 이러한 개념을 알아두고 Axios코드로 돌아가보면 그림과 똑같은 문법을 하나 찾아볼 수 있다. 바로 Easyfetch.prototype이다. 이것을 활용해서 프로토타입의 메서드들을 확장해주면 굳이 class에서 메서드들을 정의를 안해도 easy라는 객체도 사용할 수 있다. 왜냐하면 easy 객체 Prototype 슬롯에 EasyFetch.Prototype이 바인딩이 되어있기 때문이다. 자바스크립트의 기본이 중요하다는것을 다시 한번 뼈저리게 느낀다

적용

바로 적용을 해봤더니 그럼 그렇지 순탄치 않을줄 알았다. 바로 path,put,post,get,delete프로퍼티는 EasyFetch 타입(정확히 말하면 EasyFetch 인스턴스 타입)의 할당할 수 없다는 것이다. 어찌보면 당연한것이 ts에서는 class 문법을 사용하면 EasyFetch 클래스의 인스턴스 타입은 constructor의 정의된 프로퍼티와 static이 아닌 class 메서드들로 이루어진 인덱스 타입이다. 그런데 현재 나는 직접적으로 path,put,post,get,delete를 클래스 내부에 메서드로 설정하고 있지 않아서 위와같은 타입오류가 발생한것 이다.

이를 해결하기 위해 타입 단언인덱스 시그니처를 사용해서 해결을 했다.

const METHOD_WITHOUT_BODY = ['get', 'delete'] as const;
const METHOD_WITH_BODY = ['patch', 'put', 'post'] as const;

type MethodWithBodyType = (typeof METHOD_WITH_BODY)[number];
type MethodWithoutBodyType = (typeof METHOD_WITHOUT_BODY)[number];

type MethodType = MethodWithBodyType | MethodWithoutBodyType;

type EasyFetchWithAPIMethodsType = EasyFetch & {
  [K in MethodType]: ???;
};

일단 가장 먼저 힌트를 얻은것이 EasyFetch는 에러구문에도 나와있듯이 인덱스 타입으로 이루어져 있다는 것이다. key와 value로 이루어진 타입이니 그와 동일하게 인덱스 타입을 생성하고 인터섹션타입으로 EasyFetchWithAPIMethodsType를 작성했다. 하지만 물음표 부분에서 상당한 고민을 했다. get,delete같은 경우에는 인자에 body가 필요없지만 patch,put,post같은 경우에는 body가 있어야하는 함수 타입을 정의해야했기 때문이다. 그래서 해결법이 extend 키워드를 활용이였다.

extend 키워드는 제네릭 타입 제한, 타입 상속, 조건부 타입으로 활용할 수 있는데 그중 조건부 타입의 기능을 extend 키워드로 활용했다.

type MethodFunction<T> = T extends MethodWithBodyType
  ? <P>(
      url: string | URL,
      reqBody?: object,
      reqConfig?: Omit<RequestInitWithNextConfig, 'method' | 'body'>
    ) => Promise<EasyFetchResponse<P>>
  : <P>(
      url: string | URL,
      reqConfig?: Omit<RequestInitWithNextConfig, 'method'>
    ) => Promise<EasyFetchResponse<P>>;

type EasyFetchWithAPIMethodsType = EasyFetch & {
  [K in MethodType]: MethodFunction<K>;
};

export { EasyFetchWithAPIMethodsType, MethodType, MethodWithoutBodyType };

EasyFetchWithAPIMethodsType에서 인덱스 타입인 K는 path,put,post,get,delete들이므로 이것을 MethodFunction이라는 타입의 제네릭으로 전달해주고 extend를 활용해 분기처리해서 함수 타입을 정의 해주는 것으로 위와같은 에러를 해결했다.

위의 함수 타입들을 보면 RequestInitWithNextConfig에서 method를 omit해주고 있는데 이것의 이유는 axios를 이것저것 만져보다가 axios.get()의 config에서 method를 또 선택할 수 있는 오류(?)를 발견해서 해당사항은 혼동을 줄 수 있을 것 같아서 내가 만든 라이브러리에서는 이러한 타입을 정확히 명시하려고 Omit을 사용한 것 이다.

// RequestInitWithNextConfig.type.ts
interface NextFetchRequestConfig {
  revalidate?: number | false;
  tags?: string[];
}

interface RequestInitWithNextConfig extends globalThis.RequestInit {
  next?: NextFetchRequestConfig | undefined;
}

Interceptor 구현기

axios의 인터셉터를 들여다 보면 promise 대한 이해도를 높일 수 있다. 인터셉터의 핵심적인 기능만 코드로 가져와 분석해보면 다음과 같다.

axios.interceptor.request.use(fulfilled1, rejected1)
axios.interceptor.request.use(fulfilled2, rejected2)

Axios에서 인터셉터 기능을 구현하다보면 fullfilled, rejected함수를 사용해하는데 내부적으로는 이러한 함수들을 배열로 관리하고 있다. 그래서 interceptors request의 배열을 forEach로 돌리고 있는데 이런 request 배열의 모습을 다음과 같이 유추해볼 수 있다.

// 설명을 위해 간략하게 추린 것 입니다. 실제로는 runWhen, synchronous 프로퍼티도 있습니다.
const request = [{fullfiled: fullfiled1, rejected: rejected1}, {fulfilled: fulfilled2, rejected: rejected2}]

위와 같이 대략적인 모습을 머릿속에 넣어두고 빨간 네모박스의 코드들을 살펴보면 이해하는데 도움이 될 것이다. 그렇다면 requestInterceptorChain의 모습은 아래 코드와 같다.

// unshift 이므로
const requestInterceptorChain = [fulfilled2, rejected2, fullfiled1, rejected1]

responseInterceptorChain도 requestInterceptorChain의 로직이랑 비슷하고 다른점은 unshift 아닌 push를 한다는 점이다.

그 다음 부분을 보면 아래와 같다.

좌측은 requestInterceptor가 비동기 일때의 코드이고 우측은 requestInterceptor 코드가 동기일때이다.

비동기일때의 코드를 보면 chain이라는 객체를 생성하고 아까 생성한 request와 response의 체인 배열들을 한군데로 합쳐준 다음에 while문을 돌면서 then메서드 안에 인자로 넣고 있다. while문을 다 돌았을 때의 코드를 상상해보면 아래와 같다.

// config는 우리가 요청을 할 때 설정한 config다
Promise.resolve(config)
  .then(requestFulfilled2, requestRejected2)
  .then(requestFulfilled1, requestRejected1)
  .then(dispatchRequest.bind(this), undefined)
  .then(responseFullFilled1, responseRejected1)
  .then(responseFullFilled2, responseRejected2)

이때의 dispatchRequest return값은 fetch의 응답값이라고 생각하면 편하다.

동기 일때는 request interceptor를 while문에서 동기적으로 실행시킨 다음 fetch를 해주는 dispatchRequest함수에 전달 해주는것만 차이점이고 나머지 response interceptor로직은 위의 설명한 바와 같다.

적용

위와 같은 방법을 보면서 기본이 중요하다는 것을 다시 느꼈다. 하지만 위와 같이 그대로 로직을 TS에서 작성할 경우 에러가 날 것 같은 부분을 바로 캐치했다. 다음 예시코드를 만들었다.

let promiseChain = Promise.resolve(config) as Promise<RequestInit>;

promiseChain = promiseChain
  .then((res) => {
    return res;
  })
  .then(async (config) => {
    const sample = await fetch('http://attraction', config);
    const data = await sample.json() as { data: string };
    return data;
  })
  .then(res => res);

첫번째 then은 requestInterceptor이고 두번째 then은 requestInterceptor의 의해서 적용된 config를 fetch한테 전달해주고 responseInterceptor를 처리하기 위해 응답값을 리턴을 해주는 로직이고 마지막 then은 responseInterceptor의 fulfilled 함수이다.
위와 같은 코드는 아까 Axios랑 똑같은 로직인데 타입에러가 난 이유는 promiseChain의 초기 타입은 Promise의 RequestInit 이다. 즉, 다시 재할당을 해도 Promise의 RequestInit을 반환해야하는데 while문에 의해서 한번에 request interceptor와 response interceptor를 처리하려고하면 타입 불일치가 발생해 에러가 발생한다.

이를 해결하기 위해 Request Interceptor와 Response Interceptor를 분리해서 처리했다.

// 실제 만든 라이브러리 코드 일부 발췌
  
interface InterceptorCallbackType<T> {
  (
    onFulfilled?: (arg: T) => T | Promise<T>,
    onRejected?: (err: any) => any
  ): void;
}
  
type InterceptorArgs<T> = Parameters<InterceptorCallbackType<T>>;
	
  
class Interceptor {
	
  flushInterceptors<T>(
    initVal: Promise<T>,
    interceptors: InterceptorArgs<T>[]
  ) {
    let promiseInit = initVal;

    for (let i = 0; i < interceptors.length; i++) {
      promiseInit = promiseInit.then(...interceptors[i]);
    }

    return promiseInit;
  }

  flushRequestInterceptors(initVal: Promise<EasyFetchRequestType>) {
    return this.flushInterceptors(initVal, this.requestCbArr);
  }

  flushResponseInterceptors(initVal: Promise<EasyFetchResponse<any>>) {
    return this.flushInterceptors(initVal, this.responseCbArr);
  }
}

export default Interceptor;

flushRequestInterceptors와 flushResponseInterceptors의 인자에 let의 초기값을 전달함으로써 flushInterceptors 메서드를 보면 prmiseInit으로 할당하고 있다. 이때 for문을 돌면서 promiseInit에 then을 할당해도 interceptors의 타입인 InterceptorArgs의 제네릭으로 초기값의 타입을 전달해주면서 타입을 일치시키고 오류를 해결할 수 있었다.

플로우 차트

내가 만든 라이브러리의 대표적인 기능을 블로그를 정리했는데 나머지 기능들도 어떻게 구현되었는지 궁금하다면 깃허브에서 확인할 수 있다.

구현하면서 느낀점

이번 라이브러리를 구현하면서 오픈 소스 코드를 직접 분석하면서 전보다 코드를 해석하는 능력이 향상되었고, Promise와 then의 활용법도 명확히 이해하게 되었다. JavaScript 코드를 TypeScript로 변환하는 과정에서 제네릭 타입, 유틸리티 타입, extends 키워드 등을 활용하면서, TypeScript에 대한 숙련도 또한 한층 더 성장한 느낌이였다.

테스트 코드를 활용해 코드의 안정성을 더했고 Rollup을 이용해 esm, cjs의 차이를 이해하고 두 모듈에 대응하도록 Rollup을 설정하면서 점차 프론트 공부 영역을 넓혀가게되는 경험을 했다. 롤업에 관한 설정은 다음 블로그를 보면 될 것 같다.

profile
차근차근 개발자

0개의 댓글