공부해봅시당

[Typescript] 비동기 호출 본문

STUDY/Typescript

[Typescript] 비동기 호출

tngus 2024. 8. 20. 12:49

API요청

1. fetch함수 대신 Axios를 도입하게 된 이유

 

내장함수인 fetch를 통해 기본적으로 구현했는데, 가장 안 좋은 모델은 특정 컴퍼넌트에서 일일이 API 요청을 복사해가면 하는 것이다. 같이 요청에 대해서도 똑같은 URI를 복붙해서 썼고 이는 백엔드 URI 변경 및 추가적인 요청 정책이 추가될 떄마다 번거러움이 발생했다.

 

이를 해결하려면 우선적으로 서비스 레이어로 분리할 필요가 있다.

컴퍼넌트와 요청을 관리하는 fetch함수를 따로 분리하는 것이다. 하지만 직접 타임 아웃, 커스텀 헤더 추가 등등 다양한 정책을 구현하는 것은 번거롭기 때문에 Axios라이브러리를 사용한다.

 

2. Axios 활용

1) 일반적인 활용

const defaultConfig = {
  baseURL: 'https://api.example.com',
  timeout: 5000,
  headers: {
    'Content-Type': 'application/json',
  },
};
const apiRequester: AxiosInstance = axios.create(defaultConfig);

const orderApiRequester: AxiosInstance = axios.create({
  ...defaultConfig,
  baseURL: 'https://api.baemin.or/',
});

const orderCartApiRequester: AxiosInstance = axios.create({
  ...defaultConfig,
  baseURL: 'https://api.baemin.order/',
});

const setRequestDefaultHeader = (requestConfig) => {
  const config = { ...requestConfig };

  config.headers = {
    ...config.headers,
    'Content-Type': 'application/json;charset=utf-8',
    user: '유저토큰',
  };

  return config;
};

apiRequester.interceptors.request.use(setRequestDefaultHeader);

 

 

axios인스턴스를 도입하면서 uri의 중복을 막고 유지보수성을 향상 시킬 수 있다.

그리고 interceptor 도입해서 header 및 각종 config , 에러를 서비스레이어에서 처리할 수 있게 된다.

 

2) 빌더 패턴 사용

 

위의 방법과 다르게 요청 옵션 따라 다른 인터셉터를 만들기 위해 빌더 패턴을 추가하여 APIBuilder 같은 클래스 형태로 구성할 수도 있다.

 

class API {
  readonly method: HTTPMethod;
  readonly url: string;
  baseURL?: string;
  headers?: HTTPHeaders;
  data?: unknown;
  timeout?: number;
  withCredentials?: boolean;

  constructor(method: HTTPMethod, url: string) {
    this.method = method;
    this.url = url;
  }

  call<T>(): AxiosPromise<T> {
    const http = axios.create();

    if (this.withCredentials) {
      http.interceptors.response.use(
        (response) => response,
        (error) => {
          if (error.response && error.response.status === 401) {
            //에러처리
          }
          return Promise.reject(error);
        }
      );
    }
    return http.request({ ...this });
  }
}

기본 API 클래스로 실제 호출 부분을 구성하고, 위와 같은 API를 호출하기 위한 래퍼를 빌더 패턴으로 만든다.

 

class APIBuilder {
  private _instance: API;

  constructor(method: HTTPMethod, url: string, data?: unknown) {
    this._instance = new API(method, url);
    this._instance.baseURL = 'hostURL';
    this._instance.data = data;
    this._instance.headers = {
      'Content-Type': 'application/json;charset=utf-8',
    };
    this._instance.timeout = 5000;
    this._instance.withCredentials = false;
  }

  static get = (url: string) => new APIBuilder('GET', url);
  static put = (url: string, data: unknown) => new APIBuilder('PUT', url, data);
  static post = (url: string, data: unknown) =>
    new APIBuilder('POST', url, data);
  static delete = (url: string) => new APIBuilder('DELETE', url);

  baseURL(value: string): APIBuilder {
    this._instance.baseURL = value;
    return this;
  }

  headers(value: HTTPHeaders): APIBuilder {
    this._instance.headers = value;
    return this;
  }

  //...
}

 

APIBuilder 클래스는 보일러플레이트 코드가 많다는 단점을 갖고 있다.

하지만 옵션이 다양한 경우에 인터셉터를 설정값에 따라 적용하고, 필요 없는 인터셉터를 선택적으로 사용할 수 있다는 장점이 있다.

 

