이번장의 내용은 배민 팀에서 비동기 호출 중에서 API 요청 및 응답 행위를 어떻게 처리했는지를 다룬다.
각각의 문제점을 어떻게 개선 및 보완하려했고 시도했던 방법들을 언급해준다.
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;
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: {
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'
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() 모듈의 반환 결과다. (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 = {
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 같은 훅을 사용한 방법은 훨씬 간단하다. 이러한 훅은 캐시를 사용하여 비동기 함수를 호출하며, 상태 관리 라이브러리에서 발생했던 읟도치 않은 상태 변경을 방지하는 데 도움이 된다.