7장. 비동기 호출
API 요청
-
fetch로 API 요청하기
- 컴포넌트 내부에 깊숙이 자리 잡은 비동기 호출 코드는 변경 요구에 취약하다.
-
서비스 레이어로 분리하기
- 비동기 호출 코드는 컴포넌트 영역에서 분리되어 다른 영역(서비스 레이어)에서 처리되어야 한다.
- fetch 함수를 분리하는 것만으로는 해결이 어렵다
- 쿼리 매개변수, 커스텀 헤더 추가 또는 쿠키를 읽어 토큰을 집어넣는 등을 모두 구현하는 것은 번거로운 일이다.
-
Axios 활용하기
- fetch의 경우, 많은 기능을 사용하려면 직접 구현해서 사용해야 하기 때문에 Axios 라이브러리를 사용한다.
-
Axios 인터셉터 사용하기
-
각각의 requester는 서로 다른 역할을 담당하는 다른 서버이기 때문에 request별로 다른 헤더를 설정해줘야 하는 로직이 필요할 수도 있다.
-
이때 인터셉터 기능을 사용해 requester에 따라 비동기 호출 내용을 추가해서 처리할 수 있다.
-
또한 API 에러를 처리할 때 하나의 에러 객체로 묶어서 처리할 수도 있다.
Axios 인터셉터 적용과정 (간단버전)
-
Axios 인스턴스 생성
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 = ( ... ) => { ... } // `interceptors` 기능을 사용해 header를 설정하는 기능을 넣거나 에러를 처리할 수 있다 apiRequester.interceptors.**request.use**(setRequestDefaultHeader); // 기본 apiRequester와는 다른 header를 설정하는 `interceptors` orderApiRequester.interceptors.request.use(setOrderRequestDefaultHeader); // `interceptors`를 사용해 httpError 같은 API 에러를 처리할 수도 있다 orderApiRequester.interceptors.response.use( (response: AxiosResponse) => response, httpErrorHandler );
-
응답 인터셉터 추가하기
instance.interceptors.**response.use**( (response) => { if (response.status === 404) { console.log('404 페이지로 넘어가야 함!'); } return response; }, async (error) => { if (error.response?.status === 401) { // isTokenExpired() - 토큰 만료 여부를 확인하는 함수 // tokenRefresh() - 토큰을 갱신해주는 함수 if (isTokenExpired()) await tokenRefresh(); const accessToken = getToken(); error.config.headers = { 'Content-Type': 'application/json', Authorization: `Bearer ${accessToken}`, }; // 중단된 요청을(에러난 요청)을 토큰 갱신 후 재요청 const response = await axios.request(error.config); return response; } return Promise.reject(error); } );
-
이와 달리 요청 옵션에 따라 다른 인터셉터를 만들기 위해 빌더 패턴을 추가하여 APIBuilder 같은 클래스 형태로 구성하기도 한다.
빌더 패턴
객체 생성을 더 편리하고 가독성 있게 만들기 위한 디자인 패턴 중 하나.
주로 복잡한 객체의 생성을 단순화하고, 객체 생성 과정을 분리해 객체를 조립하는 방법을 제공한다.
- 기본 API 클래스로 실제 호출 부분을 구성하고, 위와 같은 API를 호출하기 위한 래퍼를 빌더 패턴으로 만든다.
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;
- APIBuilder
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;
- API Builder 사용 코드
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 응답 타입 지정하기
- Response 타입은 apiRequester가 모르게 관리되어야 한다.
- 해당 값에 어떤 응답이 들어있는지 알 수 없거나 값의 형식이 달라지더라도 로직에 영향을 주지 않는 경우에는 unknown 타입을 사용하여 알 수 없는 값임을 표현한다.
-
뷰 모델(View Model) 사용하기
-
뷰 모델을 사용해 API 변경에 따른 범위를 한정해줘야 한다. (→ API 응답이 바뀌어도 UI가 깨지지 않음)
-
그러나 뷰 모델 방식에서도 문제가 발생한다(API 20개면,,, 뷰 모델이 20개 이상 추가될 수도 있다는 뜻)
-
때문에 API 응답의 변경에 따라 클라이언트 코드를 수정하는 비용을 줄이면서도 도메인의 일관성을 지킬 수 있는 절충안을 찾아야 한다.
→ 적절한 뷰 모델 사용, 충분한 소통으로 API 응답 변화 최대한 줄이기, 뷰 모델에 필드 추가 대신 getter 함수 추가하기(~> 어떤 값이 뷰 모델에 추가한 값인지 알기 쉽게 함)
-
-
Superstruct를 사용해 런타임에서 응답 타임 검증하기
-
인터페이스 정의와 자바스크립트 데이터의 유효성 검사를 쉽게 할 수 있다.
-
런타임에서의 데이터 유효성 검사를 통해 개발자와 사용자에게 자세한 런타임 에러를 보여주기 위해 고안되었다.
-
데이터의 유효성 검사를 도와주는 모듈
공통점 : 데이터 정보를 담은 벼수와 데이터 명세를 가진 스키마를 인자로 받아 부합하는지 검사
차이점 : 데이터의 유효성을 다르게 접근하고 반환 값 형태가 다름
- assert : 유효하지 않은 경우 에러를 던진다.
- is : 유효성 검사 결과에 따라 true 또는 false, 즉 boolean 값을 반환한다.
- validate : [error, data] 형식의 튜플을 반환한다. 유효하지 않을 때는 에러 값이 반환되고 유효한 경우에는 첫 번째 요소로 undefined, 두 번째 요소로 data value가 반환된다.
-
-
실제 API 응답 시의 Superstruct 활용 사례
import { assert } from "superstruct"; function isListItem(listItems: ListItem[]) { listItems.forEach((listItem) => assert(listItem, ListItem)); }
API 상태 관리하기
- 상태 라이브러리에서 호출하기
-
Redux는 비동기 상태가 아닌 전역 상태를 위해 만들어진 라이브러리이기 때문에 미들웨어라고 불리는 여러 도구를 도입하여 비동기 상태를 관리한다.
→ 보일러플레이트 코드가 많아진다. → 관리가 어려움
-
모든 상태 관리 라이브러리에서 비동기 처리 함수를 호출하기 위해 액션이 추가될 때마다 관련된 스토어나 상태가 계속 늘어난다.
→ 전역 상태 관리자가 모든 비동기 상태에 접근하고 변경할 수 있다.
→ 의도치 않은 상태 변경 발생 가능성
-
- 훅으로 호출하기
- react-query나 useSwr 같은 훅을 사용하는 방법은 상태 변경 라이브러리를 사용한 방식보다 훨씬 간단하다.
- 캐시를 사용하여 비동기 함수를 호출하며, 상태 관리 라이브러리에서 발생했던 의도치 않은 상태 변경을 방지하는 데 도움이 된다.
상태 관리 라이브러리는 비동기로 상태를 변경하는 코드가 점점 추가되면 스토어가 점점 커짐 → 상태 변경 액션이 증가하는 것 뿐만 아니라 전역 상태 자체도 복잡해짐
또한 컴포넌트의 결합도와 복잡도가 높아져 유지보수를 어렵게 만든다.
API 에러 핸들링
-
타입 가드 활용하기
function isServerError(error: unknown): error is AxiosError<ErrorResponse> { return axios.isAxiosError(error); }
사용자 정의 타입 가드를 정의할 때는 타입 가드 함수의 반환 타입으로 parameterName is Type 형태의 타입 명제를 정의해주는 것이 좋다.
이때 parameterName은 타입 가드 함수의 시그니처에 포함도니 매개변수여야 한다.
-
에러 서브클래싱하기
서브클래싱
기존(상위 또는 부모) 클래스를 확장하여 새로운(하위 또는 자식) 클래스를 만드는 과정을 말한다.
새로운 클래스는 상위 클래스의 모든 속성과 메서드를 상속받아 사용할 수 있고 추가적인 속성과 메서드를 정의할 수도 있다.
- 에러를 서브클래싱을 활용하면 에러가 발생했을 때 코드상에서 어떤 에러인지 바로 확인할 수 있다.
- 또한 에러 인스턴스가 무엇인지에 따라 에러 처리 방식을 다르게 구현할 수 있다.
=>명시적으로 에러 처리를 할 수 있다.
-
인터셉터를 활용한 에러 처리
- 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 );
-
에러 바운더리를 활용한 에러 처리
- 에러 바운더리 : 리액트 컴포넌트 트리에서 에러가 발생할 때 공통으로 에러를 처리하는 리액트 컴포넌트
- 에러 바운더리를 사용하면 리액트 컴포넌트 트리 하위에 있는 컴포넌트에서 발생한 에러를 캐치하고, 해당 에러를 가장 가까운 부모 에러 바운더리에서 처리하게 할 수 있다.
- 에러 바운더리는 에러가 발생한 컴포넌트 대신에 에러 처리를 하거나 예상치 못한 에러를 공통 처리할 때 사용할 수 있다.
-
상태 관리 라이브러리에서의 에러 처리
-
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} /> ))} </> ); };
-
그 밖의 에러 처리
- 일관적으로 에러를 처리하고 싶은 경우 Axios 등의 라이브러리 기능을 활용하면 된다.
- 특정 호스트에 대한 API requester를 별도로 선언하고 상태 코드 비교 로직을 인터셉터에 추가할 수 있다.
API 모킹
가짜 서버(Mock Server)의 경우 모든 예외 사항을 처리하는 것은 쉽지 않고, 매번 테스트를 위해 구현을 반복하는 것은 번거롭다.
이럴 때 모킹(Mocking)이라는 방법을 활용할 수 있다.
모킹은 가짜 모듈을 활용하는 것으로, 앞서 제시한 상황에서 유연하게 대처할 수 있다.
또한 dev 서버가 불안정하거나 AWS 등에 문제가 생겼을 때와 같은 서버 상태에 문제가 발생한 경우에도 서버의 영향을 받지 않고 프론트엔드 개발을 할 수 있다.
이외에도 이슈가 생겼을 때 charles 등의 도구를 활용하면 응답 값을 그대로 복사하여 이슈 발생 상황을 재현하는 데 도움이 된다.
-
JSON 파일 불러오기
- 간단한 조회만 필요한 경우에 사용한다.
- 별도의 환경 설정이 필요하지 않아 쉽게 구현할 수 있다.
- 사용자의 인터렉션 없이 빠르게 목업을 구축할 때 유용하게 사용할 수 있다.
- 그러나 실제 API로 요청하는 것이 아니기 때문에 추후에 요청 경로를 바꿔야 한다.
-
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;
-
API 요청 핸들러에 분기 추가하기
- 요청 경로를 수정하지 않고 평소에 개발할 때 필요한 경우에만 실제 요청을 보내고 그 외에는 목업을 사용하여 개발하고 싶다면 다음과 같이 처리할 수 있다.
- API 요청을 훅 또는 별도 함수로 선언해준 다음 다음 조건에 따라 목업 함수를 내보내거나 실제 요청 함수를 내보낼 수 있다.
- 이 방법을 사용하면 개발이 완료된 이후에도 유지보수할 때 목업 함수를 사용할 수 있다.
- 그러나 모든 API 요청 함수에 if 분기문을 추가해야 하므로 번거롭게 느껴질 수 있다.
-
axios-mock-adapter로 모킹하기
- 서비스 함수에 분기문이 추가되는 것을 바라지 않는다면 라이브러리를 사용하면 된다.
- axios-mock-adapter는 Axios 요청을 가로채서 요청에 대한 응답 값을 대신 반환한다.
- 단순히 응답 바디만 모킹할 수도 있지만 상태 코드, 응답 지연 시간 등을 추가로 설정할 수도 있다.
- 또한 GET 뿐만 아니라 POST, PUT, DELETE 등 다른 HTTP 메서드에 대한 목업을 작성할 수 있다.
-
목업 사용 여부 제어하기
-
플래그를 사용하여 목업으로 개발할 때와 개발하지 않을 때를 구분할 수 있다.
-
로컬에서 개발할 때는 주로 목업을 사용하고, dev 서버 환경이 필요할 때는 dev 서버를 바라보도록 설정할 수 있다.
-
이는 프론트엔드와 서버를 독립시킬 수 있어 혹여나 dev 서버에 문제가 생기더라도 로컬에서 진행되는 프론트엔드 개발에는 영향을 주지 않는다.
-
목업을 사용할 때 네트워크 요청을 확인하고 싶을 때는 네트워크에 보낸 요청을 변경해주는 Cypress 같은 도구의 웹푹을 사용하면 된다.
Cypress
프론트엔드 테스트를 위한 오픈 소스 자바스크립트 엔드 투 엔드 테스트 도구
주로 웹 애플리케이션의 동작을 시뮬레이션하고 테스트하는 데 사용된다
-
데이터 패칭 라이브러리를 사용하나요? 어떤 기준으로 선택했나요? 장단점은?
- A팀
- 비동기 요청이 많지 않아 데이터 패칭 라이브러리는 사용하지 않음.
- Recoil에서 제공하는 useSelector 사용
- B팀
- 데이터의 복잡성이 높지 않아서 react-query 사용. ‘상태 관리’를 하는 목적을 고려했을 때, 상당 부분이 비동기 통신을 위해 쓰이는 것 같다고 생각해 Redux를 버림.
- Redux는 규칙도 알아야하고 불편한 코드가 너무 많아서 번거로웠음.
- react-query는 당시 swr이 지원하지 않던 suspense 모드를 제공하고 있음. 전체적으로 제공하는 기능이 많고 선호도도 높았음
- C팀
- react-query 사용.
- 기존의 상태 관리 라이브러리를 사용하여 서버에서 가져온 값을 처리할 때는 추가 작업이 필요했는데, react-query를 사용하면서 이 부분이 편리해짐.
- 코드를 짜면서 신경써야 하는 부분도 줄었다.
- 또한 데이터를 다시 가져오거나(refetch), 캐싱해주는 기능은 강력하다.
- D팀
- 데이터 패칭 라이브러리를 사용하는 이유는 서버 데이터를 캐싱해주고 하나의 API를 여러 컴포넌트에서 사용할 때 편리하게 활용할 수 있다는 점 때문이다.
- swr과 달리 react-query는 서버 상태를 변경하는 useMutation 메서드를 제공해주며 자체적으로 isLoading에 대해 처리도 해주어 편하다.
- 이러한 편리함과 다양한 기능 때문에 선호도가 높은 것 같다.
- E팀
- 고객 지향 서비스에서는 swr를, 어드민에서는 react-query를 사용한다.
- react-query가 제공하는 API의 인터페이스가 좀 더 직관적이고 러닝 커브가 낮아 사용하기 더 편하다