Part7_AsynchronousCall
이효경
책 읽기

Study

7장 비동기 호출

7.1 API 요청

7.1.1 fetch로 API 요청하기

  • 신입 개발자가 사용자가 장바구니를 조회해서 볼 수 있는 기능을 만들게 되었다. 외부 데이터 베이스에 접근하여 사용자가 장바구니에 추가한 정보를 호출하는 코드를 작성했는데 직접 fetch 함수를 사용해서 사용자가 담은 장바구니 물품 개수를 배지로 멋지게 보이게 했다.
  • 배지 : 일반적으로 UI에 표시되는 작은 원형이나 사각형 형태의 요소를 말한다. 주로 다른 요소의 옆이나 아이콘 위에 위치하여 사용자에게 새로운 것이 있음을 알려주고자 할 때 많이 사용된다.
import React, { useEffect, useState } from "react";
 
const CartBadge: React.FC = () => {
  const [cartCount, setCartCount] = useState(0);
 
  useEffect(() => {
    fetch("https://api.baemin.com/cart")
      .then((response) => response.json())
      .then(({ cartItem }) => {
        setCartCount(cartItem.length);
      });
  }, []);
 
  return <>{/*  cartCount 상태를 이용하여 컴포넌트 렌더링 */}</>;
};
  • 장바구니 정보를 비동기 함수인 fetch로 불러와 장바구니 내부를 확인하여 장바구니에 담긴 물품의 개수를 배지 아이콘으로 보여주는 기능, 사용자에게 장바구니에 담은 물품을 보여주는 페이지 구현 등 여러 곳에서 같은 API URL을 복붙하여 사용하게 되었다.
  • 그런데 백엔드에서 기능 변경을 해야 해서 API URL을 수정해야 한다고 한다. 아쉽게도 이미 컴포넌트 내부에 깊숙이 자리 잡은 비동기 호출 코드는 이러한 변경 요구에 취약하다. 비단 URL 변경뿐 아니라 '여러 서버에 API를 요청할 때 타임아웃 설정이 필요하다' 또는 '모든 요청에 커스텀 헤더가 필요하다' 같은 새로운 API 요청 정책이 추가될 때마다 계속해서 비동기 호출 코드를 수정해야 하는 번거로움이 발생한다.

7.1.2 서비스 레이어로 분리하기

  • 여러 API 요청 정책이 추가되어 코드가 변경될 수 있다는 것을 감안한다면, 비동기 호출 코드는 컴포넌트 영역에서 분리되어 다른 영역(서비스 레이어)에서 처리되어야 한다.
  • 앞의 코드를 기준으로 설명하면 fetch 함수를 호출하는 부분이 서비스 레이어로 이동하고, 컴포넌트는 서비스 레이어의 비동기 함수를 호출하여 그 결과를 받아와 렌더링하는 흐름이 된다.
  • 그러나 단순히 fetch 함수를 분리하는 것만으로는 API 요청 정책이 추가되는 것을 해결하기 어렵다. 예를 들어 fetch 함수에서 타임아웃을 설정하기 위해서는 다음과 같이 구현해야 한다.
async function fetchCart() {
  const controller = new AbortController();
 
  const timeoutId = setTimeout(() => controller.abort(), 5000);
 
  const response = await fetch("https://api.baemin.com/cart", {
    signal: controller.signal,
  });
 
  clearTimeout(timeoutId);
 
  return response;
}
  • 또한 쿼리 매개변수나 커스텀 헤더 추가 또는 쿠키를 읽어 토큰을 집어넣는 등 다양한 API 정책이 추가될 수 있는데 이를 모두 구현하는 것은 번거로운 일이다.

7.1.3 Axios 활용하기

  • fetch는 내장 라이브러리이기 때문에 따로 임포트하거나 설치할 필요 없이 사용할 수 있다. 그러나 많은 기능을 사용하려면 직접 구현해서 사용해야 한다. 이러한 번거로움 때문에 fetch 함수를 직접 쓰는 대신 Axios 라이브러리를 사용하고 있다.
import axios, { AxiosInstance, AxiosPromise } from "axios";
 
export type FetchCartResponse = unknown;
export type PostCartRequest = unknown;
export type PostCartResponse = unknown;
 
export const apiRequester: AxiosInstance = axios.create({
  baseURL: "https://api.baemin.com",
  timeout: 5000,
});
 
export const fetchCart = (): AxiosPromise<FetchCartResponse> =>
  apiRequester.get<FetchCartResponse>("cart");
 
export const postCart = (
  postCartRequest: PostCartRequest
): AxiosPromise<PostCartResponse> =>
  apiRequester.post<PostCartResponse>("cart", postCartRequest);
  • 각 서버(주문을 처리하는 서버와 장바구니를 처리하는 서버)가 담당하는 부분이 다르거나 새로운 프로젝트의 일부로 포함될 때 기존에 사용하는 API Entry(Base URL)와는 다른 새로운 URL로 요청해야 하는 상황이 생길 수 있다.
  • 이렇게 API Entry가 2개 이상일 경우에는 각 서버의 기본 URL을 호출하도록 orderApiRequester, orderCartApiRequester 같이 2개 이상의 API 요청을 처리하는 인스턴스를 따로 구성해야 한다. 이후 다른 URL로 서비스 코드를 호출할 때는 각각의 apiRequester를 사용하면 된다.
import axios, { AxiosInstance } from "axios";
 
const defaultConfig = {};
 
const apiRequester: AxiosInstance = axios.create(defaultConfig);
const orderApiRequester: AxiosInstance = axios.create({
  baseURL: "https://api.baemin.or/",
  ...defaultConfig,
});
const orderCartApiRequester: AxiosInstance = axios.create({
  baseURL: "https://cart.baemin.order/",
  ...defaultConfig,
});

7.1.4 Axios 인터셉터 사용하기

  • 각각의 requester는 서로 다른 역할을 담당하는 다른 서버이기 때문에 requester별로 다른 헤더를 설정해줘야 하는 로직이 필요할 수 있다.
  • 이때 인터셉터 기능를 사용하여 requester에 따라 비동기 호출 내용을 추가해서 처리할 수 있다. 또한 API 에러를 처리할 때 하나의 에러 객체로 묶어서 처리할 수 있다.
import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse } from "axios";
 
const getUserToken = () => "";
const getAgent = () => "";
const getOrderClientToken = () => "";
const orderApiBaseUrl = "";
const orderCartApiBaseUrl = "";
const defaultConfig = {};
const httpErrorHandler = () => {};
 
const apiRequester: AxiosInstance = axios.create({
  baseURL: "https://api.baemin.com",
  timeout: 5000,
});
 
const setRequestDefaultHeader = (requestConfig: AxiosRequestConfig) => {
  const config = requestConfig;
  config.headers = {
    ...config.headers,
    "Content-Type": "application/json;charset=utf-8",
    user: getUserToken(),
    agent: getAgent(),
  };
  return config;
};
 
const setOrderRequestDefaultHeader = (requestConfig: AxiosRequestConfig) => {
  const config = requestConfig;
  config.headers = {
    ...config.headers,
    "Content-Type": "application/json;charset=utf-8",
    "order-client": getOrderClientToken(),
  };
  return config;
};
 
// `interceptors` 기능을 사용해 header를 설정하는 기능을 넣거나 에러를 처리할 수 있다
apiRequester.interceptors.request.use(setRequestDefaultHeader);
const orderApiRequester: AxiosInstance = axios.create({
  baseURL: orderApiBaseUrl,
  ...defaultConfig,
});
// 기본 apiRequester와는 다른 header를 설정하는 `interceptors`
orderApiRequester.interceptors.request.use(setOrderRequestDefaultHeader);
// `interceptors`를 사용해 httpError 같은 API 에러를 처리할 수도 있다
orderApiRequester.interceptors.response.use(
  (response: AxiosResponse) => response,
  httpErrorHandler
);
const orderCartApiRequester: AxiosInstance = axios.create({
  baseURL: orderCartApiBaseUrl,
  ...defaultConfig,
});
orderCartApiRequester.interceptors.request.use(setRequestDefaultHeader);
  • 이와 달리 요청 옵션에 따라 다른 인터셉터를 만들기 위해 빌더 패턴을 추가하여 APIBuilder 같은 클래스 형태로 구성하기도 한다.
  • 빌더패턴 : 객체 생성을 더 편리하고 가독성있게 만들기 위한 디자인 패턴 중 하나다. 주로 복잡한 객체의 생성을 단순화하고, 객체 생성 과정을 분리하여 객체를 조립하는 방법을 제공한다.