3.  API응답 타입 지정하기

같은 서버에서 오는 응답의 형태는 대체로 통일되어 있어서 하나의 Response 타입으로 묶일 수 있다.

interface Response<T> {
  data: T;
  status: string;
  serverDateTime: string;
  errorCode?: string; //FAIL, ERROR
  errorMessage?: string;
}

const fetchCart = (): AxiosPromise<Response<FetchCartResponse>> =>
  apiRequester.get<Response<FetchCartResponse>>'cart';

다만 Response 타입을 apiRequester 내에서 처리할 때, UPDATE나 CREATE같이 응답이 없을 수 있는 API처리가 까다로워진다. 따라서 Response 타입은 apiRequester가 모르게 관리되어야 한다.

 

API요청 및 응답 값 중에서는 하나의 API 서버에서 다른 API 서버로 넘겨주기만 하는 값도 존재할 수 있다. 해당 값에 어떤 응답이 들어있느지 알 수 없거나 값의 형식이 달라지더라도 로직에 영향을 주지 않느 경우에는 unknown 타입으로 사용하여 알 수 없는 값임을 표현한다.

interface response {
  data: {
    // cartItems: CartItem[];
    forPass: unknown;
  };
}

type ForPass = {
  type: 'A' | 'B' | 'C';
};

const isTargetValue = () => (data.forPass as ForPass).type === 'A';

그리고 만약 forPass안에 프론트 로직에서 사용해야하는 값이 있다면, 여전히 알 수 없으므로 unknown을 유지하고, 넘겨주는 값의 타입은 언제든지 변경될 수 있으므로 forPass 내의 값을 사용하지 않아야한다. 하지만 이미 설계된 프로덕트에서 쓰는 값이라면 프론트 로직에서 써야 하는 값에 대해서만 타입을 선언한 다음 사용하는게 좋다.

4.  View Model 사용해서 API응답

1) 일반적인 케이스

interface ListResponse {
  items: ListItem[];
}

const fetchList = async (filter?: ListFetchFilter): Promise<ListResponse> => {
  const { data } = await apiRequester
    .params({ ...filter })
    .get('/apis/get-list-summaries')
    .call<Response<ListResponse>>();

  return { data };
};

하지만 위와 같이 사용하면 API 응답의 items 인자를 좀 더 정확한 개념으로 나타내기 위해 jobItems등으로 수정하면 해당 컴포넌트도  수정해야한다. 이렇게 수정해야 할 컴포넌트가 API 1개 뿐만 아니라, 사용는 기존 컴포넌트도 수정해야 한다

(초기 프로젝트에서 자주 나옴)

 

2) 뷰 모델을 도입

interface JobListItemResponse {
  name: string;
}

interface JobListResponse {
  jobItems: JobListItemResponse[];
}

class JobList {
  readonly totalItemCount: number;
  readonly items: JobListItemResponse[];

  constructor({ jobItems }: JobListResponse) {
    this.totalItemCount = jobItems.length;
    this.items = jobItems;
  }
}

const fetchJobList = async (
  filter?: ListFetchFilter
): Promise<JobListResponse> => {
  const { data } = await apiRequester
    .params({ ...filter })
    .get('/apis/get-list-summaries')
    .call<Response<JobListResponse>>();

  return new JobList(data);
};

뷰 모델을 만들면 API 응답이 바뀌어도 UI가 꺠지지 않게 개발할 수 있다. 또한 API 응답에는 없는 totalItemCount 같은 도메인 개념을 넣을 때 백엔드나 UI에서 로직을 추가하여 처리할 필요 없이 간편하게 새로운 필드를 뷰 모델에 추가할 수 있다.

 

하지만 뷰모델에서도 '추상화 레이어 추가는 결국 코드를 복잡하게 만들며 레이어 관리하고 개발하는데 비용이 든다'는 단점이 있다. 앞의 코드에서 JobListItemResponse 타입은 서버에서 지정한 응답 형식이기 때문에 이를 UI에서 사용하려면  더 많은 타입을 선언해야 한다. 앞 코드의 totalItemCount 같이 API 응답에는 없는 새로운 필드를 만들어서 사용할 때, 서버가 내려준 응답과 클라이언트가 실제 사용하는 도메인이 다르면 서버와 클라이언트 간의 의사소통 문제도 생길 수 있다.

 

