axios 기반 라이브러리 제작 - 1 Custom Hook 뜯어보기
Front-End 에서 HTTP 통신시에 주로 axios 라이브러리를 사용한다. 필자도 axios 를 즐겨 사용하며 개발을 한다.
javascriptconst [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const [data, setData] = useState(null);
const fetchArticles = async () => {
setLoading(true);
setError(null);
try {
const res = await axios.get('api/articles');
setData(res.data)
} catch(error) {
setError(err.message);
} finally {
setLoading(false);
}
}
다음과 같이 대중적으로 axios 를 사용하게 되는데 필자는 해당 코드에서 공통 헤더를 정의하려고 할 때 똑같은 내용을 반복해서 정의해줘야 하는 것에 대해 불편함이 있었다.
예를 들어 인증이 필요한 데이터 접근 시에 대체로 header 값을 추가하는데
프로필 조회
javascriptconst fetchProfile = async () => {
setLoading(false);
setError(null);
try {
const res = await axios.get('/api/profile',
{
headers : {
Authorization : `Bearer ${token}`,
}
}
)
} catch (error) {
setError(error.message);
} finally {
setLoading(false)
}
}
닉네임 변경
javascriptconst updateNickname = async () => {
setLoading(true);
setError(null);
try {
const res = await axios.patch('/api/update_nickname',
{
nickname : nickname
}, {
headers: {
Authorization: `Bearer ${token}`,
}
)
} catch (error) {
setError(error.message)
} finally {
setLoading(false)
}
}
다음과 같이 동일한 경로에 다른 엔드포인트로 접근할 때 인증이 필요한 경우 중복되게 headers 를 넣어줘야 하는 걸 확인할 수 있다.
그래서 axios 의 instance를 직접 정의해서 중복된 코드를 제거할 수 있는데
axios instance 생성
javascriptconst axiosInstance = axios.create({
baseURL: 'api',
headers: {
'Content-Type': 'application/json',
},
})
axiosInstance.interceptors.request.use(
config => {
const token = localStorage.getItem('token') // 로컬 스토리지에서 accessToken 가져오기
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
error => Promise.reject(error)
)
export default axiosInstance;
다음과 같이 axios 의 interceptors 를 활용하여 자동으로 인증이 필요한 경우에 쉽게 headers에 삽입이 가능하다.
javascriptconst updateNickname = async () => {
setLoading(true);
setError(null);
try {
const res = await axiosInstance.patch('/update_nickname',{ nickname }
} catch (error) {
setError(error.message);
} finally {
setLoading(false)
}
}
확실히 코드가 간결해지고 편해졌다! 근데 보다보니 이 방법 또한 몇가지 문제가 보이기 시작했다.
- 매 API 요청시마다
useState로loading,error,data를 수동으로 관리해야 한다
javascriptconst [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
// 이 코드들이 매 API 요청시 마다 반복되는 걸 확인할 수 있다.
- 요청 성공 후 후처리를 위해 매번 try 블록 내부에 작성해야 함
데이터가 잘 넘어왔을 때 만약 어느 페이지로 이동하는 로직을 작성하면 다음과 같이 하게 되는데
javascripttry {
const res = await axiosInstance.patch('/update_nickname',{ nickname }
alert("닉네임이 성공적으로 변경되었습니다!");
navigate("/")
} catch (error) {
setError(error.message);
} finally {
setLoading(false)
}
}
// 닉네임이 변경되었을 때 alert 창으로 변경됨을 알리고 메인페이지로 이동
해당 코드의 경우 API 호출 로직과 UI 관련 로직이 뒤섞이는 문제가 있고 만약 다른 페이지에서 해당 함수를 사용하려고 하면 navigate 와 같은 UI 처리 로직이 묶여있어서 재사용에 어려움이 있다.
- 만약 응답 양식이 다르다면 일일히 파싱을 해줘야 함
예를 들어서 어떤 응답은 { data } , 어떤 건 { result } , 어떤건 그냥 배열로 응답되는 경우가 있는데 그렇다면 다음과 같이
javascriptsetData(res.data)
setData(res.result)
일일히 다르게 파싱을 해줘야 한다.
그래서 더 명확하게 하는 방법을 없다가 고민하다가 결국 내가 직접 커스텀으로 전역적으로 사용할 수 있는 훅을 만들기로 했다.
useAPI 훅
typescriptimport { useCallback, useState } from "react";
import axiosInstance from "../api/axios";
import { Response } from "../types/response/Response";
interface CustomConfig {
url?: string;
headers?: Record<string, string>;
params?: Record<string, unknown>;
}
export const useApi = <T, P = void>(endpoint: string, method: "GET" | "PATCH" | "POST" | "DELETE" | "PUT" = "GET") => {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [data, setData] = useState<T | null>(null);
const execute = useCallback(
async (payload?: P, config?: CustomConfig) => {
setLoading(true);
setError(null);
try {
let response;
const url = config?.url || endpoint;
const headers = config?.headers || {};
const params = config?.params;
switch (method) {
case "GET":
response = await axiosInstance.get<Response<T>>(url, { headers, params });
break;
case "PATCH":
response = await axiosInstance.patch<Response<T>>(url, payload, { headers, params });
break;
case "DELETE":
response = await axiosInstance.delete<Response<T>>(url, {
headers,
data: payload,
});
break;
case "POST":
response = await axiosInstance.post<Response<T>>(url, payload, {
headers,
});
break;
case "PUT":
response = await axiosInstance.put<Response<T>>(url, payload, { headers, params });
break;
}
const responseData = response.data;
// 기존 API 응답 처리
if (responseData.isSuccess) {
setData(responseData.result);
return responseData;
} else {
console.warn(`API 오류 응답:`, responseData);
setError(responseData.message);
return {
isSuccess: false,
code: responseData.code || 500,
message: responseData.message,
result: null,
};
}
} catch (err: unknown) {
console.error(`API 호출 중 예외 발생:`, err);
// 401 에러 (인증 실패) 처리
// 기타 오류 처리
let errorMessage = "알 수 없는 오류 발생";
if (err && typeof err === "object" && "message" in err) {
errorMessage = (err as Error).message;
}
if (err && typeof err === "object" && "response" in err) {
const axiosError = err as { response?: { data?: { message?: string } } };
errorMessage = axiosError.response?.data?.message || errorMessage;
console.error("응답 오류 데이터:", axiosError.response?.data);
}
setError(errorMessage);
return { success: false, error: errorMessage };
} finally {
setLoading(false);
}
},
[endpoint, method]
);
return { data, loading, error, execute, setData };
};
이렇게 커스텀 훅을 만들게 되었는데 코드를 뜯어보면
- Generic 타입(
<T, P = void>)
typescriptexport const useApi = <T, P = void)(endpoint: string, method: "GET" | ... = "GET")
T: API 응답 결과 타입P요청에 전달할 페이로드 타입
- 상태값 관리 (loading, error, data)
typescriptconst [loading, setLoding] = useState(false);
const [error, setError] = useState<string | null>(null);
const [data, setData] = useState<T | null)(null);
- 메서드 분기처리
typescriptswitch (method) {
case "GET" : ...
case "POST" : ...
...
}
switch문을 활용하여 HTTP 메서드 분기 처리- 불필요한 중복 없이
payload,params처리
axiosInstance사용
typescriptimport axios, { AxiosError, AxiosInstance, InternalAxiosRequestConfig, AxiosResponse } from "axios";
// Axios 인스턴스 생성
const axiosInstance: AxiosInstance = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL, // 백엔드 서버 URL
withCredentials: true, // 쿠키를 포함한 요청을 위해 필요
headers: {
"Content-Type": "application/json",
},
});
// 요청 인터셉터
axiosInstance.interceptors.request.use(
(config: InternalAxiosRequestConfig) => {
const token = localStorage.getItem("accessToken");
if (token && config.headers) {
config.headers["Authorization"] = `Bearer ${token}`;
}
},
(error: AxiosError) => {
return Promise.reject(error);
}
);
...
);
export default axiosInstance;
axiosInstance 사용을 통해 interceptor 기능 활용
- 예외 처리
typescriptcatch (err: unknown) {
...
if (err && typeof err === "object" && "message" in err) {
...
}
}
unknown으로 타입을 지정하고 타입 가드를 사용하여 점진적으로 파싱401,응답 메시지,기타 오류등등 커버 가능
사용 예시
typescriptexport const useUpdateNickname = () => {
const { loading, error, execute: updateNickname } = useApi<User>("api/member/nickname", "PATCH");
/**
* 닉네임 변경 함수
* @param nickname 새 닉네임
* @returns 결과 객체 (success, data: User)
*/
const changeNickname = async (nickname: string) => {
return await updateNickname(undefined, {
params: { nickname },
});
};
return {
changeNickname,
loading,
error,
};
};
이렇게 동일한 닉네임 변경 로직이 다음과 같이 간단해졌으며, 별도의 hook으로 분리하여 API 요청 로직만 담당하도록 하였다.
실제로 한 프로젝트에서 다음과 같이 코드를 작성하였고 다른 프로젝트에서도 동일하게 구조를 잡고 진행하였는데 팀원들이 API 요청 방식이 간단해져서 좋았고 코드 리뷰 때나 유지 보수에 편하다는 의견이 많았다.
그래서 이 Hook 을 토대로 라이브러리를 제작해보면 어떨까?