import axios, { AxiosPromise } from "axios";
 
// 임시 타이핑
export type HTTPMethod = "GET" | "POST" | "PUT" | "DELETE";
 
export type HTTPHeaders = any;
 
export type HTTPParams = unknown;
 
//
class API {
  readonly method: HTTPMethod;
 
  readonly url: string;
 
  baseURL?: string;
 
  headers?: HTTPHeaders;
 
  params?: HTTPParams;
 
  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();
    // 만약 `withCredential`이 설정된 API라면 아래 같이 인터셉터를 추가하고, 아니라면 인터셉터 를 사용하지 않음
    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 });
  }
}
 
export default API;
  • 이처럼 기본 API 클래스로 실제 호출 부분을 구성하고, 위와 같은 API를 호출하기 위한 래퍼를 빌더 패턴으로 만든다.
import API, { HTTPHeaders, HTTPMethod, HTTPParams } from "./7.1.4-2";
 
const apiHost = "";
 
class APIBuilder {
  private _instance: API;
 
  constructor(method: HTTPMethod, url: string, data?: unknown) {
    this._instance = new API(method, url);
    this._instance.baseURL = apiHost;
    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;
  }
 
  timeout(value: number): APIBuilder {
    this._instance.timeout = value;
    return this;
  }
 
  params(value: HTTPParams): APIBuilder {
    this._instance.params = value;
    return this;
  }
 
  data(value: unknown): APIBuilder {
    this._instance.data = value;
    return this;
  }
 
  withCredentials(value: boolean): APIBuilder {
    this._instance.withCredentials = value;
    return this;
  }
 
  build(): API {
    return this._instance;
  }
}
 
export default APIBuilder;
  • 이와 같은 패턴으로 제공한 APIBuilder를 사용하는 코드는 다음과 같다.
import APIBuilder from "./7.1.4-3";
 
// ex
type Response<T> = { data: T };
type JobNameListResponse = string[];
 
const fetchJobNameList = async (name?: string, size?: number) => {
  const api = APIBuilder.get("/apis/web/jobs")
    .withCredentials(true) // 이제 401 에러가 나는 경우, 자동으로 에러를 탐지하는 인터셉터를 사용하게 된다
    .params({ name, size }) // body가 없는 axios 객체도 빌더 패턴으로 쉽게 만들 수 있다
    .build();
  const { data } = await api.call<Response<JobNameListResponse>>();
  return data;
};
  • APIBuilder 클래스는 보일러플레이트 코드가 많다는 단점을 갖고 있다. 하지만 옵션이 다양한 경우에 인터셉터를 설정값에 따라 적용하고, 필요없는 인터셉터를 선택적으로 사용할 수 있다는 장점도 갖고 있다.
  • 보일러플레이트 코드 : 어떤 기능을 사용할 때 반복적으로 사용되는 기본적인 코드를 말한다. 예를 들어 API를 호출하기 위한 기본적인 설정과 인터셉터 등을 설정하는 부분을 보일러플레이트 코드로 간주할 수 있다.

7.1.5

  • 같은 서버에서 오는 응답의 형태는 대체로 통일되어 있어서 앞서 소개한 API의 응답 값은 하나의 Response 타입으로 묶일 수 있다.
import { AxiosPromise } from "axios";
import {
  FetchCartResponse,
  PostCartRequest,
  PostCartResponse,
  apiRequester,
} from "./7.1.3-1";
 
export interface Response<T> {
  data: T;
  status: string;
  serverDateTime: string;
  errorCode?: string; // FAIL, ERROR errorMessage?: string; // FAIL, ERROR
}
const fetchCart = (): AxiosPromise<Response<FetchCartResponse>> =>
  apiRequester.get<Response<FetchCartResponse>>("cart");
 
const postCart = (
  postCartRequest: PostCartRequest
): AxiosPromise<Response<PostCartResponse>> =>
  apiRequester.post<Response<PostCartResponse>>("cart", postCartRequest);
  • 이와 같이 서버에서 오는 응답을 통일해줄 때 주의할 점이 있다. Reponse 타입을 apiRequester 내에서 처리하고 싶은 생각이 들 수 있는데 이렇게 하면 UPDATE나 CREATE같이 응답이 없을 수 있는 API를 처리하기 까다로워진다.
import { AxiosPromise } from "axios";
import { FetchCartResponse, apiRequester } from "./7.1.3-1";
import { Response } from "./7.1.5-1";
 
const updateCart = (
  updateCartRequest: unknown
): AxiosPromise<Response<FetchCartResponse>> => apiRequester.get("cart");
  • 따라서 Response 타입은 apiRequester가 모르게 관리되어야 한다.
  • API 요청 및 응답 값 중에서는 하나의 API 서버에서 다른 API 서버로 넘겨주기만 하는 값도 존재할 수 있다. 해당 값에 어떤 응답이 들어있는지 알 수 없거나 값의 형식이 달라지더라도 로직에 영향을 주지 않는 경우에는 unknown 타입을 사용하여 알 수 없는 값임을 표현한다.
interface response {
  data: {
    cartItems: CartItem[];
    forPass: unknown;
  };
}
  • 만약 forPass 안에 프론트 로직에서 사용해야 하는 값이 있다면, 여전히 어떤 값이 들어올지 모르는 상태이기 때문에 unknown을 유지한다. 로그를 위해 단순히 받아서 넘겨주는 값의 타입은 언제든지 변경될 수 있으므로 forPass 내의 값을 사용하지 않아야 한다. 다만 이미 설계된 프로덕트에서 쓰고 있는 값이라면 프론트 로직에서 써야 하는 값에 대해서만 타입을 선언한 다음에 사용하는 게 좋다.
type ForPass = {
  type: "A" | "B" | "C";
};
 
const isTargetValue = () => (data.forPass as ForPass).type === "A";

7.1.6 뷰 모델 사용하기

  • API 응답은 변할 가능성이 크다. 특히 새로운 프로젝트는 서버 스펙이 자주 바뀌기 때문에 뷰 모델을 사용하여 API 변경에 따른 범위를 한정해줘야 한다.
  • 특정 객체 리스트를 조회하여 리스트 각각의 내용과 리스트 전체 길이 등을 보여줘야 하는 화면을 떠올려보자. 해당 리스트를 조회하는 fetchList API는 다음처럼 구성될 것이다.
interface ListResponse {
  items: ListItem[];
}
 
const fetchList = async (filter?: ListFetchFilter): Promise<ListResponse> => {
  const { data } = await api
    .params({ ...filter })
    .get("/apis/get-list-summaries")
    .call<Response<ListResponse>>();
 
  return { data };
};
  • 해당 API를 사용할 때는 다음처럼 사용한다. 이 예시에서는 컴포넌트 내부에서 비동기 함수를 호출하고 then으로 처리하고 있지만 실제 비동기 함수는 컴포넌트 내부에서 직접 호출하지 않는다.
const ListPage: React.FC = () => {
  const [totalItemCount, setTotalItemCount] = useState(0);
  const [items, setItems] = useState<ListItem[]>([]);
 
  useEffect(() => {
    // 예시를 위한 API 호출과 then 구문
    fetchList(filter).then(({ items }) => {
      setTotalItemCount(items.length);
      setItems(items);
    });
  }, []);
 
  return (
    <div>
      <Chip label={totalItemCount} />
      <Table items={items} />
    </div>
  );
};
  • 흔히 좋은 컴포넌트는 변경될 이유가 하나뿐인 컴포넌트라고 말한다. API 응답의 items 인자를 좀 더 정확한 개념으로 나타내기 위해 jobItemes나 cartItems와 같은 이름으로 수정하면 해당 컴포넌트도 수정해야 한다. 이렇게 수정해야 할 컴포넌트가 API 1개에 하나라면 좋겠지만 API를 사용하는 기존 컴포넌트도 수정되어야 한다. 보통 이런 상황이 프로젝트 초기에 자주 발생하곤 한다.
  • 이러한 문제를 해결하기 위한 방법으로 뷰 모델을 도입할 수 있다.