따라서 API 응답이 바뀌었을 떄는 클라이언트 코드를 수정하는 데 들어가는 비용을 줄이면서도 도메인의  일관성을 지킬 수 있는 절충안을 찾아야 한다.

 

ex) 꼭 필요한 곳에만 뷰ㄷ모델 부분적으로 만들어서 사용하기, 백엔드와 클라이언트 개발자가 충분히 소통해 API 응답 변화 최대한 줄이기, 뷰 모델에 필드를 추가하는 대신 getter 등의 함수를 축하여 실제 어떤 값이 뷰 모델에 추가한 값인지 알기 쉽게 하기 등등

 

5.  Superstruct를 사용해서 A런타임에서 응답 타입 검증하기

Superstruct 라이브러리는 2가지의 핵심 역할을 언급한다.

  • 인터페이스 정의와 자바스크립트 데이터의 유효성 검사를 쉽게 하기
  • 런타임에서의 데이터 유효성 검사를 통해 개발자와 사용자에게 자세한 런타임 에러를 보여주기

 

import { assert, object, number, string, array } from 'superstruct'
//https://docs.superstructjs.org/

const Article = object({
  id: number(),
  title: string(),
  tags: array(string()),
  author: object({
    id: number(),
  }),
})

const data = {
  id: 34,
  title: 'Hello World',
  tags: ['news', 'features'],
  author: {
    id: 1,
  },
}

assert(data, Article)
// This will throw an error when the data is invalid.
// If you'd rather not throw, you can use `is()` or `validate()`.
is(data,Article);
validate(Data,Article);

Article이라는 변수는 Superstruct의 object() 모듈의 반환 결과다. (id는 숫자, title은 문자열 등등의 속성을 가진 객체)

data는 정보를 다음 객체다.

 

assert,is,validate 모듈은 유효성 검사를 도와주는 모듈들이다.

공통점은 데이터 정보를 담은 data 변수와 데이터 명세를 가진 스키마인 Article을 인자로 받아 데이터가 스키마와 부합하는지 검사하는 것이다.

차이점은

  • assert : 유효하지 않을 경우 에러를 던진다.
  • is : 유혀성 감사 결과에 따라 true 또는 false를 반환한다.
  • validate : [error,data] 형식의 튜플을 반환한다. 유효하지 않을 떄는 에러 값이 반환되고 유효한 경우에는 첫 번째 요소로 undefined, 두 번쨰 요소로 data value가 반환된다.
import { Infer, number, object, string, assert } from 'superstruct';

const User = object({
  id: number(),
  email: string(),
  name: string(),
});

// type User = Infer<typeof User>;

type User = {
  id: number;
  email: string;
  name: string;
};

function isUser(user: User) {
  assert(user, User);
  console.log('적절한 유저입니다.');
}

 

적절한 값이 들어온다면 "적절한 유저입니다"가 출력되고 아닌 경우(오염된 경우)에는 런타임 에러가 발생한다.

 

이를 활용하여 아래와 같이 사용할 수 있다. (타입이 다를 경우 에러를 던져서 런타임 유효성 검사를 할 수 있다.)

import {assert} from "superstruct";

functoin isListItem(listItems : ListItem[]){
	listItems,forEach((listItem) => aseert(listItem, ListItem));
}

 

API 상태 관리하기

실제 API 요청시에는 성공 유무에 따른 상태 관리가 되어야 하므로 상태 관리 라이브러리의 액션이나 훅과 같이 재정의된 형태를 사용해야 한다.

 

1. 상태 관리 라이브러리에서 호출

상태 관리 라이브러리의 비동기 함수들은 서비스 코드를 사용해서 비동기 상태를 변화시킬 수 있는 함수를 제공한다. 컴포넌트느 이러한 함수를 사용하여 상태를 구독하며, 상태가 변경될 때 컴포넌트를 다시 렌더링하는 방식으로 동작한다.

 

redux에서는 미들웨어를 통해 비동기 상태를 관리한다. 그 결과 보일러플레이트가 많다.

Mobx에서는 위의 불편함을 개선하기 위해 비동기 콜백함수를 분리하여 액션을 만들거나 runInAction 과 같은 메서드를 사용하여 상태 변경을 처리한다. 또한 async/await 나 flow 같은 비동기 상태 관리를 위한 기능도 있다.

 

