일반적으로 대부분의 서비스는 데이터베이스에 접근하는 행위를 담당하는 백엔드와 UI 를 담당하는 프론트엔드로 분리되어 운영된다. 프론트엔드는 주로 HTTP 프로토콜을 통해 백엔드와 통신한다. 특히, REST API 를 주로 사용한다.
API 를 요청하고 응답받는 행위는 모두 비동기로 이루어진다. 비동기 처리를 다룰 때는 다음과 같은 사항을 고려해야한다.
이 장은 TS 에서 비동기 요청을 어떻게 처리하고 관리하는 지를 알아보겠다.
배달이(26세, 신입 프론트엔드 개발자)는 사용자가 장바구니를 조회해서 볼 수 있는 기능을 만들게 되었다. 기능은 외부 데이터베이스에 fetch 함수를 사용하여 접근해, 장바구니 물품 개수를 배지로 보여준다. 코드는 다음과 같다.
import { useEffect, useState } from "react";
const CartBadge: React.FC = () => {
const [cartCount, setCartCount] = useState(0);
useEffect(() => {
fetch("https://api.com/cart")
.then((response) => {
response.json();
})
.then(({cartItem}) => {
setCartCount(cartItem.length);
});
}, []);
return <></> // cartCount 상태를 이용하여 컴포넌트 렌더링
};
만일, 백엔드에서 기능 변경을 해야해서 API URL 을 수정해야 한다고 하자. 혹은 “여러 서버에 API 를 요청할 때 타임아웃 설정이 필요하다’ 또는 ‘모든 요청에 커스텀 헤더가 필요하다’ 와 같은 새로운 API 요청 정책이 추가된다고 가정해보자.
위의 코드는 이러한 변경 요구에 취약하다. 변경 요구 가 추가될 때마다 컴포넌트 내부에 이미 자리잡은 코드들을 일일이 수정해야하는 번거로움이 발생한다.
그렇다면 API 요청 정책이 추가되어 코드가 변경될수있는 위와 같은 상황에선 어떻게 하는 것이 좋을까? 비동기 호출 코드 (API 호출 코드) 를 컴포넌트 영역에서 분리하여, 다른 영역(서비스 레이어) 에서 처리하는 것이다.
async function fetchCart() {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller, 5000);
const response = await fetch("https://api.baemin.com/cart", {
signal: controller.signal,
});
clearTimeout(timeoutId);
return response;
}
앞의 코드를 기준으로 설명하면, fetch 함수를 호출하는 부분이 서비스 레이어로 이동하고, 컴포넌트는 서비스 레이어의 비동기 함수를 호출하여 그 결과를 받아와 렌더링하는 흐름이된다.
따라서 API 요청 정책이 추가되거나 변경된다면, 분리한 서비스 레이어에서만 코드변경이 일어나게 된다.
하지만 여전히 끊임없이 변경되는 API 요청 정책을 효과적으로 추가하는 것은 해결하기 어렵다. 현재로써는, 커스텀 헤더 추가, 혹은 쿠키를 읽어 토큰을 집어 넣는 등 다양한 정책을 직접 구현하는 수 밖에 없다.
fetch 는 내장 라이브러리 이기 때문에 따로 설치할 필요없다는 장점이 있지만, 많은 기능을 사용하려면 직접 구현해서 사용해야한다. 이러한 번거로움 때문에 fetch 함수를 직접 쓰는 대신 Axios 라이브러리를 사용할 수있다.
위와 같이 Axios 인스턴스 를 생성할때 설정을 포함하면, 모든 API 호출에서 같은 설정을 적용할 수 있게 되어, API 정책이 추가되어도 일일이 수정할 필요가 없어진다.
기존에 사용하는 API Entry(baseUrl) 와는 다른 새로운 URL 로 요청해야하는 상황이 생길 수 있다. 이러한 경우에는 각각의 Axios 인스턴스 를 생성할 수 있다.
const defaultConfig = {
baseURL: 'https://api.baemin.com',
timeout: 5000,
};
const orderApiRequester: AxiosInstance = axios.create({
baseUrl: 'https://api.baemin.or',
...defaultConfig,
});
const orderCartApiRequester: AxiosInstance = axios.create({
baseUrl: 'https://cart.baemin.order',
...defaultConfig,
});
위의 예제에서 각각의 requester 는 서로 다른 역할을 담당하기 때문에, request 별로 다른 헤더(header) 를 설정해줘야 하는 로직이 필요할 수도 있다.
이때 인터셉터 기능을 사용하여 requester 에 따라 비동기 호출 내용을 추가해서 처리할 수 있다. 혹은, API 에러를 처리할 때 하나의 에러 객체로 묶어서 처리할 수 도 있다.
import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse } from "axios";
const getUserToken = ()=> "";
const getAgent = ()=> "";
const getOrderClientToken = () =>"";
const orderApiBaseUrl = "";
const defaultHeader = {
"Content-Type": "application/json;charset=utf-8",
user: getUserToken(),
agent: getAgent(),
}
// 각각의 requester 에 해당하는 axios instance 생성
const apiRequester:AxiosInstance = axios.create({
baseURL: 'https://api.baemin.com',
timeout: 5000,
})
const orderApiRequester:AxiosInstance = axios.create({
baseURL: orderApiBaseUrl,
timeout: 5000,
});
const setRequestHeader = (requestConfig: AxiosRequestConfig)=> {
const config = requestConfig;
config.headers = {
...config.headers,
...defaultHeader,
};
return config;
}
const setOrderRequestHeader = (requestConfig: AxiosRequestConfig)=> {
const config = requestConfig;
config.headers = {
...config.headers,
...defaultHeader,
"order-client": getOrderClientToken()
}
return config
}
// interceptor.request 기능을 사용해 header 를 설정하는 기능을 넣거나 에러를 처리할 수 있음
apiRequester.interceptors.request.use(setRequestHeader);
// 기본 apiRequester 와는 다른 header 를 설정하는 interceptors
orderApiRequester.interceptors.request.use(setOrderRequestHeader);
// interceptors.reponse 를 사용해 httpError 같이 API 에러를 일괄적으로 처리할 수 있음
orderApiRequester.interceptors.response.use(
(response:AxiosResponse)=> response,
httpErrorHandler)
이와 달리 요청 옵션에 따라 다른 인터셉터를 만들기 위해 빌더 패턴을 추가하여 APIBuilder 같은 클래스 형태를 구성하기도 한다.
빌더 패턴은 객체 생성을 더 편리하고 가독성 있게 만들기 위한 디자인 패턴 중 하나다. 주로 복잡한 객체의 생성을 단순화하고, 객체 생성 과정을 분리하여 객체를 조립하는 방법을 제공한다.
APIBuilder 클래스를 만들기전, 실제 API 호출 역할을 하는 예제인 API 클래스를 만들어보겠다.
class API {
readonly method: HTTPMethod;
readonly url:string;
baseURL: string;
headers?: HTTPHeaders;
params?: HTTPParams;
data?: unknown;
timeout?: number;
withCredentials: boolean;
constructor(method:HTTPMethos, 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})
}
}
이처럼 기본 API 클래스로 실제 호출 부분을 구성하고, 위와 같은 API 를 호출하기 위한 래퍼를 빌더 패턴으로 만든다.
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
}
}
이와 같은 패턴으로 제공한 APIBuilder 를 사용하는 코드는 다음과 같다.
const fetchJobNameList = async (name?:string, size?:number) => {
// 각 메서드는 this 를 반환하기 때문에 다음과 같은 방식으로 메서드에 접근할 수 있다.
const api = APIBuilder.get("/api/jobs")
.withCredentials(true) // 이제 401 에러가 나는 경우, 자동으로 에러를 탐지하는
// 인터셉터를 사용하게 된다.
.params({name, size}) // body 가 없는 axios 객체도 빌더 패턴으로 쉽게 만들 수 있다.
.build();
const {data} = api.call<Response<JobNameListResponse>>();
return data;
}
위의 APIBuilder 클래스는 보일러플레이트 코드가 많다는 단점을 가지고 있다. 하지만 옵션이 다양한 경우, 인터셉터를 선택적으로 사용하고, 설정값에 따라 적용할 수 있다는 장점 또한 가지고 있다. 사용하는 API 의 요청 옵션에 따라 고려하여 사용하면 좋을 듯 하다.
보일러플레이트 코드 는 어떤 기능을 사용할 때 반복적으로 사용되는 기본적인 코드를 말한다. 예를 들어 API를 호출하기위한 기본적인 설정과 인터셉터 등을 설정하는 부분을 보일러플레이트 코드로 간주할 수 있다.
같은 서버에서 오는 응답의 형태는 대체로 통일되어 있어서 앞서 소개한 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");
const postCart = (postCartRequest: PostCartRequest): AxiosPromise<Response<PostCartResponse>> =>
apiRequester.post<Response<PostCartResponse>>("cart", postCartRequest);
이와 같이 서버에서 오는 응답을 통일할 때 주의점이 있다. Response 타입을 apiRequester 내에서 처리하고 싶을 수 있는 데, 이렇게 하면 UPDATE 나 CREATE 같이 응답이 없을 수 있는 API 를 처리하기 까다로워 진다.
따라서 Response 타입은 apiRequester 가 모르게 관리되어야 한다.
API 요청 및 응답 값 중에서는 하나의 API 서버에서 다른 API 서버로 넘겨주기만 하는 값도 존재할 수 있다. 이처럼 해당 값에 어떤 응답이 들어있는지 알 수 없거나 값의 형식이 달라지더라도 로직에 영향을 주지 않는 경우에는 unknown 타입을 사용하여 알 수 없는 값임을 표현한다.
다음 코드를 살펴보자.
interface resposne {
data: {
cartItems: CartItem[];
forPass : unknown;
}
}
만약 forPass 안에 프론트에서 사용하는 값이 있다면, 여전히 어떤 값이 들어올지 모르기 때문에 unknown 을 유지한다. 다만 이미 설계된 프로덕트에서 쓰고 있는 값이라면 프론트에서 사용하는 값에 대해서만 타입을 선언한 다음에 사용하는 게 좋다.
type ForPass = {
type: "A" | "B" | "C";
};
const isTargetValue = () => (data:forPass as ForPass).type === "A";
API 응답은 변할 가능성이 크다. 특히 새로운 프로젝트는 서버 스펙이 자주 바뀌기 때문에, 뷰모델을 사용하여 API 변경에 따른 범위를 한정해줘야한다.
특정 객체 리스트를 조회하여 리스트 각각의 내용과 전체 길이 등을 보여줘야하는 화면을 떠올려보자. 해당 리스트를 조회하는 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>>();
}
해당 API 를 사용할 때는 다음처럼 사용한다.
이 예시에서는 컴포넌트 내부에서 비동기 함수를 호출하여 처리하지만, 실제 비동기 함수는 컴포넌트 내부에서 직접 호출되지 않는다. 이 내용은 7.2 API 상태 관리하기에서 더 자세하게 다룰 것이다.
const ListPage: React.FC = () => {
const [totalItemCount, setTotalItemCount] = useState(0);
const [items, setItems ] = useState<ListItem[]>([]);
useEffect(()=> {
fetchList(filter).then(({items})=> {
setTotalItemCount(items.length);
setItems(items);
})
},[])
}
흔히 좋은 컴포넌트는 변경될 이유가 하나뿐인 컴포넌트라고 말한다.
위의 코드에서는 만약 API 응답의 items 인자를 좀 더 정확하게 나타내기 위해 jobItems 나 cartItems 와 같은 이름으로 수정하면 해당 컴포넌트 도 수정해야 하며, 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<ListResponse>>();
return new JobList(data);
}
뷰 모델을 만들면 API 응답이 바뀌어도 UI 가 깨지지 않게 개발할 수 있다. 또한 앞의 예시처럼 API 응답에는 없는 totalItemCount 같은 도메인 개념을 백엔드나 UI 단에서 로직을 추가하여 처리할 필요없이 간편하게 새로운 필드를 뷰 모델에 추가할 수 있다.
하지만 뷰 모델 방식에서도 문제는 발생할 수 있다.
추상화 레이어 추가는 결국 코드를 복잡하게 만들며, 레이어를 관리하고 개발하는데도 비용이 든다. 앞선 코드에서 사용한 JobListItemResponse 타입은 서버에서 지정한 응답 형식이기 때문에 이를 UI 에서 사용하려면 다음처럼 더 많은 타입을 선언해야 한다. 이 역할을 위해 JobListItem 라는 클래스가 추가된다.
interface JobListItemResponse {
name: string;
}
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;
// 서버에서 받은 데이터를 UI 에 사용할 수 있는 형태로 가공
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<ListResponse>>();
return new JobList(data);
}
만약 20개의 API 가 추가된다면 20개의 응답이 추가될 이며, 20개의 뷰 모델이 추가될 수 있다는 의미이다. 또한 totalItemCount 와 같이 서버의 응답과 클라이언트가 실제 사용하는 도메인이 다르다면, 서버와 클라이언트 간의 의사소통 문제가 생길 수 있다.
결국 API 응답이 바뀌었을 때는 클라이언트 코드를 수정하는 데 들어가는 비용을 줄이면서도, 도메인을 일관성있게 지킬 수 있는 절충안을 찾아야한다.
예를 들면, 꼭 필요한 곳에만 뷰 모델을 부분적으로 사용하기, 충분한 소통을 통해 API 응답 변화를 최대한 줄이기, 뷰 모델에 필드를 추가하는 대신 getter 등의 함수를 추가하기 등이 있을 수 있다.
앞서 얘기했듯, 개발 단계에서는 API 응답 형식이 자주 바뀐다. 그렇기에 오류가 발생할 확률도 커질수 있다. TS 는 정적 검사 도구로 런타임에서 발생하는 오류는 찾아낼 수 없다. 이를 방지하기 위해 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(숫자를 속성 으로 가진 객체) 라는 속성(데이터 명세)을 가지는 스키마 이다.
그렇다면 assert, is, validate 라는 모듈은 무엇일까?
각각 ‘확인’, '~이다’, '검사하다' 정도로 직역할 수 있는데, 3가지 모두 데이터의 유효성 검사를 도와주는 모듈이다. 공통점은 데이터 정보를 담은 data 변수와 데이터 명세를 가진 스키마인 Article 을 인자로 받고, 해당 데이터 가 스키마 와 부합하는 지를 검사한다는 것이다. 차이점은 모듈마다 데이터의 유효성을 다르게 접근하고 반환 값 형태가 다르다는 것이다.
assert 는 유효하지 않을 경우 에러를 던진다.is 는 유효성 검사 결과에 따라 true 또는 false 즉, boolean 값을 반환한다.validate 는 [error, data] 형식의 튜플을 반환한다. 유효하지 않을 때는 에러 값이 반환되고 유효한 경우에는 첫 번째 요소로 undefined, 두 번째 요소로 data value가 반환된다.지금까지 Superstruct 의 공식 문서를 참조하여 런타임에서의 JS 데이터의 유효성 검사가 실행되는 구조를 살펴봤다. 그렇다면 TS 와는 어떤 시너지를 발휘할 수 있는 지 알아보자.
먼저 아래와 같이 Inter 를 사용하면 기존 TS 문법 의 타입 선언 방식과 동일하게 타입을 선언할 수 있다.
import { Infer, number, object, string, assert } from "superstruct";
const User = object({
id: number(),
email: string(),
name: string(),
});
type User = Infer<typeof User>;
다음 예시에서 Superstruct 의 assert 메서드를 사용해 user 가 User 타입과 매칭되는 지 확인하는 isUser 함수를 작성해보자.
type User = {
id: number;
email: string;
name: string;
};
import { assert } from 'superstruct';
function isUser(user: User) {
assert(user, User);
console.log("적절한 유저입니다.");
}
isUser 의 user 에 User 타입과 일치하는 데이터를 인자로 넣으면 "적절한 유저입니다." 가 출력될 것이고, 그렇지 않은 데이터를 넣으면 런타임 에러가 발생하게 된다.
이처럼 컴파일 단계가 아닌 런타임에서도 적절한 데이터인지를 확인하는 검사가 필요할 때 유용하게 사용할 수 있다.
이제 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 의 assert 모듈을 사용하여 검증하는 코드를 아래 같이 작성해보자.
import { assert } from "superstruct";
function isListItem(listItems: ListItem[]) {
// listItems 을 순회하며, listItem 이 ListItem 타입과 동일한지 확인하고,
// 다를 경우 에러를 던짐.
listItems.forEach((listItem) => assert(listItem, ListItem));
}
실제 API 를 요청하는 코드는 컴포넌트 내에서 직접 처리되지 않는다. API 호출을 위해서는 요청의 성공과 실패에 따른 상태 관리가 필요하다. 이를 위해 상태 관리 라이브러리에서는 액션(action) 이나 훅과 같이 재정의 된 형태를 사용해야한다.
상태 관리 라이브러리에서 제공하는 비동기 함수는 서비스 로직을 처리하면서 비동기 상태를 변경할 수 있는 기능을 제공한다. 컴포넌트는 이 함수를 사용해 상태를 구독하고, 상태 변화가 있을 때 컴포넌트를 다시 렌더링한다.
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
);
// histor 내역을 검색하는 함수, 검색 조건이 바뀌면 상태를 갱신하고 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 함수에서는 dispatch 코드를 제외하더라도 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 일 시
(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 같은 비동기 상태 관리를 위한 기능도 있어 더욱 간편하게 사용할 수 있다.
하지만 결국 모든 상태 관리 라이브러리에서 비동기 처리 함수를 호출하기 위해 액션이 추가될 때마다 관련된 스토어나 상태가 계속 늘어난다.
이로 인한 가장 큰 문제점은 전역 상태 관리자가 모든 비동기 상태에 접근하고 변경할 수 있다는 것이다. 만약 2개 이상의 컴포넌트가 구독하고 있는 비동기 상태가 있다면 쓸데없는 비동기 통신이 발생하거나 의도치 않은 상태 변경이 발생 할 수 있다.
react-query 나 useSwr 같은 훅을 사용한 방법은 상태 변경 라이브러리를 사용한 방식 보다 훨씬 간단하다. 이러한 훅은 캐시를 사용하여 비동기 함수를 호출하며, 상태 관리 라이브러리에서 발생했던 의도치 않은 상태 변경을 방지하는 데 도움이 된다. useSwr 과 react-query 의 사용법이 유사하므로 여기서는 react-query의 사용 예시만 살펴보겠다.
아래 코드는 Job 목록을 불러오는 훅과 Job 1개를 업데이트하는 예시다. 만약 Job 이 업데이트되면 해당 Job 목록의 정보가 유효하지 않게 되므로 다시 API 를 호출해야 함을 알려줘야 한다.
이러한 기능을 react-query 에서는 onSuccess 옵션의 invalidateQueries 를 사용하여 특정 키의 API 를 유효하지 않은 상태로 설정하는 방식으로 구현할 수 있다.
import { runInAction, makeAutoObservable } from "mobx";
import type Job from "models/Job";
class JobStore {
job: Job = {} as Job;
constructor() {
makeAutoObservable(this);
}
}
type LoadingState = "PENDING" | "DONE" | "ERROR";
class Store {
job: Job = {} as Job;
state: LoadingState = "PENDING";
errorMsg = "";
constructor() {
makeAutoObservable(this);
}
async fetchJobList() {
this.job = {} as Job;
this.state = "PENDING";
this.errorMsg = "";
try {
const projects = await fetchJobList();
runInAction(() => {
this.job = projects;
this.state = "DONE";
});
} catch (e) {
runInAction(() => {
this.state = "ERROR";
this.errorMsg = e.message;
});
}
}
}
이후 컴포넌트에서는 일반적인 훅을 호출하는 것처럼 사용하면 된다. 만약 JobList 컴포넌트 가 반드시 최신 상태를 표현하려면 폴링(polling) 이나 웹소켓 등의 방법을 사용해야 한다.
폴링 은 클라이언트가 주기적으로 서버에 요청을 보내 데이터를 업데이트하는 것이다. 클라이언트는 일정 한 시간 간격으로 서버에 요청을 보내고, 서버는 해당 요청에 대해 최신 상태의 데이터를 응답으로 보내주는 방식을 말한다.
아래 예시 에서는 간단한 폴링 방식으로 최신 상태를 업데이트하는 것을 볼 수 있다.
const JobList: React.FC = () => {
const { isLoading,
isError,
error,
refetch,
data: jobList
} = useFetchJobList();
// 간단한 폴링 로직, 30초 간격으로 refetch 를 호출하며 갱신
useInterval(() => refetch(), 30000);
// Loading 인 경우 화면에 Loading UI 표시
if (isLoading) return <LoadingSpinner />;
if (isError) return <ErrorAlert error={error} />;
return (
<div>
{jobList?.map((job: Job) => (
<div key={job.id}>
<Job job={job} />
</div>
))}
</div>
);
};
최근 우아한 형제들 사내에서도 Redux 나 MobX 와 같은 전역 상태 관리 라이브러리를 react-query 로 변경하고자 하는 시도가 이루어지고 있다고 한다. 상태 관리 라이브러리는 비동기로 상태를 변경하는 코드가 추가될수록 전역 상태 관리 스토어가 비대해지고, 단순히 상태를 변경하는 액션이 증가하는 것뿐만 아니라 전역 상태 자체도 확장하기 때문이다.
에러 발생, 로딩 중 등과 같은 상태는 전역으로 관리할 필요가 거의 없다. 다른 컴포넌트가 에러 상태인지, 성공 상태인지를 구독하는 경우 컴포넌트의 결합도와 복잡도가 높아져 유지보수 를 어렵게 만들 수 있다. 이런 고민으로 인해 비동기 통신을 react-query 를 사용해서 처리하 고 있다.
react-query 를 가장 활용하고 있지만 전역 상태 관리를 위한 라이브러리가 아닌 만큼 상태 관리 라이브러리 중에서 가장 뛰어나다는 의미는 아니다. 어떤 상태 관리 라이브러리를 선택할지는 프로젝트의 도메인, 개발자의 학습 곡선 그리고 기존 코드와의 호환성 등에 따라 달라질 수 있다.
상태 관리 라이브러리에는 고정된 모범 사례가 있는 것이 아니기 때문에 상황에 따라 적절한 판단이 필요하다.
비동기 API 호출을 하다 보면 상태 코드에 따라 401 (인증되지 않은 사용자), 404(존재하지 않는 리소스), 500 (서버 내부 에러) 혹은 CORS 에러 등 다양한 에러가 발생할 수 있다. 코드에서 발생할 수 있는 에러 상황에 대해 명시적인 코드를 작성하면 유지보수가 용이해지고, 사용자에게도 구체적인 에러 상황을 전달할 수 있는 이점이 있다. 이 절에서는 TS 에서는 어떻게 비동기 API 에러를 구체적이고 명시적으로 핸들링하는 방법을 예시와 함께 살펴보겠다.
Axios 라이브러리에서는 Axios 에러에 대해 isAxiosError 라는 타입 가드를 제공하고 있다.
이 타입 가드를 직접 사용할 수도 있지만, 서버 에러임을 명확하게 표시하고 서버에서 내려주는 에러 응답 객체에 대해서도 구체적으로 정의함으로써 에러 객체가 어떤 속성을 가졌는지를 파악할 수 있다.
다음과 같이 서버에서 전달하는 공통 에러 객체에 대한 타입을 정의할 수 있다.
interface ErrorResponse {
status: string;
serverDateTime: string;
errorCode: string;
errorMessage: string;
}
ErrorResponse 인터페이스를 사용하여 처리해야할 Axios 에러 형태는 AxiosError<ErrorResponse> 로 표현할 수 있으며 다음과 같이 가드를 명시적으로 작성할 수 있다.
// 사용자 정의 타입 가드를 정의할 때는 반환 타입으로
// parameter is Type 형태의 타입 명제를 정의해주는 게 좋다.
function isServerError(error: unknown): error is AxiosError<ErrorResponse> {
return axios.isAxiosError(error);
}
const onClickDeleteHistoryButton = async (id: string) => {
try {
await axios.post("https://...", { id });
alert("주문 내역이 삭제되었습니다.");
} catch (error: unknown) {
// 서버 에러인지 확인하는 타입 가드
if (isServerError(error) && error.response && error.response.data.errorMessage) {
// 서버 에러일 때의 처리임을 명시적으로 알 수 있다
setErrorMessage(error.response.data.errorMessage);
return;
}
// 다른 에러 발생 시 처리
setErrorMessage("일시적인 에러가 발생했습니다. 잠시 후 다시 시도해주세요.");
}
};
이처럼 타입 가드를 활용하면 서버 에러를 명시적으로 확인할 수 있다.
실제 요청을 처리할 때는 인증 정보 에러, 네트워크 에러, 타임 아웃 에러 같은 다양한 에러가 발생하기도 한다. 이를 더욱 명시적으로 표시하기 위해 서브 클래싱(Subclassing) 을 활용할 수 있다.
서브클래싱 은 기존 (상위 또는 부모) 클래스를 확장하여 새로운 (하위 또는 자식) 클래스를 만드는 과정을 말한다. 새로운 클래스는 상위 클래스의 모든 속성과 메서드를 상속받아 사용할 수 있고 추가적인 속성과 메서드를 정의할 수도 있다.
사용자에게 주문 내역을 보여주기 위해 서버에 주문 내역을 요청하는 다음과 같은 코드가 있다고 치자.
const getOrderHistory = async (page: number): Promise<History[]> => {
try {
const { data } = await axios.get(`https://some.site?page=${page}`);
const history = JSON.parse(data);
return history;
} catch (error: unknown) {
if (error instanceof Error) {
alert(error.message);
} else {
alert("알 수 없는 오류가 발생했습니다.");
}
}
};
사용자는 "로그인 정보가 만료되었습니다. 유효하지 않은 요청 데이터입니다." 와 같이 서버에서 전달된 에러 메시지를 보고 어떤 에러가 발생한 것인지 판단할 수 있더라도, 개발자 입장에서는 어떤 에러가 발생한 것인지 구분할 수 없다.
이때 서브클래싱을 활용하면 에러가 발생했을 때 코드 상에서도 어떤 에러인지를 바로 확인할 수 있다. 또한 에러 인스턴스가 무엇인지에 따라 에러 처리 방식을 다르게 구현할 수 있다.
다음 코드에서 에러 클래스 는 공통의 Error 클래스 를 상속받고, 각 케이스에 따라 에러의 이름과 메세지를 다르게 정의한다.
// 각 케이스 별 에러 클래스
class OrderHttpError extends Error {
private privateResponse?: AxiosResponse<ErrorResponse>;
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: string = "Network Error") {
super(message);
this.name = "NetworkError";
}
}
class UnauthorizedError extends OrderHttpError {
constructor(message: string, response?: AxiosResponse<ErrorResponse>) {
super(message, response);
this.name = "UnauthorizedError";
}
}
만일 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;
// 에러의 statusCode 에 따라 적합한 에러 객체
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;
};
다시 요청 코드로 돌아와 결과적으로 다음과 같이 활용할 수 있다.
// 케이스에 따라 적합한 에러 객체를 나누고 message 를 alert 로 띄움.
const onActionError = (
error: unknown,
params?: Omit<AlertPopup, "type" | "message">
) => {
if (error instanceof UnauthorizedError) {
alert(error.message, {
type: "error",
message: error.message,
onClose: 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);
}
};
이처럼 에러를 서브클래싱해서 표현하면 명시적으로 에러 처리를 할 수 있다.
Axios 에서 제공하는 인터셉터 기능을 활용하여 일관된 에러 처리를 할 수도 있다.
const httpErrorHandler = (
error: AxiosError<ErrorResponse> | Error
): Promise<Error> => {
// 401 에러인 경우 로그인 페이지로 이동
if (axios.isAxiosError(error) && error.response && error.response.status === 401) {
location.href = '/login';
return Promise.reject(error);
}
return Promise.reject(error);
};
orderApiRequester.interceptors.response.use(
(response: AxiosResponse) => response,
httpErrorHandler
);
에러 바운더리 는 리액트 컴포넌트 트리에서 에러가 발생할 때 공통으로 에러를 처리하는 리액트 컴포넌트이다.
에러 바운더리를 사용하면 리액트 컴포넌트 트리 하위에 있는 컴포넌트에서 발생한 에러를 캐치하고, 해당 에러를 가장 가까운 부모 에리 바운더리 에서 처리하게 할 수 있다. 에러 바운더리 는 에러가 발생한 컴포넌트 대신에 에러 처리를 하거나 예상치 못한 에러 를 공통 처리할 때 사용할 수 있다.
import React, { ErrorInfo } from "react";
import ErrorPage from "pages/ErrorPage";
interface ErrorBoundaryProps {
children: React.ReactNode;
}
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 컴포넌트 내에서 처리되지 않은 에러가 있더라도 에러바운더리 에서 에러 를 처리하기 때문에, 적절한 에러 페이지를 노출할 수 있다.
앞서 잠깐 살펴본 Redux의 에러 처리 방법은 다음과 같다.
// API 호출에 관한 api call reducer
const apiCallSlice = createSlice({
name: 'apiCall',
initialState,
reducers: {
setApiCall: (state, action: PayloadAction<any>) => {
/* API State 를 채우는 Logic */
},
setApiCallError: (state, action: PayloadAction<string>) => {
state.error = action.payload;
},
},
});
const API = axios.create();
const setAxiosInterceptor = (store: EnhancedStore) => {
/* 중복 코드 생략*/
// onSuccess 시 처리를 인터셉터로 처리한다.
API.interceptors.response.use(
(response: AxiosResponse) => {
const { method, url } = response.config;
store.dispatch(
apiCallSlice.actions.setApiCall({
status: ApiCallStatus.None, // API 호출 상태를 `요청되지 않음` 으로 변경
urlInfo: { url: method, method: url },
})
);
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;
}
else {
message.error(`서버 요청 에러: ${error?.response?.data?.message}`);
}
const {
config: {url, method},
} = error;
store.dispatch(
apiCallSlice.actions.setApiCall({
status: ApiCallStatus.None,
urlInfo: { url, method },
})
);
return Promise.reject(error);
}
);
};
에러 상태를 관리하지 않고 처리할 수 있다면(401, 403) 인터셉터에서 바로 처리 하고, 그렇지 않다면 reject 로 넘겨준다. 이후 액션을 정의하면서 setApiCallError 를 사용하여 에러를 상태로 처리한다. 다음과 같이 사용할 수 있다.
export const fetchMenu = createAsyncThunk(
FETCH_MENU_REQUEST,
async ({ shopId, menuld }: FetchMenu) => {
try {
const data = await api.fetchMenu(shopId, menuld);
return data;
} catch (error) {
dispatch(setApiCallError(error));
}
}
);
react-query 나 swr 과 같은 데이터 페칭 라이브리리를 사용하면 요청에 대한 상태를 반환해 주기 때문에 요청 상태를 확인하기 쉽다. 다음은 를 사용한 예시다.
const JobComponent: React.FC = () => {
const { isError, error, isLoading, data } = useFetchJobList();
// Error 일 때
if (isError) {
return (
<div>
{error?.message}가 발생했습니다. 나중에 다시 시도해주세요.
</div>
);
}
// Loading 중일 때
if (isLoading) {
return <div>로딩 중입니다...</div>;
}
return (
<div>
{data?.map((job) => (
<JobItem key={job.id} job={job} />
))}
</div>
);
};
export default JobComponent;
API 응답은 주로 성공 시 2xx 코드를, 실패 시 4xx, 5xx 코드를 반환한다. 그러나 비즈니스 로직에서의 유효성 검증에 의해 추가된 커스텀 에러는 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);
}
};
이 방법을 사용하면 간단하게 커스텀 에러를 처리할 수 있다. 또한 영향 범위가 각 요청에 대한 성공/실패 응답 처리 함수로 한정되어 관리하기 편리해진다. 그러나 이렇게 처리해야 하는 AP가 많을 때는 매번 if(response status == "SUCCESS") 구문을 추가해야 한다.
만약 커스텀 에러를 사용하고 있는 요청을 일괄적으로 에러로 처리하고 싶다면 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);
}
};
프론트엔드 개발을 하다보면 서버 API 가 완성되기 전에 개발을 진행해야 하는 일이 종종 생긴다. 기획이 완료되고 서버 API 가 완성된 다음에 프론트엔드 개발을 한 후 QA 를 진행할 수 있다면 좋겠지만. 현실에서는 프론트엔드 개발이 서버 개발보다 먼저 이루어지지나 서버와 프론트엔드 개발이 동시에 이루어지는 경우가 더 많다.
그렇다면 이러한 상황에서 프론트엔드 개발을 어떻게 진행할 수 있을까? 단순하게는 개발 중인 코드에 임시 변수를 만들어 UI 를 먼저 구현할 수 있을 것이다.
그런데 API 요청을 보내야 한다면, 그리고 요청 응답에 따라 각기 다른 팝업을 보여주어야 한다면 어떻게 해야 할까?
서버가 별도의 가짜 서버(Mock Server) 를 제공한다고 하더라도 프론트엔드 개발 과정에서 발생할 수 있는 모든 예외 사항을 처리하는 것은 쉽지 않다. 또한 매번 테스트를 위해 구현을 반복해야하기 때문에 번거로울 수 있다.
이럴 때 모킹(Mocking) 이라는 방법을 활용할 수 있다. 모킹은 가짜 모듈을 활용하는 것을 말한다. 모킹은 프론트엔드 개발 과정에서 테스트 코드를 작성할 때, jest 와 같은 툴로 사용하기도 하는 방법이다. 모킹은 이렇게 테스트 할 때 뿐 아니라 개발할 때도 사용할 수 있다.
모킹을 활용하면 앞서 제시한 상황에서 유연하게 대처할 수 있게 된다. 또한 서버 상태에 문제가 발생한 경우에도 영향을 받지 않고 프론트엔드 개발을 할 수 있게 된다. 개발하면서 다양한 예외 케이스의 응답을 편하게 테스트해볼 수도 있다. 우아한형제들 프론트엔드 에서는 axios-mock-adapter, Next Api Handler 등을 활용하여 API 를 모킹해서 사용하고 있다.
간단한 조회만 필요한 경우에는 *.json 파일을 만들거나 자바스크립트 파일 안에 JSON 형식 의 정보를 저장하고 익스포트 해주는 방식을 사용하면 된다. 이후 GET 요청에 파일 정보를 삽입해주면 조회 응답으로 원하는 값을 받을 수 있다.
// 어떻게 가능한지 모르겠네요
// mock/service.ts
const SERVICES: Service[] = [
{ id: 0, name: "배달의민족" },
{ id: 1, name: "만화경" },
];
export default SERVICES;
// api
const getServices = () => apiRequester.get('/mock/service');
저게 되는 코드인지 잘 모르겠으나 다음과 같이 사용할 수 있음.
// mock/service.json
[
{ "id": 0, "name": "배달의민족" },
{ "id": 1, "name": "만화경" },
];
// api
import services from 'mock/service.json'
프로젝트에서 Next js를 사용하고 있다면 NextApiHandler 를 활용할 수 있다.
Next.js 는 리액트 기반의 프론트엔드 프레임워크다. 리액트 앱을 쉽게 구축하고 운영하기 위한 도구를 제공하는 프레임워크로서, 서버 사이드 렌더링, 정적 사이트 생성, 코드 스플리팅 등의 기능을 내장하고 있다.
NextApiHandler 는 하나의 파일 안에 하나의 핸들러를 default export 로 구현해야 한다. 핸들러를 사용하는 경우 단순히 파일을 불러오는 것과 다르게 중간 과정에 응답 처리 로직을 추가할 수 있다.
// api/mock/brand.ts
import { NextApiHandler } from "next";
const BRANDS: Brand[] = [
{ id: 1, label: "배민스토어" },
{ id: 2, label: "비마트" },
];
const handler: NextApiHandler = (req, res) => {
res.json(BRANDS);
};
export default handler;
요청 경로를 수정하지 않고 개발할 때 필요한 경우에만 실제 요청을 보내고 그 외에는 목업을 사용하여 개발하고 싶다면 다음과 같이 처리할 수도 있다.
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 분기문을 추가해야 하므로 번거롭게 느껴질 수도 있다.
서비스 함수에 분기문이 추가되는 것을 바라지 않는다면 라이브러리를 사용하면 된다. axios-mock-adapter 는 Axios 요청을 가로채서 요청에 대한 응답 값을 대신 반환한다. Axios 의 인터셉터 과 다른 점은, 인터셉터는 사용하여도 실제 요청을 보내는 반면, axios-mock-adapter 는 실제 요청을 보내지 않는다.
// 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") // "/order/list" 로 요청 시, 요청을 가로챔
.reply(200, fetchOrderListSuccessResponse); // 응답값을 대신 반환
// fetchOrderListSuccessResponse.json
{
"data": [
{
"orderNo": "ORDER1234",
"orderDate": "2022-02-02",
"shop": {
"shopNo": "SHOP1234",
"name": "가게이름1234"
},
"deliveryStatus": "DELIVERY"
}
]
}
단순히 응답 바디만 모킹할 수도 있지만 상태 코드, 응답 지연 시간 등을 추가로 설정할 수도 있다. 이에 따라 다양한 HTTP 상태 코드에 대한 목업을 정의할 수 있고. API 별로 지연 시간을 다르게 설정할 수 있다.
이렇게 응답 처리를 하는 부분만 별도의 함수로 구현하면, 여러 mock 함수에서 사용할 수 있다.
// 지연 시간이 설정된 API 응답의 데이터
export const lazyData = (
status: number = Math.floor(Math.random() * 10) > 7 ? 200 : 500,
successData: unknown = defaultSuccessData,
failData: unknown = defaultFailData,
time: number = Math.floor(Math.random() * 1000)
): Promise<any> =>
new Promise((resolve) => {
setTimeout(() => {
resolve({ status, data: status === 200 ? successData : failData });
}, time); // 시간 후에 resolve
});
export const fetchOrderListMock = ({
status = 200,
time = 100,
use = true,
}: MockResult) => {
if (use) {
mock.onGet("/order/list").reply(() => {
// 응답 처리 하는 함수를 반환
return lazyData(status, fetchOrderListSuccessResponse, undefined, time); // lazyData 호출
});
}
};
또한 axios-mock-adapter 에서는 임의로 에러를 발생시킬 수 있는 networkError, timeoutError 등을 메서드로 제공하기 때문에, 다음처럼 임의로 에러를 발생시킬 수도 있다.
export const fetchOrderListMock = () =>
mock
.onPost(/\/order\/list/)
.networkError();
로컬(개발환경) 에서만 목업을 사용하고, 프로덕션 에서는 사용하지 않기 위해 플래그를 사용하여 구분할 수 있다. 이렇게 하면 프로덕션에서 사용되는 코드와 목업을 위한 코드를 분리할 필요가 없다.
// 환경변수에서 mock 을 사용하는 환경인지 확인하는 플래그
const useMock = Object.is(process.env.REACT_APP_MOCK, "true");
// mock 함수
const mockFn = ({
status = 200,
time = 100,
use = true,
}: MockResult) => {
if (use) {
mock.onGet("/order/list").reply(() => {
return new Promise((resolve) => {
setTimeout(() => {
resolve([status, status === 200 ? fetchOrderListSuccessResponse : undefined]);
}, time); // 주어진 시간만큼 지연 후 응답
});
});
}
};
// mock 을 사용하는 환경일 경우, mock 함수 실행
if (useMock) {
mockFn({ status: 200, time: 100, use: true });
}
다음처럼 플래그에 따라 매개변수를 넘겨 특정 mock 함수만 동작하게 하거나 동작하지 않게 할 수 있다. 스크립트 실행 시 구분 짓고자 한다면 package.json 에 관련 스크립트를 추가해줄 수 있다.
// package.json
{
...,
"scripts": {
"start:mock": "REACT_APP_MOCK=true npm run start",
"start": "REACT_APP_MOCK=false npm run start",
...
},
}
axios-mock-adapter 는 앞서 얘기했듯 실제로 API 요청을 주고받지 않는다. 따라서 브라우저가 제공하는 개발자 도구의 네트워크 탑 에서는 확인하기 어렵다. API 요청의 흐름을 파악하고 싶다면 react-query-devools 혹은 redux test tool 과 같은 별도의 도구를 사용해야 한다.
목업을 사용할 때 네트워크 요청을 확인하고 싶을 때는 네트워크에 보낸 요청을 변경해주는 Cypress 같은 도구의 웹훅을 사용하면 된다.
Cypress 는 프론트엔드 테스트를 위한 오픈 소스 자바스크립트 엔드 투 엔드 테스트 도구다. 주로 웹 앱의 동작을 시뮬레이션하고 테스트하는 데 사용된다.
우아한 형제들 내부에서는 앞에서 소개한 모킹 방식 외에도 최근에는 서비스워커를 활용하는 라이브러리인 MSW를 도입한 팀도 있다. MSW를 사용하면 모킹 시 개발 환경과 운영 환경을 분리할 수 있으며, 개발자 도구의 네트워크 탭에서 API 통신을 확인할 수 있다.