// 기존 ListResponse에 더 자세한 의미를 담기 위한 변화
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 api
    .params({ ...filter })
    .get("/apis/get-list-summaries")
    .call<Response<JobListResponse>>();
 
  return new JobList(data);
};
  • 뷰 모델을 만들면 API 응답이 바뀌어도 UI가 깨지지 않게 개발할 수 있다. 또한 앞의 예시처럼 API 응답에는 없는 totalItemCount 같은 도메인 개념을 넣을 때 백엔드나 UI에서 로직을 추가하여 처리할 필요없이 간편하게 새로운 필드를 뷰 모델에 추가할 수 있다.
  • 그러나 뷰 모델 방식에서도 문제가 발생할 수 있다. 추상화 레이어 추가는 결국 코드를 복잡하게 만들며 레이어를 관리하고 개발하는데도 비용이 든다. 앞의 코드에서 JobListItemResponse 타입은 서버에서 지정한 응답 형식이기 때문에 이를 UI에서 사용하려면 다음처럼 더 많은 타입을 선언해야 한다.
interface JobListResponse {
  jobItems: JobListItemResponse[];
}
 
class JobListItem {
  constructor(item: JobListItemResponse) {
    /* JobListItemResponse에서 JobListItem 객체로 변환해주는 코드 */
  }
}
 
class JobList {
  readonly totalItemCount: number;
  readonly items: JobListItemResponse[];
  constructor({ jobItems }: JobListResponse) {
    this.totalItemCount = jobItems.length;
    this.items = jobItems.map((item) => new JobListItem(item));
  }
}
 
const fetchJobList = async (
  filter?: ListFetchFilter
): Promise<JobListResponse> => {
  const { data } = await api
    .params({ ...filter })
    .get("/apis/get-list-summaries")
    .call<Response<JobListResponse>>();
 
  return new JobList(data);
};
  • 단순히 API 20개를 추가한다면 20개의 응답이 추가될 것이다. 이 말은 20개 이상 뷰 모델이 추가될 수 있다는 뜻이다. 앞 코드의 totalItemCount같이 API 응답에는 없는 새로운 필드를 만들어서 사용할 때 서버가 내려준 응답과 클라이언트가 실제 사용하는 도메인이 다르다면 서버와 클라이언트 간의 의사소통 문제가 생길 수 있다.
  • 결국 API 응답이 바뀌었을 땐 클라이언트 코드를 수정하는데 들어가는 비용을 줄이면서도 도메인의 일관성을 지킬 수 있는 절충안을 찾아야 한다.
  • 꼭 필요한 곳에만 뷰 모델을 부분적으로 만들어서 사용하기, 백엔드와 클라이언트 개발자와 충분히 소통한 다음에 개발하여 API 응답 변화를 최대한으로 줄이기, 뷰 모델에 필두를 추가하는 대신에 getter 등의 함수를 추가하여 실제 어떤 값이 뷰 모델에 추가한 값인지 알기 쉽게 하기 등의 방법을 예로 들수있다.
  • 개발단계에서는 API 응답 형식이 자주 바뀐다. 또한 응답 값의 타입이 string이어야 하는데 number가 들어오는 것과 같이 잘못된 타입이 전달되기도 한다. 그러나 타입스크립트는 정적 검사 도구로 런타임에서 발생하는 오류는 찾아낼 수 없다. 런타임에 API 응답의 타입 오류를 방지하려면 Superstruct 같은 라이브러리를 사용하며 된다.

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

  • 런타임 응답 타입 검증을 하기 위해 사용하는 Superstruct 라이브러리의 소개를 찾아보면 아래와 같이 설명하고 있다.
  1. Superstruct를 사용하여 인터페이스 정의와 자바스크립트 데이터의 유효성 검사를 쉽게 할 수 있다.
  2. Superstruct는 런타임에서의 데이터 유효성 검사를 통해 개발자와 사용자에게 자세한 런타임 에러를 보여주기 위해 고안되었다.
  • 이처럼 Superstruct 라이브러리 공식 문서에는 크게 2가지 핵심 역할을 언급하고 있다. Superstruct 라이브러리가 타입스크립트와 어떤 시너지를 낼 수 있을지 알아보기 전에 간단하게 Superstruct 사용 방법을 살펴보자. 공식 문서에서 제공하는 간단한 코드 예시는 아래와 같다.
import {
  assert,
  is,
  validate,
  object,
  number,
  string,
  array,
} from "superstruct";
 
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);
is(data, Article);
validate(data, Article);
  • 먼저 Article 이라는 변수는 Superstruct의 object() 모듈의 반환 결과다.
  • object()라는 모듈 이름에서 예상할 수 있듯이 Article은 object(객체) 형태를 가진 무언가라고 생각할 수 있다.
  • 그렇다면 number(), string() 모듈의 반환 타입도 숫자, 문자열 형태라고 이해할 수 있다.
  • Article의 id는 숫자, title은 문자열, tags는 문자열 배열, author는 id라는 숫자를 속성으로 가진 객체 형태의 object이다. 즉. 위와 같은 데이터 명세를 가진 스키마이다.
  • data라는 변수는 보는 그대로 어떤 데이터 정보를 담은 object를 가리키고 있다. 그렇다면 assert, is, validate라는 모듈은 무엇일까? 각각 '확인','~이다','검사하다'정도로 직역할 수 있는데 3가지 모두 데이터의 유효성 검사를 도와주는 모듈이다.
  • 세 모듈의 공통점은 데이터 정보를 담은 data 변수와 데이터 명세를 가진 스키마인 Article을 인자로 받아 데이터가 스키마와 부합하는지를 검사한다는 것이다. 차이점은 모듈마다 데이터 유효성을 다르게 접근하고 반환 값 형태가 다르다는 것이다.
    • assert는 유효하지 않을 경우 에러를 던진다.
    • is는 유효성 검사 결과에 따라 true 또는 false 즉, boolean 값을 반환한다.
    • validate는 [error, data] 형식의 튜플을 반환한다. 유효하지 않을 때는 에러 값이 반환되고 유효한 경우에는 첫 번째 요소로 undefined, 두 번재 요소로 data value가 반환된다.
  • 지금까지 Superstruct의 공식 문서를 참조하여 런타임에서의 자바스크립트 데이터의 유효성 검사가 실행되는 구조를 살펴봤다. 그렇다면 타입스크립트와는 어떤 시너지를 발휘할 수 있는지 알아보자
  • 먼저 아래와 같이 Infer를 사용하여 기존 타입 선언 방식과 동일하게 타입을 선언할 수 있다.
import { Infer, number, object, string } from "superstruct";
 
const User = object({
  id: number(),
  email: string(),
  name: string(),
});
 
type User = Infer<typeof User>;
  • 앞의 type User는 기존의 타입스크립트 문법으로 작성되었다.
type User = { id: number; email: string; name: string };
 
import { assert } from "superstruct";
 
function isUser(user: User) {
  assert(user, User);
  console.log("적절한 유저입니다.");
}
  • 앞의 예시는 Superstruct의 assert 메서드를 통해 인자로 받은 user가 User 타입과 매칭되는지 확인하는 isUser 함수이다.
const user_A = {
  id: 4,
  email: "test@woowahan.email",
  name: "woowa",
};
 
isUser(user_A);
  • 앞 코드를 실행하면 성공적으로 "적절한 유저입니다."가 출력된다. 반면 아래 같이 기대하던 데이터 형식과 달리 런타임에 데이터가 오염되어 들어왔을 때는 어떻게 될까?
const user_B = {
  id: 5,
  email: "wrong@woowahan.email",
  name: 4,
};
 
isUser(user_B); // error TS2345: Argument of type '{ id: number; email: string; name: number; }' is not assignable to parameter of type '{ id: number; email: string; name: string; }'
  • 위와 같이 런타임 에러가 발생한다.
  • 이처럼 컴파일 단계가 아닌 런타임에서도 적절한 데이터인지를 확인한느 검사가 필요할 때 유용하게 사용할 수 있다.

7.1.8 실제 API 응답 시의 Superstruct 활용 사례

  • Superstruct의 개념과 사용법에 대해 간략하게 살펴보았다. 이제 API 응답 시 활용되는 방법을 예시로 살펴보자. 앞선 예시에서 본 fetchList 함수를 사용해보자.
  • fetchList의 호출 결과는 ListItem 타입의 배열이다. 여기에서는 ListItem 타입이 다음과 같다고 가정해보자