모든 상태 관리 라이브러리에서 비동기 처리 함수를 호출하기 위해 액션이 추가될 때마다 관련 스토어나 상태가 늘어난다. 이로 인한 가장 큰 문제는 전역 상태 관리자가 모든 비동기 상태에 접근하고 변경할 수 있다는 것이다.

 

2. 훅으로 호출

react-query나 useSwr 같은 훅을 사용한 방법은 훨씬 간단하다. 이러한 훅은 캐시를 사용하여 비동기 함수를 호출하며, 상태 관리 라이브러리에서 발생했던 읟도치 않은 상태 변경을 방지하는 데 도움이 된다.

 

API 에러 핸들링

비동기 API 호출을 하다 보면 다양한 에러가 발생할 수 있다. 이를 타입스크립트에서 구체적이고 명시적으로 핸들링하는 방법을 알아보자

1. 타입가드

Axios에서는 Axios에 대해 isAxiosError라는 타입가드를 제공한다. 이 타입가드를 가공해서 , 서버 에러임을 명확하게 표시하고 서버에서 내려주는 에러 응답 객체에 대해서도 구체적으로 정의함으로써 에러 객체가 어떤 속성을 가졌는지 파악할 수 있다.

 

// 공통에러에 대한 정의
import axios, { AxiosError } from 'axios';

interface ErrorResponse {
  status: string;
  serverDateTime: string;
  errorCode: string;
  errorMessage: string;
}

function isServerError(error: unknown): error is AxiosError<ErrorResponse> {
  return axios.isAxiosError(error);
}

2. 에러 서브 클래싱하기

서브 클래싱이란 기존 클래스를 확장하여 새로운 클래스를 만드는 과정이다.

단순한 서버 에러말고 인증 정보 에러, 네트워크 에러등 다양한 에러를 명시적으로 표시하기 위해 서브클래싱을 사용할 수 있다. 서브클래싱을  활용하면 에러가 발생했을 때 코스당에서 어떤 에러인지를 바로 확인할 수 있고 에러 인스턴스에 따라 처리 방식을 다르게 구현할 수 있다.

 

class OrderHttpError extends Error {
  private readonly privateResponse: AxiosResponse<ErrorResponse> | undefined;

  constructor(message?: string, response?: AxiosResponse<ErrorResponse>) {
    super(message);
    this.name = 'OrderHttpError';
    this.privateResponse = response;
  }

  get response(): AxiosResponse<ErrorResponse> | undefined {
    return this.privateResponse;
  }
}

class NetworkError extends Error {
  constructor(message = '') {
    super(message);
    this.name = 'NetworkError';
  }
}

class UnauthorizedError extends Error {
  constructor(message: string, response?: AxiosResponse<ErrorResponse>) {
    super(message);
    this.name = 'UnauthorizedError';
  }
}

 

 

 

const httpErrorHandler = (
  error: AxiosError<ErrorResponse> | Error
): Promise<Error> => {
  let promiseError: Promise<Error>;

  if (axios.isAxiosError(error)) {
    if (Object.is(error.code, 'ECONNABORTED')) {
      promiseError = Promise.reject(new TimeoutError());
    } else if (Object.is(error.code, 'Network Error')) {
      promiseError = Promise.reject(new NetworError());
    } else {
      const { response } = error as AxiosError<ErrorResponse>;

      switch (response?.status) {
        case HttpStatusCode.Unauthorized:
          promiseError = Promise.reject(
            new UnauthorizedError(response?.data.errorMessage, response)
          );
          break;
        default:
          promiseError = Promise.reject(
            new OrderHttpError(response?.data.errorMessage, response)
          );
      }
    }
  } else {
    promiseError = Promise.reject(error);
  }
  return promiseError;
};

이후 error instance of OrderHttpError와 같이 작성된 타입 가드문을 통해 코드상에서 에러핸들링에 대한 부분을 한눈에 볼 수 있게 만들 수 있다.

const onUnauthorizedError = (message: string, callback?: () => void) => {
  console.error(`Unauthorized Error: ${message}`);
  if (callback) {
    callback();
  }
};
const onActionError = (
  error: unknown,
  params?: Omit<AlertPopup, 'type' | 'message'>
) => {
  if (error instanceof UnauthorizedError) {
    onUnauthorizedError(error.message);
  } else if (error instanceof NetworkError) {
    // ...
    alert('내트워크 연결이 이상합니다.');
  }
};

 

 

3. Axios 인터셉터를 활용한 에러처리

