background
TypeScript10분

axios 기반 라이브러리 제작 - 1 Custom Hook 뜯어보기

2025년 7월 17일

Front-End 에서 HTTP 통신시에 주로 axios 라이브러리를 사용한다. 필자도 axios 를 즐겨 사용하며 개발을 한다.

javascript
const [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 값을 추가하는데

프로필 조회

javascript
const 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)
	}
}

닉네임 변경

javascript
const 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 생성

javascript
const 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;

다음과 같이 axiosinterceptors 를 활용하여 자동으로 인증이 필요한 경우에 쉽게 headers에 삽입이 가능하다.

javascript
const updateNickname = async () => {
	setLoading(true);
	setError(null);
	
	try {
		const res = await axiosInstance.patch('/update_nickname',{ nickname }
	} catch (error) {
		setError(error.message);
	} finally {
		setLoading(false)
	}
}

확실히 코드가 간결해지고 편해졌다! 근데 보다보니 이 방법 또한 몇가지 문제가 보이기 시작했다.

  1. 매 API 요청시마다 useState loading , error , data 를 수동으로 관리해야 한다
javascript
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);

// 이 코드들이 매 API 요청시 마다 반복되는 걸 확인할 수 있다.
  1. 요청 성공 후 후처리를 위해 매번 try 블록 내부에 작성해야 함

데이터가 잘 넘어왔을 때 만약 어느 페이지로 이동하는 로직을 작성하면 다음과 같이 하게 되는데

javascript
try {
		const res = await axiosInstance.patch('/update_nickname',{ nickname }
		alert("닉네임이 성공적으로 변경되었습니다!");
		navigate("/")
	} catch (error) {
		setError(error.message);
	} finally {
		setLoading(false)
	}
}

// 닉네임이 변경되었을 때 alert 창으로 변경됨을 알리고 메인페이지로 이동

해당 코드의 경우 API 호출 로직과 UI 관련 로직이 뒤섞이는 문제가 있고 만약 다른 페이지에서 해당 함수를 사용하려고 하면 navigate 와 같은 UI 처리 로직이 묶여있어서 재사용에 어려움이 있다.

  1. 만약 응답 양식이 다르다면 일일히 파싱을 해줘야 함

예를 들어서 어떤 응답은 { data } , 어떤 건 { result } , 어떤건 그냥 배열로 응답되는 경우가 있는데 그렇다면 다음과 같이

javascript
setData(res.data)
setData(res.result)

일일히 다르게 파싱을 해줘야 한다.

그래서 더 명확하게 하는 방법을 없다가 고민하다가 결국 내가 직접 커스텀으로 전역적으로 사용할 수 있는 훅을 만들기로 했다.

useAPI 훅

typescript
import { 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 };
};

이렇게 커스텀 훅을 만들게 되었는데 코드를 뜯어보면

  1. Generic 타입(<T, P = void>)
typescript
export const useApi = <T, P = void)(endpoint: string, method: "GET" | ... = "GET")
  • T : API 응답 결과 타입
  • P 요청에 전달할 페이로드 타입
  1. 상태값 관리 (loading, error, data)
typescript
const [loading, setLoding] = useState(false);
const [error, setError] = useState<string | null>(null);
const [data, setData] = useState<T | null)(null);
  1. 메서드 분기처리
typescript
switch (method) {
	case "GET" : ...
	case "POST" : ...
	...
}
  • switch 문을 활용하여 HTTP 메서드 분기 처리
  • 불필요한 중복 없이 payload , params 처리
  1. axiosInstance 사용
typescript
import 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 기능 활용

  1. 예외 처리
typescript
catch (err: unknown) {
...
	if (err && typeof err === "object" && "message" in err) {
		...
	}

}
  • unknown 으로 타입을 지정하고 타입 가드를 사용하여 점진적으로 파싱
  • 401 , 응답 메시지 , 기타 오류 등등 커버 가능

사용 예시

typescript
export 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 을 토대로 라이브러리를 제작해보면 어떨까?

태그

#axios#library