interface ListItem {
  id: string;
  content: string;
}
 
interface ListResponse {
  items: ListItem[];
}
const fetchList = async (filter?: ListFetchFilter): Promise<ListResponse> => {
  const { data } = await api
    .params({ ...filter })
    .get("/apis/get-list-summaries")
    .call<Response<ListResponse>>();
 
  return { data };
};
  • 우리는 fetchList 함수를 호출했을 때 id와 content가 담긴 ListItem 타입의 배열이 오기를 기대한다. 타입스크립트로 작성한 코드는 명시한 타입대로 응답이 올 거라고 기대하고 있지만 실제 서버 응답 형식은 다를 수 있다. 타입스크립트는 컴파일타임에 타입을 검증하는 역할을 한다. 따라서 타입스크립트만으로는 실제 서버 응답의 형식과 명시한 타입이 일치하는지를 확인할 수 없다.
  • 이때 Superstruct를 활용하여 타입스크립트로 선언한 타입과 실제 런타임에서의 데이터 응답값을 매칭하여 유효성 검사를 할 수 있다.
  • 먼저 Superstruct의 모듈을 사용하여 검증하는 코드를 아래 같이 작성해보자.
import { assert } from "superstruct";
 
function isListItem(listItems: ListItem[]) {
  listItems.forEach((listItem) => assert(listItem, ListItem));
}
  • isListItem은 ListItem의 배열 목록을 받아와 데이터가 ListItem 타입과 동일한지 확인하고 다를 경우에는 에러를 던진다.
  • 이제 fetchList 함수에 Superstruct로 작성한 검증 함수를 추가하면 런타임 유효성 검사를 진행할 수 있기 된다.

7.2 API 상태 관리하기

  • 실제 API를 요청하는 코드는 컴포넌트 내에서 비동기 함수를 직접 호출하지는 않는다. 비동기 API를 호출하기 위해서는 API의 성공/실패에 따른 상태가 관리되어야 하므로 상태 관리 라이브러리의 액션이나 훅과 같이 재정의된 형태를 사용해야 한다.

7.2.1 상태 관리 라이브러리에서 호출하기

  • 상태 관리 라이브러리의 비동기 함수들은 서비스 코드를 사용해서 비동기 상태를 변화시킬 수 있는 함수를 제공한다. 컴포넌트는 이러한 함수를 사용하여 상태를 구독하며 상태가 변경될 때 컴포넌트를 다시 렌더링하는 방식으로 동작한다.
  • Redux는 비교적 초기에 나온 상태 관리 라이브러리다. 다음 예시를 살펴보자
import { useEffect } from "react";
import { useDispatch, useSelector } from "react-redux";
 
export function useMonitoringHistory() {
  const dispatch = useDispatch();
  // 전역 Store 상태(RootState)에서 필요한 데이터만 가져온다
  const searchState = useSelector(
    (state: RootState) => state.monitoringHistory.searchState
  );
  // history 내역을 검색하는 함수, 검색 조건이 바뀌면 상태를 갱신하고 API를 호출한다
  const getHistoryList = async (
    newState: Partial<MonitoringHistorySearchState>
  ) => {
    const newSearchState = { ...searchState, ...newState };
    dispatch(monitoringHistorySlice.actions.changeSearchState(newSearchState));
    const response = await getHistories(newSearchState); // 비동기 API 호출하기 dispatch(monitoringHistorySlice.actions.fetchData(response));
  };
 
  return { searchState, getHistoryList };
}
  • 스토어에서 getHistories API만 호출하고 그 결과를 받아와서 상태를 업데이트하는 일반적인 방식으로 사용할 수 있다. 그러나 앞의 예시와 같이 getHistoryList 함수에서는 dispath 코드를 제외하더라도 다음과 같이 API 호출과 상태 관리 코드를 작성해야 한다.
enum ApiCallStatus {
  Request,
  None,
}
 
const API = axios.create();
 
const setAxiosInterceptor = (store: EnhancedStore) => {
  API.interceptors.request.use(
    (config: AxiosRequestConfig) => {
      const { params, url, method } = config;
      store.dispatch(
        // API 상태 저장을 위해 redux reducer `setApiCall` 함수를 사용한다 // 상태가 `요청됨`인 경우 API가 Loading 중인 상태
        setApiCall({
          status: ApiCallStatus.Request, // API 호출 상태를 `요청됨`으로 변경
          urlInfo: { url, method },
        })
      );
      return config;
    },
    (error) => Promise.reject(error)
  );
  // onSuccess 시 인터셉터로 처리한다
  API.interceptors.response.use(
    (response: AxiosResponse) => {
      const { method, url } = response.config;
      store.dispatch(
        setApiCall({
          status: ApiCallStatus.None, // API 호출 상태를 `요청되지 않음`으로 변경
          urlInfo: { url, method },
        })
      );
      return response?.data?.data || response?.data;
    },
    (error: AxiosError) => {
      const {
        config: { url, method },
      } = error;
      store.dispatch(
        setApiCall({
          status: ApiCallStatus.None, // API 호출 상태를 `요청되지 않음`으로 변경
          urlInfo: { url, method },
        })
      );
      return Promise.reject(error);
    }
  );
};
  • API를 호출할 때, 호출한 뒤 그리고 호출하고 에러가 발생했을 때 각각 setApiCall을 호출해서 상태를 업데이트해야 한다. Redux는 비동기 상태가 아닌 전역 상태를 위해 만들어진 라이브러리이기 때문에 미들웨어라고 불리는 여러 도구를 도입하여 비동기 상태를 관리한다. 따라서 보일러플레이트 코드가 많아지는 등 간편하게 비동기 상태를 관리하기 어려운 상황도 발생한다.
  • 반명 MobX 같은 라이브러리에서는 이러한 불편함을 개선하기 위해 비동기 콜백 함수를 분리하여 액션으로 만들거나 runInAction과 같은 메서드를 사용하여 상태 변경을 처리한다. 또한 async / await나 flow 같은 비동기 상태 관리를 위한 기능도 있어 더욱 간편하게 사용할 수 있다. 비동기 상태 관리 코드 예시를 살펴보자
import { runInAction, makeAutoObservable } from "mobx";
import type Job from "models/Job";
 
class JobStore {
  job: Job[] = [];
  constructor() {
    makeAutoObservable(this);
  }
}
 
type LoadingState = "PENDING" | "DONE" | "ERROR";
 
class Store {
  job: Job[] = [];
  state: LoadingState = "PENDING";
  errorMsg = "";
 
  constructor() {
    makeAutoObservable(this);
  }
 
  async fetchJobList() {
    this.job = [];
    this.state = "PENDING";
    this.errorMsg = "";
    try {
      const projects = await fetchJobList();
      runInAction(() => {
        this.projects = projects;
        this.state = "DONE";
      });
    } catch (e) {
      runInAction(() => {
        this.state = "ERROR";
        this.errorMsg = e.message;
      });
    }
  }
}
  • 모든 상태 관리 라이브러리에서 비동기 처리 함수를 호출하기 위해서 액션이 추가될 때마다 관련된 스토어나 상태가 계속 늘어난다. 이로 인한 가장 큰 문제점은 전역 상태 관리자가 모든 비동기 상태에 접근하고 변경할 수 있다는 것이다. 만약 2개 이상의 컴포넌트가 구독하고 있는 비동기 상태가 있다면 쓸데없는 비동기 통신이 발생하거나 의도치 않은 상태 변경이 발생할 수 있다.

7.2.2 훅으로 호출하기

  • react-query나 useSwr 같은 훅을 사용한 방법은 상태 변경 라이브러리를 사용한 방식보다 훨씬 간단한다. 이러한 훅은 캐시를 사용하여 비동기 함수를 호출하며 상태 관리 라이브러리에서 발생했던 의도치 않은 상태 변경을 방지하는데 도움이 된다. useSwr과 react-query의 사용법이 유사하므로 여기서는 react-query의 사용 예시만 살펴본다.
  • 아래 코드는 Job 목록을 불러오는 훅과 Job 1개를 업데이트하는 예시다. 만약 Job이 업데이트되면 해당 Job 목록의 정보가 유효하지 않게 되므로 다시 API를 호출해야 함을 알려줘야 한다. 이러한 기능을 구현하는 방법을 살펴보자. react-query에서는 onSuccess 옵션의 in-validateQueries를 사용하여 특정 키의 API를 유효하지 않은 상태로 설정할 수 있다.