const httpErrorHanlder = (
  error: AxiosError<ErrorResponse> | Error
): Promise<Error> => {
  (error) => {
    if (error.response && error.response.status === 401) {
      window.location.href = `${backofficeAuthHost}`;
    }
    return Promise.reject(error);
  };
};

orderApiRequester.interceptors.response.use(
  (response: AxiosResponse) => response,
  httpErrorHandler
);

응답 시에 인터셉터를 통해 처리 가능하다.

 

4. 에러 바운더리를 활용한 에러처리

에러 바운더리는 리액트 컴포넌트 트리에서 에러가 발생할 때 공통으로 에러를 처리하는 리액트 컴퍼넌트이다. 에러 바운더리는 에러가 발생한 컴퍼넌트 대신에 에러 처리를 하거나 예상치 못한 에러를 공통 처리할 떄 사용할 수 있다.

 

5. 상태 관리 라이브러리에서 에러 처리

6. react-query 를 활용한 에러 처리

요청에 대한 상태를 반환해 주기 때문에 요청 상태를 확인하기 쉽다.

 

7. 그 밖의 에러처리

커스텀 에러를 만들어서 처리할 수도 있다.

 

API 모킹

서버 API가 완성되기 전에 가짜 모듈을 활용하는 것을 모킹이라고 한다.

모킹의 사용 예시로는

  1. JSON 파일 불러오기
    간단한  경우 사용하는 방법. 
  2. NextApiHandler 활용하기
    Next.js에 존재함. 응답 처리 로직도 추가 가능
  3. API 요청 핸들러에 분기 추가하기
    분기처리를 통해서 필요할 떄에만 실제 요청을 보낼 수 있다. 이 방법은 개발 이후에도 쓸 수 있으니, 모든 api요청에 if 분기문을 추가해야하므로 번거로울 수 있다.

    const mockFetchBrands = (): Promise<FetchBrandsResponse> =>
      new Promise((resolve) => {
        setTimeout(() => {
          resolve({
            status: 'SUCCESS',
            message: null,
            data: [
              {
                id: 1,
                label: '배민스토어',
              },
              {
                id: 2,
                label: '비마트',
              },
            ],
          });
        }, 500);
      });
    
    const fetchBrands = () =>{
        if(useMock){
            return mockFetchBrands();
        }
        return requester.get("/brands")
    }
  4. axios-mock-adapter로 모킹하기
    서비스에 분기문이 추가되는 것을 바라지 않는다면 ,axios-mock-adapter 라이브러리를 사용하면 된다. 해당 라이브러리는 Axios 요청을 가로채서 요청에 대한 응답 값을 대신 반환한다.
    에러 및 HTTP 메서드에 대한 목업도 작성 가능하다.
  5. 목업 사용 여부 제어하기
    플래그를 사용하여 목업으로 개발할 떄와 개발하지 않을 때를 구분할 수 있다.

const useMock = Object.is(REACT_APP_MOCK, 'true');
const mockFn = ({status=200,time=100,use=true}:MockResult) => use &&
    mock.onGet(/\/order\/list/).reply(()=>
    new Promise((resolve)=>
    setTimeout(()=>{
        resolve([
            status,
            status ===200? fetchOrderListSuccessResponse : undefined,
        ]);
    },time)
    )

    if(useMock){
        mockFn({status:200,time:100,use:true})
    }
)

위 처럼 플래그에 따라 mockFN을 제어할 수 있는데, 매개변수를 넘겨 특정 mock함수만 동작 여부를 선택할 수 있다.
이후 스크립트 실행 시 구분 짓고자 한다면 package.json에 관련 스크립트를 추가해줄 수도 있다.


출처

https://ungumungum.tistory.com/89

 

우아한 타입스크립트 with React -7장 비동기 호출 -1

이번장의 내용은 배민 팀에서 비동기 호출 중에서 API 요청 및 응답 행위를 어떻게 처리했는지를 다룬다. 각각의 문제점을 어떻게 개선 및 보완하려했고 시도했던 방법들을 언급해준다. API요청 1

ungumungum.tistory.com

 

'STUDY > Typescript' 카테고리의 다른 글

[Typescript] JSX에서 TSX로  (1) 2024.09.03
[Javascript] JSX(JavaScript XML)  (0) 2024.09.03
[Typescript] 타입스크립트 컴파일  (0) 2024.08.20
[Typescript] 타입 활용하기  (0) 2024.08.06
[Typescript] 타입 확장하기 & 좁히기  (0) 2024.07.30