fetch 함수를 사용해서 사용자가 담은 장바구니 물품 개수를 배지로 멋지게 보이게 했다.
💡 배지 일반적으로 UI에 표시되는 작은 원형이나 사각형 형태의 요소를 말함. 주로 다른 요소의 옆이나 아이콘 위에 위치하여 사용자에게 새로운 것이 있음을 알려주고자 할 때 많이 사용됨.여러 API 요청 정책이 추가되어 코드가 변경될 수 있다는 것을 감안한다면, 비동기 호출 코드는 컴포넌트 영역에서 분리되어 다른 영역(서비스 레이어)에서 처리되어야 한다.
fetch는 내장 라이브러리이기 때문에 따로 임포트하거나 설치할 필요 없이 사용할 수 있다.
const apiRequester:AxiosInstance = axios.carete({
baseURL:"https://api.baemin.com",
timeout:5000,
)};
const fetchCart = ():AxiosPromise<FetchCartResponse> =>
apiRequester.get <FetchCartResponse> ("cart");
const postCart = (postCartRequest:PostCartRequest):AxiosPromise<PostCartResponse> =>
apiRequester.get <PostCartResponse> ("cart",postCartRequest);
const apiRequester:AxiosInstance = axios.create(defultConfig)
const orderApiRequester:AxiosInstance = axios.create({
baseURL:"https://api.baemin.or/",
...defultConfig
})
const orderCartApiRequester:AxiosInstance= axios.create({
baseURL:"https://cart.baemin.order/",
...defultConfig
})
각각의 requester는 서로 다른 역할을 담당하는 다른 서버이기 때문에 requester별로 다른 헤더를 설정해줘야 하는 로직이 필요할 수도 있다.
import axios,{Axiosinstance, AxiosRequestConfig,AxiosResponse} from "axios";
const getUserToken = ()=>"";
const getAgent = () => "";
const getOrderClientToken = () => "";
const orderApiBaseUrl = "";
const orderCartApiBaseUrl = "";
const defultConfig = {};
const httpErrorHandler = () => {};
const apiRequester: AxiosInstance = axios.create({
baseURL:"https://api.baemin.com",
timeout:5000,
})
const setRequestDefultHeader = (requestConfig:AxiosRequestConfig) =>{
const config = requestConfig;
config.haders={
...config.headers,
"Content-Type":"application/json;charset=utf-8",
user:getUserToken(),
agent:getAgent(),
}
return config;
}
const setOrderRequestDefaultHeader = (requestConfig:AxiosRequestConfig)=>{
const config = requestConfig;
config.haders={
...config.headers,
"Content-Type":"application/json;charset=utf-8",
"order-client":getOrderClientToken(),
}
return config;
}
// interceptors 기능을 사용해 hader를 설정하는 기능을 넣거나 에러를 처리
orderApiRequester.intersceptors.request.use(setOrderRequestDefaultHeader);
orderApiRequester.interceptors.response.use(
(response:AxiosResponse)=>response,
httpErrorHandler
)
const orderCartApiRequester:AxiosInstance = axios.create({
baseURL: orderCartApiBaseUrl,
...defaultConfig,
})
orderCartApiRequester.intersceptors.request.use(setOrderRequestDefaultHeader);
💡 빌더 패턴
객체 생성을 더 편리하고 가독성 있게 만들기 위한 디자인 패턴 중 하나다. 주로 복잡한 객체의 생성을 단순화하고, 객체 생성 과정을 분리하여 객체를 조립하는 방법을 제공
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()
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})
}
}
빌더 패턴으로 만든다.
const fetchJobNameList = async (name?:string,size?:number)=>{
const api = APIBulider.get("/apis/web/jobs")
.withCredentials(true)
.params({name,size})
.build();
const {data} = await api.call<Response<JobNameListResponse>>();
return data;
}
같은 서버에서 오는 응답의 형태는 대체로 통일되어 있어서 앞서 소개한 API의 응답 값은 하나의 Resoponse 타입으로 묶일 수 있다.
interface Reponse<T> {
data: T;
status: string;
serverDateTime: string;
errorCode?: string;
errorMessage?: string;
}
const fetchCart = (): AxioosPromise<Response<FetchCartResponse>> =>
apiRequester.get < Response < FetchCartResponse >> "Cart";
const postCart = (
postCartRequest: PostCartRequest
): AxiosPromise<Response<PostCartResponse>> =>
apiRequester.post<Response<PostCartResponse>>("cart", postCartRequest);
이와 같이 서버에서 오는 응답을 통일해줄 때 주의할 점이 있다. Response 타입을 apiResquester 내에서 처리하고 싶은 생각이 들 수 있는데, 이렇게하면 update나 create 같이 응답이 없을 수 있는 API를 처리하기 까다로워진다.
따라서 Response 타입은 apiRequester가 모르게 관리되어야 함.
API 요청 및 응답 값 중에서는 하나의 API 서버에서 다른 API 서버로 넘겨주기만 하는 값도 존재할 수 있다. 해당 값에 따른 어떤 응답이 들어있는지 알 수 없거나 값의 형식이 달라지더라도 로직에 영향을 주지 않는 경우에는 unknown 타입을 사용하여 알 수 없는값임을 표현.
interface response{
data:{
cartItems: CartItem[],
forPass:unkown
}
}
forPass 안에 프론트 로직에서 사용해야 하는 값이 있다면, 여전히 어떤 값이 들어오는지 모를때 unknown을 유지. 로그를 위해 단순히 받아서 넘겨주는 값의 타입은 언제든지 변경될 수 있으므로 forPass 내의 값을 사용하지 않아야 한다.
type ForPass = {
type: "A" | "B" | "C";
}
const isTragetValue = () => (data.forPass as ForPass).type === "A";
새로운 프로젝트는 서버 스펙이 자주 바뀌기 떄문에 뷰 모델을 사용하여 API 변경에 따른 범위를 한정해줘야 함.
interface ListResponse{
items:ListItem[];
}
const fetchList = async (filter?:ListFetchfilter):Promise<List<ListResponse>=>{
const {data} = await api.params({...filter}).get("/apis/get-list-summaries").call<Response<ListResponse>>();
return {data}
}
const ListPage: React.FC = ()=>{
const [totalItemCount,setTotalItemCount] = useState(0);
const [items,setItems] = useState<ListItem[]>([]);
}
useEffect(()=>{
fetchList(filter).then(({items}))=>{
setCartCount(items.length);
setItems(items);
}
},[])
return(
<div>
<Chip label={totalItemCount}/>
<Table items={items}/>
</div>
)
흔히 좋은 컴포넌트는 변경될 이유가 하나뿐인 컴포넌트라고 말한다. API 응답의 items 인자를 좀 더 정확한 개념으로 나타내기 위해 jobItems나 cartItems 같은 이름으로 수정하면 해당 컴포넌트도 수정해야 한다.
뷰 모델 도입
//기존 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 APIBuilder.params({ ...filter })
.get("/apis/get-list-summaries")
.call<Response<JobListResponse>>();
return new JobList(data);
};
뷰 모델을 만들면 API 응답이 바뀌어도 UI가 깨지지 않게 개발 가능.
뷰 모델 방식에도 문제가 발생할 수 있다. 추상화 레이어 추가는 결국 코드를 복잡하게 만들며 레이러를 관리하고 개발하는 데도 비용이 든다.
개발 단계에서는 API 응답 형식이 자주 바뀐다. 또한 응답 값의 타입이 string이어야 하는데 number가 들어오는 것과 같이 잘못된 타입이 전달되기도 한다. 그러나 타입스크립트는 정적 검사 도구로 런타임에서 발생하는 오류는 찾아낼 수 없다. 런타임에 API 응답의 타입 오류를 방지하라면 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",
tags: ["news", "features"],
author: {
id: 1,
},
};
assert(data, Article);
is(data, Article);
validate(data, Article);
먼저 Article는 Superstruct의 object 모듈을 반환
object()라는 모듈 이름에서 예상할 수 있듯이 Article은 object(객체)형태를 가진 무언가라고 생각할 수 있다.
그렇다면 number(), string() 모듈의 반환 타입도 숫자, 문자열 형태로 이해함.
assert, is, valudate라는 모듈은 무엇인가? 각각 ‘확인’, ‘~이다’, ‘검사하다’정도로 직역할 수 있는데 3가지 모두 데이터의 유효성 검사를 도와주는 모듈이다.
세 모듈의 공통점은 데이터 정보를 담은 data 변수와 데이터 명세를 가진 스키마인 Article을 인자로 받아 데이터가 스키마와 부합하는지를 검사한다는 것. 차이점은 모듈마다 데이터의 유효성을 다르게 접근하고 반환 값 형태가 다르다는 것.
타입스크립트와 어떤 시너지를 발휘하는지 알아보자.
먼저 아래와 같이 infer를 사용하여 기존 타입 선언 방식과 동일하게 타입을 선언
impoert {Infer,number,object,string} from "superstruct";
const User = ({
id:number(),
email:string(),
name:string()
})
type User = Infer<typeof User>;
type user = {
id:number;
email:string;
name:string;
}
import {assert} from 'supersturct';
function isUser(user:User){
assert(user,User);
console.log('유저')
}
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);
interface ListItem {
id: string;
content: string;
}
interface ListResponse {
items: ListItem[];
}
const fetchList = async (filter?: ListFetchFilter): Promise<ListResponse> => {
const { data } = await APIBuilder.params({ ...filter }).get(
"/apis/get-list-summaries").call<Response<ListResponse>>();
);
return {data}
};
타입스크립트는 컴파일타임에 타입을 검증하는 역할을 한다.
이때 Superstruct를 활용하여 타입스크립트로 선언한 타입과 실제 런타임에서의 데이터 응답값을 매칭하여 유효성 검사를 할 수 있다.
import {assert} from "superstruct";
function isListItem(listItems:ListItem[]){
listItem.forEach((listItem)=>assert(listItem,ListItem));
}
isListItem은 ListItem의 배열 목록을 받아와 데이터가 ListItem 타입과 동일한지 확인하고 다를 경우 에러 던짐
reactquery에서는 onSuccess옵션의 invalidateQuries를 사용하여 특정 키의 API를 유효하지 않은 상태로 설정할 수 있다.
const useFetchJobList = () => {
return useQuery([
"fetchJobList",
async () => {
const response = await JobService.fetchJobList();
return new useFetchJobList(response);
},
]);
};
const useUpdateJob = (
id: number,
{ onSuccess, ...options }: UseMutationOptions<void, Error, JobUpdateFormValue>
): UseMutationResult<void, Error, JobUpdateFormValue> => {
const queryClient = useQueryClient();
return useMutation(
["updateJob", id],
async (jobUpdateFrom: JobUpdateFormValue) => {
await JobService.updateJob(id, jobUpdateFrom);
},
{
onSuccess: (dta: void, vlaues: JobUpdateFormValue, contexts: unknown) => {
queryClient.invalidateQuries(["fetchJobList"]);
onSuccess && onSuccess(data, values, context);
},
...options,
}
);
};
JobList 컴포넌트가 반드시 최신 상태를 표현하려면 폴링이나 웹 소켓등의 방법을 사용해야 함.
간단한 폴링 방식으로 최신 상태를 업데이트
const JobList: React.FC = () => {
const {
isLoading,
isError,
error,
refetch,
data: jobList,
} = useFetchJobList();
useInterval(() => refetch(), 30000);
if (isLoading) return <LoadingSpinner />;
if (isError) return <ErrorAlert error={error} />;
return (
<>
{jobList.map((job) => (
<Job job={job} />
))}
</>
);
};
Axios 라이브러리에서는 Axios 에러에 대해 isAxiosError 타입 가드를 제공
interface ErrorResponse{
status:string;
serverDateTime:string;
errorCode:string;
errorMessage:string;
}
ErrorResponse 인터페이스를 사요하여 처리해야 할 Axios 에 형태는 ErrorResponse로 표현할 수 있으며 다음과 같이 타입 가드를 명시적으로 작성
function isServerError(error:unknown): error is AxiosError<ErrrorResponse>{
return axios.isAxiosError(error)
}
const onClickDeleteHistoryButton = async (id: string) => {
try {
await axios.post("https...", { id });
} catch (error: unknown) {
if (isServerError(e) && e.response && e.response.data.errorMessage) {
setErrorMessage(e.response.data.errorMessage);
return;
}
setErrorMessage("일시적인 에러가 발생");
}
};
실제 요청을 처리할 때 단순 서버 에러도 방생하지만 인증 정보 에러, 네트워크 에러, 타임 아웃 에러 같은 다양한 에러가 발생, 이를 더욱 명시적으로 표시하기 위해 서브클래싱 활용
💡 서브클래싱 기존(상위 또는 부모) 클래스를 확장하여 새로운(하위또는자식) 클래스를 만드는 과정을 말함. 새로운 클래스는 상위 클래스의 모든 속성과 메서드를 상속받아 사용할 수 있고 추가적인 속성과 메서드를 정의Axios 같은 페칭 라이브러리는 인터셉터 기능을 제공
const httpErrorHandler = (
error: AxiosError<ErrorResponse> | Error
): Promise<Error> => {
(error) => {
if (error.response && error.response.status === 401) {
window.location.href = `${backOfficeAuthHost}/logiun?targetUrl=${window.location.href}`;
}
return Promise.reject(error);
};
};
orderAPiRequeter.interceptors.response.use(
(response: AxiosResponse) => response,
httpErrorHandler
);
에러 바운더리는 리액트 컴포넌트 트리에서 발생할 때 공통으로 에러를 처리하는 리액트 컴포넌트.
에러 바운더리르 사용하면 리액트 컴포넌트 트리 하위에 있는 컴포넌트에서 발생한 에러를 캐치 해당 에러를 가장 가까운 부모 에러 바운더리에서 처리할 수 있따.
const JobComponent: React.FC = () => {
const { isError, error, isLoading, data } = useFetchJobList();
if (isError) {
return <div>{`${error.message}가 발생했다.`}</div>;
}
if (isLoading) {
return <div>로딩</div>;
}
return <>{data}</>;
};
이슈가 생겼을 때 charles 등의 도구를 활용하면 응답 값을 그대로 복사하여 이슈 발생 상황을 재형하는데 도움이 된다.
우아한형제들 프론트에서는 axios-mock-adapter,nextAPIHandler등을 활용하여 API를 모킹해서 사용
const SERVICE: Service[] = [
{
id: 0,
name: "배민",
},
{ id: 1, name: "만화" },
];
export default SERVICE;
//api
const getServices = ApiRequester.get("/mock/service.ts")
프로젝트 초기 단계에서 사용자의 인터랙션없이 빠르게 목업을 구축할 때 유용
그러나 실제 APIURL로 요청하는 것이 아니 떄문에 추후에 요청 경로를 바꿔야 함
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 요청 함수에 if 분기문을 추가해야 하므로 번거로울 수 있다.
axios-mock-adapter는 Axios 요청을 가로채서 요청에 대한 응답 값을 대신 반환
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().reply(200,fetchOrderListSuccessResponse){
"data":[
{
"orderNo":"ORDER1234",
"orderDate":"2022-02-02",
"shop":{
"shopNo":"shop1234",
"name":"1234"
},
"deliverStatus":"DELIVERY"
}
]
}
단순히 응답 바만 모킹 할 수도 이지만 상태 코드, 응답 지연 시간 등을 추가로 설정할 수도 있다.
응답 처리를 하는 부분을 별도 함수로 구현하면 여러 mock 함수에서 사용할 수 있다.
또한 networkError,timeoutError 등을 메서드로 제공하기 때문에 다음처럼 임의로 에러를 발생 시킬 수 있다.
export const fetchOrderListMock = () =>
mock.onPost(/\/order\/list/).networkError();
로컬에서는 목업 사용 하거나 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에 관련 스크립트를 추가해줄 수 있다.
//pacakge.josn
{
"scripts":{
"start:mock":"REACT_APP_MOCK=true npm run start",
"start": "REACT_APP_MOCK=false npm run start"
}
}
이렇게 자바스크립트 코드의 실행 여부를 제어하지 않고 config 파일을 별도로 구성하거나 프록시를 사용할 수도 있다.