// Job 목록을 불러오는 훅
const useFetchJobList = () => {
  return useQuery(["fetchJobList"], async () => {
    const response = await JobService.fetchJobList(); // View Model을 사용해서 결과
    return new JobList(response);
  });
};
 
// Job 1개를 업데이트하는 훅
const useUpdateJob = (
  id: number,
  // Job 1개 update 이후 Query Option
  { onSuccess, ...options }: UseMutationOptions<void, Error, JobUpdateFormValue>
): UseMutationResult<void, Error, JobUpdateFormValue> => {
  const queryClient = useQueryClient();
 
  return useMutation(
    ["updateJob", id],
    async (jobUpdateForm: JobUpdateFormValue) => {
      await JobService.updateJob(id, jobUpdateForm);
    },
    {
      onSuccess: (
        data: void, // updateJob의 return 값은 없다 (status 200으로만 성공 판별) values: JobUpdateFormValue,
        context: unknown
      ) => {
        // 성공 시 ‘fetchJobList’를 유효하지 않음으로 설정 queryClient.invalidateQueries(["fetchJobList"]);
        onSuccess && onSuccess(data, values, context);
      },
      ...options,
    }
  );
};
  • 이후 컴포넌트에서는 일반적인 훅을 호출하는 것처럼 사용하면 된다. JobList 컴포넌트가 반드시 최신 상태를 표현하려면 폴링이나 웹소켓 등의 방법을 사용해야 한다. 아래 예시에서는 간단한 폴링 방식으로 최신 상태를 업데이트하는 것을 볼 수 있다.
  • 폴링: 클라이언트가 주기적으로 서버에 요청을 보내 데이터를 업데이트하는 것이다. 클라이언트는 일정한 시간 간격으로 서버에 요청을 보내고 서버는 해당 요청에 대해 최신 상태의 데이터를 응답으로 보내주는 방식을 말한다.
const JobList: React.FC = () => {
  // 비동기 데이터를 필요한 컴포넌트에서 자체 상태로 저장
  const {
    isLoading,
    isError,
    error,
    refetch,
    data: jobList,
  } = useFetchJobList();
 
  // 간단한 Polling 로직, 실시간으로 화면이 갱신돼야 하는 요구가 없어서 // 30초 간격으로 갱신한다
  useInterval(() => refetch(), 30000);
 
  // Loading인 경우에도 화면에 표시해준다
  if (isLoading) return <LoadingSpinner />;
 
  // Error에 관한 내용은 11.3 API 에러 핸들링에서 더 자세하게 다룬다
  if (isError) return <ErrorAlert error={error} />;
 
  return (
    <>
      {jobList.map((job) => (
        <Job job={job} />
      ))}
    </>
  );
};
  • 최근 사내에서도 Redux나 MobX와 같은 전역 상태 관리 라이브러리를 react-query로 변경하고자 하는 시도가 이뤄지고 있다. 앞서 언급했다시피 상태 관리 라이브러리에서는 비동기로 상태를 변경하는 코드가 점점 추가되면서 전역 상태 관리 스토어가 비대해지기 때문이다.
  • 에러 발생, 로딩 중 등과 같은 상태는 전역으로 관리할 필요가 거의 없다. 다른 컴포넌트가 에러 상태인지, 성공 상태인지를 구독하는 경우 컴포넌트의 결합도와 복잡도가 높아져 유지보수를 어렵게 만들 수 있다. 이런 고민으로 인해 비동기 통신을 react-query를 사용해서 처리하고 있다.
  • react-query를 가장 많이 활용하고 있지만 react-query는 전역 상태 관리를 위한 라이브러리가 아닌 만큼 상태 관리 라이브러리 중에서 가장 뛰어나다는 의미는 아니다. 어떤 상태 관리 라이브러리를 선택할지는 프로젝트의 도메인, 개발자의 학습 곡선 그리고 기존 코드의 호환성 등에 따라 달라질 수 있다. 상태 관리 라이브러리는 고정된 모법 사례가 있는 것이 아니기 때문에 상황에 따라 적절한 판단이 필요하다.

7.3 API 에러 핸들링

  • 비동기 API 호출을 하다 보면 상태 코드에 따라 401(인증되지 않은 사용자), 404(존재하지 않는 리소스), 500(서버 내부 에러) 혹은 CORS 에러 등 다양한 에러가 발생할 수 있다. 타입스크립트에서는 어떻게 이러한 에러를 처리하고 명시할 수 있는지 알아보자. 코드에서 발생할 수 있는 에러 상황에 대해 명시적인 코드를 작성하면 유지보수가 용이해지고, 사용자에게도 구체적인 에러 상황을 전달할 수 있다. 이 절에서는 비동기 API 에러를 구체적이고 명시적으로 핸들링하는 방법을 예시와 함께 살펴본다.

7.3.1 타입 가드 활용하기

  • Axios 라이브러리에서는 Axios 에러에 대해 isAxiosError라는 타입 가드를 제공하고 있다. 이 타입 가드를 직접 사용할 수 있지만 서버 에러임을 명확하게 표시하고 서버에서 내려주는 에러 응답 객체에 대해서도 구체적으로 정의함으로써 에러 객체가 어떤 속성을 가졌는지를 파악할 수 있다.
  • 다음과 같이 서버에서 전달하는 공통 에러 객체에 대해 타입을 정의할 수 있다.
interface ErrorResponse {
  status: string;
  serverDateTime: string;
  errorCode: string;
  errorMessage: string;
}
  • 다음과 같이 타입 가드를 명시적으로 작성할 수 있다.
function isServerError(error: unknown): error is AxiosError<ErrorResponse> {
  return axios.isAxiosError(error);
}
  • 사용자 정의 타입 가드를 정의할 때는 타입 가드 함수의 반환 타입으로 parameterName is Type 형태의 타입 명제를 정의해주는 게 좋다. 이때 parameter Name은 타입 가드 함수의 시그니처에 포함된 매개변수여야 한다.
const onClickDeleteHistoryButton = async (id: string) => {
  try {
    await axios.post("https://....", { id });
 
    alert("주문 내역이 삭제되었습니다.");
  } catch (error: unknown) {
    if (isServerError(e) && e.response && e.response.data.errorMessage) {
      // 서버 에러일 때의 처리임을 명시적으로 알 수 있다 setErrorMessage(e.response.data.errorMessage);
      return;
    }
    setErrorMessage("일시적인 에러가 발생했습니다. 잠시 후 다시 시도해주세요");
  }
};
  • 이처럼 타입 가드를 활용하면 서버 에러를 명시적으로 확인할 수 있다.

7.3.2 에러 서브클래싱하기

  • 실제 요청을 처리할 때 단순한 서버 에러도 발생하지만 인증 정보 에러, 네트워크 에러, 타임 아웃 에러 같은 다양한 에러가 발생하기도 한다. 이를 더욱 명시적으로 표시하기 위해 서브클래싱을 활용할 수 있다.
  • 서브클래싱 : 기존 클래스를 확장하여 새로운 클래스를 만드는 과정을 말한다. 새로운 클래스는 상위 클래스의 모든 속성과 메서드를 상속받아 사용할 수 있고 추가적인 속성과 메서드를 정의할 수도 있다.
  • 사용자에게 주문 내역 보여주기 위해 서버에 주문 내역을 요청할 때는 다음과 같은 코드를 작성할 수 있다.
const getOrderHistory = async (page: number): Promise<History> => {
  try {
    const { data } = await axios.get(`https://some.site?page=${page}`);
    const history = await JSON.parse(data);
 
    return history;
  } catch (error) {
    alert(error.message);
  }
};
  • 이 코드는 주문 내역을 요청할 때 에러가 발생하면 에러 메시지를 얼럿을 사용하여 사용자에게 표시해준다. 이때 '로그인 정보가 만료되었습니다', '유효하지 않는 요청 데이터입니다.'와 같이 서버에서 전달된 에러 메시지를 보고 사용자는 어떤 에러가 발생한 것인지 판단할 수 있더라도 개발자 입장에서는 사용자 로그인 정보가 만료되었는지 타임아웃이 발생한 건지 혹은 데이터를 잘못 전달한 것인지를 구분할 수 없다.
  • 이때 서브클래싱을 활용하면 에러가 발생했을 때 코드상에서 어떤 에러인지를 바로 확인할 수 있다. 또한 에러 인스턴스가 무엇인지에 따라 에러 처리 방식을 다르게 구현할 수 있다.
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, response);
    this.name = "UnauthorizedError";
  }
}
  • 그다음 아래와 같이 에러 객체를 상속한 OrderHttpError, NetworkError, UanthorizedError를 정의한다. Axios를 사용하고 있다면 조건에 따라 인터셉터에서 적합한 에러 객체를 전달할 수 있다.
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.message, "Network Error")) {
      promiseError = Promise.reject(new NetworkError());
    } else {
      const { response } = error as AxiosError<ErrorResponse>;
      switch (response?.status) {
        case HttpStatusCode.UNAUTHORIZED:
          promiseError = Promise.reject(
            new UnauthorizedError(response?.data.message, response)
          );
          break;
        default:
          promiseError = Promise.reject(
            new OrderHttpError(response?.data.message, response)
          );
      }
    }
  } else {
    promiseError = Promise.reject(error);
  }
 
  return promiseError;
};
  • 다시 요청 코드로 돌아와서 다음과 같이 활용할 수 있다.
const alert = (meesage: string, { onClose }: { onClose?: () => void }) => {};
 
const onActionError = (
  error: unknown,
  params?: Omit<AlertPopup, "type" | "message">
) => {
  if (error instanceof UnauthorizedError) {
    onUnauthorizedError(
      error.message,
      errorCallback?.onUnauthorizedErrorCallback
    );
  } else if (error instanceof NetworkError) {
    alert("네트워크 연결이 원활하지 않습니다. 잠시 후 다시 시도해주세요.", {
      onClose: errorCallback?.onNetworkErrorCallback,
    });
  } else if (error instanceof OrderHttpError) {
    alert(error.message, params);
  } else if (error instanceof Error) {
    alert(error.message, params);
  } else {
    alert(defaultHttpErrorMessage, params);
  }
 
  const getOrderHistory = async (page: number): Promise<History> => {
    try {
      const { data } = await fetchOrderHistory({ page });
      const history = await JSON.parse(data);
 
      return history;
    } catch (error) {
      onActionError(error);
    }
  };
};
  • 이처럼 에러를 서브클래싱에서 표현하면 명시적으로 에러 처리를 할 수 있다. error instaneof OrderHttpError와 같이 작성된 타입 가드문을 통해 코드상에서 에러 핸들링에 대한 부분을 한눈에 볼 수 있다.

7.3.3 인터셉터를 활용한 에러 처리

  • Axios 같은 페칭 라이브러리는 인터셉터 기능을 제공한다. 이를 사용하면 HTTP 에러에 일관된 로직을 적용할 수 있다.
const httpErrorHandler = (
  error: AxiosError<ErrorResponse> | Error
): Promise<Error> => {
  (error) => {
    // 401 에러인 경우 로그인 페이지로 이동
    if (error.response && error.response.status === 401) {
      window.location.href = `${backOfficeAuthHost}/login?targetUrl=${window.location.href}`;
    }
    return Promise.reject(error);
  };
};
 
orderApiRequester.interceptors.response.use(
  (response: AxiosResponse) => response,
  httpErrorHandler
);

7.3.4 에러 바운더리를 활용한 에러 처리

  • 에러 바운더리는 리액트 컴포넌트 트리에서 에러가 발생할 때 공통으로 처리하는 리액트 컴포넌트이다. 에러 바운더리를 사용하면 리액트 컴포넌트 트리 하위에 있는 컴포넌트에서 발생한 에러를 캐치하고 해당 에러를 가장 가까운 부모 에러 바운더리에서 처리하게 할 수 있다. 에러 바운더리는 에러가 발생한 컴포넌트 대신에 에러 처리를 하거나 예상치 못한 에러를 공통 처리할 때 사용할 수 있다.
import React, { ErrorInfo } from "react";
import ErrorPage from "pages/ErrorPage";
 
interface ErrorBoundaryProps {}
 
interface ErrorBoundaryState {
  hasError: boolean;
}
 
class ErrorBoundary extends React.Component<
  ErrorBoundaryProps,
  ErrorBoundaryState
> {
  constructor(props: ErrorBoundaryProps) {
    super(props);
    this.state = { hasError: false };
  }
 
  static getDerivedStateFromError(): ErrorBoundaryState {
    return { hasError: true };
  }
 
  componentDidCatch(error: Error, errorInfo: ErrorInfo): void {
    this.setState({ hasError: true });
 
    console.error(error, errorInfo);
  }
 
  render(): React.ReactNode {
    const { children } = this.props;
    const { hasError } = this.state;
 
    return hasError ? <ErrorPage /> : children;
  }
}
 
const App = () => {
  return (
    <ErrorBoundary>
      <OrderHistoryPage />
    </ErrorBoundary>
  );
};
  • 이처럼 작성하면 OrderHistoryPage 컴포넌트 내에서 처리되지 않은 에러가 있을 때 에러 바운더리에서 에러 페이지를 노출한다. 이외에도 에러 바운더리에 로그를 보내는 코드를 추가하여 예상치 못한 에러의 발생 여부를 추적할 수 있게 된다.

7.3.5 상태 관리 라이브러리에서의 에러 처리

  • 앞서 잠깐 살펴본 Redux의 에러 처리 방법은 다음과 같다.
// API 호출에 관한 api call reducer
const apiCallSlice = createSlice({
  name: "apiCall",
  initialState,
  reducers: {
    setApiCall: (state, { payload: { status, urlInfo } }) => {
      /* API State를 채우는 logic */
    },
    setApiCallError: (state, { payload }: PayloadAction<any>) => {
      state.error = payload;
    },
  },
});
 
const API = axios.create();
 
const setAxiosInterceptor = (store: EnhancedStore) => {
  /* 중복 코드 생략 */
  // onSuccess시 처리를 인터셉터로 처리한다
  API.interceptors.response.use(
    (response: AxiosResponse) => {
      const { method, url } = response.config;
 
      store.dispatch(
        setApiCall({
          status: ApiCallStatus.None, // API 호출 상태를 `요청되지 않음`으로 변경
          urlInfo: { url, method },
        })
      );
 
      return response?.data?.data || response?.data;
    },
    (error: AxiosError) => {
      // 401 unauthorized
      if (error.response?.status === 401) {
        window.location.href = error.response.headers.location;
 
        return;
      }
      // 403 forbidden
      else if (error.response?.status === 403) {
        window.location.href = error.response.headers.location;
        return;
      }
      // 그 외에는 화면에 alert 띄우기
      else {
        message.error(`[서버 요청 에러]: ${error?.response?.data?.message}`);
      }
 
      const {
        config: { url, method },
      } = error;
 
      store.dispatch(
        setApiCall({
          status: ApiCallStatus.None, // API 호출 상태를 `요청되지 않음`으로 변경
          urlInfo: { url, method },
        })
      );
 
      return Promise.reject(error);
    }
  );
};
  • 에러 상태를 관리하지 않고 처리할 수 있다면 바로 처리(예: 401, 403)하고, 그렇지 않다면 reject로 넘겨준다. 이후 액션을 정의하면서 setApiCallError를 사용하여 에러를 상태로 처리한다.
const fetchMenu = createAsyncThunk(
  FETCH_MENU_REQUEST,
  async ({ shopId, menuId }: FetchMenu) => {
    try {
      const data = await api.fetchMenu(shopId, menuId);
      return data;
    } catch (error) {
      setApiCallError({ error });
    }
  }
);
  • 이렇게 저장된 에러는 컴포넌트에서 사용할 수 있다. 만약 MobX를 사용하고 있다면 주로 스토어에서 에러 핸들링을 한다. 외부에서는 별도로 성공/실패 등에 참조하지 않으며 비동기 동작의 수행 및 결과값을 사용한다.
class JobStore {
  jobs: Job[] = [];
  state: LoadingState = "PENDING"; // "PENDING" | "DONE" | "ERROR"; errorMsg = "";
 
  constructor() {
    makeAutoObservable(this);
  }
 
  async fetchJobList() {
    this.jobs = [];
    this.state = "PENDING";
    this.errorMsg = "";
 
    try {
      const projects = await fetchJobList();
 
      runInAction(() => {
        this.projects = projects;
        this.state = "DONE";
      });
    } catch (e) {
      runInAction(() => {
        // 에러 핸들링 코드를 작성
        this.state = "ERROR";
        this.errorMsg = e.message;
        showAlert();
      });
    }
  }
 
  get isLoading(): boolean {
    return state === "PENDING";
  }
}
 
const JobList = (): JSX.Element => {
  const [jobStore] = useState(() => new JobStore());
 
  if (jobStore.job.isLoading) {
    return <Loader />;
  }
 
  return (
    <>
      {jobStore.jobs.map((job) => (
        <Item job={job} />
      ))}
    </>
  );
};

7.3.6 react-query를 활용한 에러 처리

  • react-query나 swr과 같은 데이터 페칭 라이브러리를 사용하면 요청에 대한 상태를 반환해 주기 때문에 요청 상태를 확인하기 쉽다. 다음은 react-query를 사용한 예시다.
const JobComponent: React.FC = () => {
  const { isError, error, isLoading, data } = useFetchJobList();
  if (isError) {
    return (
      <div>{`${error.message}가 발생했습니다. 나중에 다시 시도해주세요.`}</div>
    );
  }
  if (isLoading) {
    return <div>로딩 중입니다.</div>;
  }
  return (
    <>
      {data.map((job) => (
        <JobItem key={job.id} job={job} />
      ))}
    </>
  );
};

7.3.7 그 밖의 에러 처리

  • API 응답은 주로 성공 시 2XX 코드를, 실패 시 4XX, 5XX 코드를 반환한다. 일반적으로 API 요청 라이브러리에서도 HTTP 상태 코드에 따라 성공 응답인지 실패 응답인지를 판단한다. 그러나 비즈니스 로직에서의 유효성 검증에 의해 추가된 커스텀 에러는 200 응답과 함께 응답 바디에 별도의 상태 코드를 전달하기도 한다. 이러한 상황에서는 커스텀 에러를 어떻게 구현하고 처리할지에 대한 논의가 이루어질 수 있다. 하지만 이미 설계가 그렇게 되어있거나 레거시로 남아있지만 영향 범위가 넓어서 대응할 수 없을 때 등 200번 대의 성공 응답에 대한 에러 처리가 필요한 상황이 생길 수 있다.
  • 예를 들어 장바구니에서 주문을 생성하는 API가 다음과 같은 커스텀 에러를 반환한다고 해보자
httpStatus: 200
{
	"status":"C20005", //성공인 경우 "SUCCESS"를 응답
    "message":"장바구니에 품절된 메뉴가 있습니다."
 }
  • 이 에러를 처리하기 위해 요청 함수 내에서 조건문으로 status(상태)를 비교할 수 있다.
const successHandler = (response: CreateOrderResponse) => {
  if (response.status === "SUCCESS") {
    // 성공 시 진행할 로직을 추가한다
    return;
  }
  throw new CustomError(response.status, response.message);
};
const createOrder = (data: CreateOrderData) => {
  try {
    const response = apiRequester.post("https://...", data);
 
    successHandler(response);
  } catch (error) {
    errorHandler(error);
  }
};
  • 이 방법을 사용하면 간단하게 커스텀 에러를 처리할 수 있다. 또한 영향 범위가 각 요청에 대한 성공/실패 응답 처리 함수로 한정되어 관리하기 편리해진다. 그러나 이렇게 처리해야 하는 API가 많을 때는 매번 if 문을 추가해야 한다.
  • 만약 커스텀 에러를 사용하고 있는 요청을 일괄적으로 에러로 처리하고 싶다면 Axios 등의 라이브러리 기능을 활용하면 된다. 특정 호스트에 대한 API requester를 별도로 선언하고 상태 코드 비교 로직을 인터셉터에 추가할 수 있다.
export const apiRequester: AxiosInstance = axios.create({
  baseURL: orderApiBaseUrl,
  ...defaultConfig,
});
 
export const httpSuccessHandler = (response: AxiosResponse) => {
  if (response.data.status !== "SUCCESS") {
    throw new CustomError(response?.data.message, response);
  }
 
  return response;
};
 
apiRequester.interceptors.response.use(httpSuccessHandler, httpErrorHandler);
 
const createOrder = (data: CreateOrderData) => {
  try {
    const response = apiRequester.post("https://...", data);
 
    successHandler(response);
  } catch (error) {
    // status가 SUCCESS가 아닌 경우 에러로 전달된다
    errorHandler(error);
  }
};
  • 인터셉터에서 커스텀 에러를 판단하고 에러를 던짐으로써 외부에서 200번 대로 온 응답이라도 400번 대, 500번 대 같은 에러로 받게 된다. 이후 성공 핸들러에서는 성공인 경우의 동작만 작성하고 에러 핸들러에서 커스텀 에러를 처리할 수 있다.

7.4 API 모킹

  • 프론트엔드 개발을 하다보면 서버 API가 완성되기 전에 개발을 진행해야 하는 일이 종종 생긴다. 기획이 완료되고 서버 API가 완성된 다음에 프론트엔드 개발을 한 후 QA를 진행할 수 있다면 좋겠지만 현실에서는 프론트엔드 개발이 서버 개발보다 먼저 이루어지거나 서버와 프론트엔드 개발이 동시에 이루어지는 경우가 더 많다.
  • 그렇다면 이러한 상황에서 프론트엔드 개발을 어떻게 진행할 수 있을까? 단순하게는 개발 중인 코드에 TEMP_DELIVERY_STATUS_TEXT="배달 중이에요."와 같이 임시 변수를 만들어 우선 UI를 구현할 수 있을 것이다. 그런데 리뷰 작성, 주문하기와 같은 POST 요청을 보내야 한다면 그리고 요청 응답에 따라 각기 다른 팝업을 보여주어야 한다면 어떻게 해야 할까?
  • 서버가 별도의 가짜 서버를 제공한다고 하더라도 프론트엔드 개발 과정에서 발생할 수 있는 모든 예외 사항을 처리하는 것은 쉽지 않다. 또한 매번 테스트를 위해 구현을 반복해야 하기 때문에 번거로울 수 있다.
  • 이럴 때 모킹이라는 방법을 활용할 수 있다. 모킹은 가짜 모듈을 활용하는 것을 말한다. API를 사용하는 함수 또는 컴포넌트에 대한 테스트를 작성해봤다면 한 번쯤은 jest.fn()과 같은 같은 방법으로 API 함수를 모킹해봤을 것이다. 모킹은 테스트할 때뿐만 아니라 개발할 때도 사용할 수 있다.
  • 모킹을 활용하면 앞서 제시한 상황에서 유연하게 대처할 수 있게 된다. 또한 dev 서버가 불안정하거나 AWS 등에 문제가 생겼을 때 같은 서버 상태에 문제가 발생한 경우에도 서버의 영향을 받지 않고 프론트엔드 개발을 할 수 있게 된다.
  • 이외에도 이슈가 생겼을 때 charles 등의 도구를 활용하면 응답 값을 그대로 복사하여 이슈 발생 상황을 재현하는데 도움이 된다. 또한 개발하면서 다양한 예외 케이스의 응답을 편하게 테스트해볼 수 있다. 우아한형제들 프론트엔드에서는 axios-mock-adapter, NextApi-Handler 등을 활용해 API를 모킹해서 사용하고 있다.

7.4.1 JSON 파일 불러오기

  • 간단한 조회만 필요한 경우에는 json 파일을 만들거나 자바스크립트 파일 안에 JSON 형식의 정보를 저장하고 익스포트해주는 방식을 사용하면 된다. 이후 GET 요청에 파일 경로를 삽입해주면 조회 응답으로 원하는 값을 받을 수 있다.
// mock/service.ts
const SERVICES: Service[] = [
  {
    id: 0,
    name: "배달의민족",
  },
  {
    id: 1,
    name: "만화경",
  },
];
 
export default SERVICES;
 
// api.ts
const getServices = ApiRequester.get("/mock/service.ts");
  • 이 방법은 별도의 환경 설정이 필요하지 않아 쉽게 구현할 수 있다. 프로젝트 초기 단계에서 사용자의 인터랙션없이 빠르게 목업을 구축할 때 유용하게 사용할 수 있다. 그러나 실제 API URL로 요청하는 것이 아니기 때문에 추후에 요청 경로를 바꿔야 한다.

7.4.2 NextApiHandler 활용하기

  • 프로젝트에서 Next.js를 사용하고 있다면 NextApiHandler를 활용할 수 있다. NextApiHandler는 하나의 파일 안에 하나의 핸들러를 디폴트 익스포트로 구현해야 하며 파일의 경로가 요청 경로가 된다.
  • 핸들러를 정의하는 것은 간단하다. 응답하고자 하는 값을 정의하고 핸들러 안에 요청에 대한 응답을 정의한다. 핸들러를 사용하는 경우 단순히 파일을 불러오는 것과 다르게 중간 과정에 응답 처리 로직을 추가할 수 있다.
  • Next.js : 리액트 기반의 프론트엔드 프레임워크다. 리액트 애플리케이션을 쉽게 구축하고 운영하기 위한 도구를 제공하는 프레임워크로서, 서버 사이드 렌더링, 정적 사이트 생성, 코드 스플리팅 등의 기능을 내장하고 있다.
// api/mock/brand
import { NextApiHandler } from "next";
 
const BRANDS: Brand[] = [
  {
    id: 1,
    label: "배민스토어",
  },
  {
    id: 2,
    label: "비마트",
  },
];
 
const handler: NextApiHandler = (req, res) => {
  // request 유효성 검증
  res.json(BRANDS);
};
 
export default handler;

7.4.3 API 요청 핸들러에 분기 추가하기

  • 요청 경로를 수정하지 않고 개발할 때 필요한 경우에만 실제 요청을 보내고 그 외에는 목업을 사용하여 개발하고 싶다면 다음과 같이 처리할 수도 있다. API 요청을 훅 또는 별도 함수로 선언해준 다음 조건에 따라 목업 함수를 내보내거나 실제 요청 함수를 내보낼 수 있다.
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");
};
  • 이 방법을 사용하면 개발이 완료된 이후에도 유지보수할 때 목업 함수를 사용할 수 있다. 필요한 경우에만 실제 API에 요청을 보내고 평소에는 서버에 의존하지 않고 개발할 수 있게 된다. 그러나 모든 API 요청 함수에 if 분기문을 추가해야 하므로 번거롭게 느껴질 수도 있다.

7.4.4 axios-mock-adapter로 모킹하기

  • 서비스 함수에 분기문이 추가되는 것을 바라지 않는다면 라이브러리를 사용하면 된다. axios-mock-adapter는 Axios 요청을 가로채서 요청에 대한 응답 값을 대신 반환한다. 먼저 MockAdapter 객체를 생성하고 해당 객체를 사용하여 모킹할 수 있다. 앞선 2가지 방법과 다르게 mock API의 주소가 필요하지 않다. 앞의 방법과 비슷하게 조회 요청에 대한 목업을 작성하면 다음과 같다.
// mock/index.ts
import axios from "axios";
import MockAdapter from "axios-mock-adapter";
import fetchOrderListSuccessResponse from "fetchOrderListSuccessResponse.json";
 
interface MockResult {
  status?: number;
  delay?: number;
  use?: boolean;
}
 
const mock = new MockAdapter(axios, { onNoMatch: "passthrough" });
 
export const fetchOrderListMock = () =>
  mock.onGet(/\/order\/list/).reply(200, fetchOrderListSuccessResponse);
// fetchOrderListSuccessResponse.json
{
  "data": [
    {
      "orderNo": "ORDER1234",
      "orderDate": "2022-02-02",
      "shop": {
        "shopNo": "SHOP1234",
        "name": "가게이름1234"
      },
      "deliveryStatus": "DELIVERY"
    }
  ]
}
  • 단순히 응답 바디만 모킹할 수도 있지만 상태 코드, 응답 지연 시간 등을 추가로 설정할 수도 있다. 이에 따라 다양한 HTTP 상태 코드에 대한 목업을 정의할 수 있고, API별로 지연 시간을 다르게 설정할 수 있다. 이렇게 응답 처리를 하는 부분을 별도 함수로 구현하면 여러 mock 함수에서 사용할 수 있다.
export const lazyData = (
  status: number = Math.floor(Math.random() * 10) > 0 ? 200 : 200,
  successData: unknown = defaultSuccessData,
  failData: unknown = defaultFailData,
  time = Math.floor(Math.random() * 1000)
): Promise<any> =>
  new Promise((resolve) => {
    setTimeout(() => {
      resolve([status, status === 200 ? successData : failData]);
    }, time);
  });
 
export const fetchOrderListMock = ({
  status = 200,
  time = 100,
  use = true,
}: MockResult) =>
  use &&
  mock
    .onGet(/\/order\/list/)
    .reply(() =>
      lazyData(status, fetchOrderListSuccessResponse, undefined, time)
    );
  • axios-mock-adapter를 사용하면 GET뿐만 아니라 POST, PUT, DELETE 등 다른 HTTP 메서드에 대한 목업을 작성할 수 있게 된다. 또한 networkError, timeoutError 등을 메서드로 제공하기 때문에 다음처럼 임의로 에러를 발생시킬 수도 있다.
export const fetchOrderListMock = () =>
  mock.onPost(/\/order\/list/).networkError();

7.4.5 목업 사용 여부 제어하기

  • 로컬에서는 목업을 사용하고 dev나 운영환경에서는 사용하지 않으려면 간단한 설정을 해주면 되는데 플래그를 사용하여 목업으로 개발할 때와 개발하지 않을 때를 구분할 수 있다.
  • 이렇게 하면 프로덕션에서 사용되는 코드와 목업을 위한 코드를 분리할 필요가 없다. 프론트엔드 코드를 작성하고 요청을 보낼 때 실제 엔드포인트를 쓸 수 있으므로 새로운 기능을 개발할 때 말고도 유지보수할 때도 작성해둔 목업을 사용할 수 있다. 이렇게 로컬에서 개발할 때는 주로 목업을 사용하고, dev 서버 환경이 필요할 때는 dev 서버를 바라보도록 설정할 수 있다. 이런 식으로 프론트엔드와 서버를 독립시킬 수 있고 혹여나 dev 서버에 문제가 생기더라도 로컬에서 진행되는 프론트엔드 개발에는 영향을 주지 않는다.
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에 관련 스크립트를 추가해 줄 수 있다.
{
  "scripts": {
    "start:mock": "REACT_APP_MOCK=true npm run start",
    "start": "REACT_APP_MOCK=false npm run start"
  }
}
  • 이렇게 자바스크립트 코드의 실행 여부를 제어하지 않고 config 파일을 별도로 구성하거나 프록시를 사용할 수도 있다.
  • axios-mock-adapter를 사용하는 경우에는 API 요청을 중간에 가로채는 것이기 때문에 실제로 API 요청을 주고받지 않는다. 따라서 브라우저가 제공하는 개발자 도구의 네트워크 탭에서는 확인하기 어렵다. API 요청의 흐름을 파악하고 싶다면 react-query-devtools 혹은 redux test tool과 같이 별도의 도구를 사용해야 한다.
  • 목업을 사용할 때 네트워크 요청을 확인하고 싶을 때는 네트워크에 보낸 요청을 변경해주는 Cypress 같은 도구의 웹훅을 사용하면 된다.
  • Cypress : 프론트엔드 테스트를 위한 오픈 소스 자바스크립트 엔드 투 엔드 테스트 도구이다. 주로 웹 애플리케이션의 동작을 시뮬레이션하고 테스트하는데 사용된다. Cypress는 사용하기 쉽고 강력한 기능을 제공하여 웹 애플리케이션을 더욱 견고하고 안정적으로 개발할 수 있도록 도와준다.
  • 앞에서 소개한 모킹 방식 외에도 최근에는 서비스워커를 활용하는 라이브러리인 MSW를 도입한 팀도 있다. MSW를 사용하면 모킹 시 개발 환경과 운영 환경을 분리할 수 있으며, 개발자 도구인 네트워크 탭에서 API 통신을 확인할 수 있다. 이 책에서는 다루지 않지만 MSW를 래핑한 개발자 도구를 만들어 사용하기도 한다. 자세한 내용은 우아한형제들의 다른 채널에서 소개할 예정이다.