2025 . 오은

repo

axios client 의 실전적 구성

axios client 의 실전적 구성

1. 문제 정의

  • axios 클라이언트 구성을 매번 그때그때 하다보니 문제가 생길때가 많음
  • 그러다보니 구조화가 되어있지 않아 어떻게했었지 하고 또 찾아봄
  • 요청, 응답 interceptor 작성을 관성적으로 했더니 사용할때 제네릭을 두번씩 api.get<타입, 타입> 이런식으로 쓰고 있었음
  • 2개 이상의 axios 클라이언트를 만들때(ex 서버용, 클라이언트용 등) 계층화가 안되어있어 불편
  • 타입 안전성 부족과 에러 처리가 일관성이 없음
  • 환경별 설정 관리가 어려움

2. 해결 방안

  • 클래스로 axios api 클라이언트를 구성한다
  • api-client 추상 클래스 에서는 axios instance 를 생성하고 기본 메서드를 오버라이드한다
  • 기본메서드를 오버라이드 하는 이유는 사용시 제네릭 입력 두번 안하고 편하게 하기 위해서
  • 실제 사용할 api 메서드들은 base-api-client 를 extends 한 클래스에 작성
  • 타입 안전성 강화와 커스텀 에러 클래스 도입
  • 환경별 설정을 구조화하여 관리
  • 요청 취소 및 재시도 로직 추가

3. 타입 정의

// API 응답 구조 정의
interface ApiResponse<T> {
  data: T;
  access_token?: string;
  message?: string;
  status?: string;
}

// 에러 응답 구조 정의
interface ApiErrorResponse {
  message: string;
  detail?: string;
  code?: string;
}

// 환경별 설정 인터페이스
interface ApiConfig {
  apiUrl: string;
  adminUrl: string;
  timeout?: number;
  retryAttempts?: number;
  enableLogging?: boolean;
}

// 커스텀 에러 클래스
class ApiError extends Error {
  constructor(
    public status: number,
    public message: string,
    public data?: any
  ) {
    super(message);
    this.name = 'ApiError';
  }
}

4. base-api-client.ts 추상클래스

export abstract class BaseApiClient {
  protected readonly apiInstance: AxiosInstance;
  protected readonly adminInstance: AxiosInstance;
  private readonly config: ApiConfig;

  constructor(config: ApiConfig) {
    this.config = config;
    
    // 원하는 만큼 인스턴스 생성 (환경별 설정 적용)
    this.apiInstance = axios.create({ 
      baseURL: config.apiUrl,
      timeout: config.timeout || 10000,
    });
    
    this.adminInstance = axios.create({ 
      baseURL: config.adminUrl,
      timeout: config.timeout || 10000,
    });

    // 여러 인스턴스에 동일 인터셉터 적용
    this.applyRequestInterceptor(this.apiInstance);
    this.applyRequestInterceptor(this.adminInstance);
    this.applyResponseInterceptor(this.apiInstance);
    this.applyResponseInterceptor(this.adminInstance);
  }

  private applyRequestInterceptor(instance: AxiosInstance): void {
    // 요청시 로컬스토리지에 토큰 있으면 자동 적용
    instance.interceptors.request.use((config) => {
      if (typeof window !== "undefined") {
        const token = getLocalStorage(TOKEN_KEY);
        if (token) {
          config.headers = config.headers || {};
          (config.headers as any).Authorization = `Bearer ${token}`;
        }
      }
      return config;
    });
  }
  
  private applyResponseInterceptor(instance: AxiosInstance): void {
    instance.interceptors.response.use(
      (response: AxiosResponse<ApiResponse<any>>) => {
        // 응답 구조 검증
        if (this.isValidApiResponse(response.data)) {
          // 토큰이 있으면 자동 저장
          if (typeof window !== "undefined" && response.data?.access_token) {
            setLocalStorage(TOKEN_KEY, response.data.access_token);
          }
          // 실제 데이터만 반환 (data 래핑 해제)
          return response.data.data || response.data;
        }
        return response.data;
      },
      (error: AxiosError<ApiErrorResponse>) => {
        return this.handleApiError(error);
      }
    );
  }

  // API 응답 구조 검증
  private isValidApiResponse(data: any): data is ApiResponse<any> {
    return data && typeof data === 'object';
  }

  // 통합 에러 처리
  private handleApiError(error: AxiosError<ApiErrorResponse>): never {
    const { status = 500, data } = error.response || {};
    const message = data?.message || error.message;
    
    // 로깅 (개발 환경에서만)
    if (this.config.enableLogging && process.env.NODE_ENV === 'development') {
      console.error(`API Error [${status}]:`, message, data);
    }

    // 상태별 처리
    switch (status) {
      case 400:
        throw new ApiError(status, message || "잘못된 요청입니다.", data);
      case 401: 
        // 토큰 만료 처리
        if (typeof window !== "undefined") {
          removeLocalStorage(TOKEN_KEY);
        }
        throw new ApiError(status, message || "인증이 필요합니다.", data);
      case 403:
        throw new ApiError(status, message || "접근 권한이 없습니다.", data);
      case 404:
        throw new ApiError(status, message || "요청한 리소스를 찾을 수 없습니다.", data);
      case 500:
        throw new ApiError(status, message || "서버 오류가 발생했습니다.", data);
      default:
        throw new ApiError(status, message || "알 수 없는 오류가 발생했습니다.", data);
    }
  }

  // 재시도 로직
  private async retryRequest<T>(
    requestFn: () => Promise<T>,
    maxRetries: number = this.config.retryAttempts || 3
  ): Promise<T> {
    for (let i = 0; i < maxRetries; i++) {
      try {
        return await requestFn();
      } catch (error) {
        if (
	        i === maxRetries - 1 || 
	        error instanceof ApiError && error.status < 500
				) {
          throw error;
        }
        // 지수 백오프 (1초, 2초, 4초...)
        await this.delay(1000 * Math.pow(2, i));
      }
    }
    throw new Error('Max retries reached');
  }

  private delay(ms: number): Promise<void> {
    return new Promise(resolve => setTimeout(resolve, ms));
  }

  // HTTP 메서드 (타입 안전성 및 요청 취소 지원)
  protected getAdmin<T = unknown>(
    url: string, 
    config?: AxiosRequestConfig & { signal?: AbortSignal }
  ): Promise<T> {
    return this.retryRequest(() => 
      this.adminInstance.get(url, config) as Promise<T>
    );
  }
  
  protected get<T = unknown>(
    url: string, 
    config?: AxiosRequestConfig & { signal?: AbortSignal }
  ): Promise<T> {
    return this.retryRequest(() => 
      this.apiInstance.get(url, config) as Promise<T>
    );
  }
  
  protected delete<T = unknown>(
    url: string, 
    config?: AxiosRequestConfig & { signal?: AbortSignal }
  ): Promise<T> {
    return this.retryRequest(() => 
      this.apiInstance.delete(url, config) as Promise<T>
    );
  }
  
  protected post<T = unknown>(
    url: string,
    data?: unknown,
    config?: AxiosRequestConfig & { signal?: AbortSignal }
  ): Promise<T> {
    return this.retryRequest(() => 
      this.apiInstance.post(url, data, config) as Promise<T>
    );
  }
  
  protected put<T = unknown>(
    url: string, 
    data?: unknown, 
    config?: AxiosRequestConfig & { signal?: AbortSignal }
  ): Promise<T> {
    return this.retryRequest(() => 
      this.apiInstance.put(url, data, config) as Promise<T>
    );
  }
  
  protected patch<T = unknown>(
    url: string,
    data?: unknown,
    config?: AxiosRequestConfig & { signal?: AbortSignal }
  ): Promise<T> {
    return this.retryRequest(() => 
      this.apiInstance.patch(url, data, config) as Promise<T>
    );
  }
}

5. apis.ts

class ApiClient extends BaseApiClient {
  constructor() {
    // 환경별 설정
    const config: ApiConfig = {
      apiUrl: API_URL || '',
      adminUrl: ADMIN_API_URL || '',
      timeout: 15000,
      retryAttempts: 3,
      enableLogging: process.env.NODE_ENV === 'development'
    };

    if (!config.apiUrl || !config.adminUrl) {
      throw new Error("API_URL과 ADMIN_API_URL이 설정되지 않았습니다.");
    }
    
    super(config);
  }

  // == 도메인 메서드 (AbortSignal 지원) ==
  
  // 유저 체크
  public getUserCheck(signal?: AbortSignal): Promise<UserCheckResponse> {
    return this.get<UserCheckResponse>(`/api/auth/user-check`, { signal });
  }

  // 닉네임 또는 메시지 비속어 체크
  public postProhibitedCheck(
    text: string, 
    signal?: AbortSignal
  ): Promise<ProhibitedCheckResponse> {
    return this.post<ProhibitedCheckResponse>(
      `/api/poster/prohibited-check?text=${text}`,
      undefined,
      { signal }
    );
  }
  
  // 레디스 체크
  public postRedisCheck(signal?: AbortSignal): Promise<RedisCheckResponse> {
    return this.post<RedisCheckResponse>(`/api/poster/redis-status`, undefined, { signal });
  }

  // 생성 여부 체크
  public getPosterCheck(signal?: AbortSignal): Promise<PosterCheckResponse> {
    return this.get<PosterCheckResponse>(`/api/auth/poster-check`, { signal });
  }
  
  // 여기에 계속 추가...
}  

const api = new ApiClient();
export default api;

6. 사용 예시

// 기본 사용
try {
  const userData = await api.getUserCheck();
  console.log(userData);
} catch (error) {
  if (error instanceof ApiError) {
    console.error(`API 오류 [${error.status}]: ${error.message}`);
    // 상태별 처리
    if (error.status === 401) {
      // 로그인 페이지로 리다이렉트
    }
  }
}

// 요청 취소
const controller = new AbortController();
const userData = api.getUserCheck(controller.signal);

// 5초 후 취소
setTimeout(() => controller.abort(), 5000);

7. 해결되는 문제

  • 제네릭 입력 편리
  • 가독성 향상
  • 적절한 추상화, 캡슐화로 사용성 향상
  • 타입 안전성 확보 (ApiResponse, ApiError 타입 정의)
  • 환경별 설정 관리 (ApiConfig 인터페이스)
  • 통일된 에러 처리 (커스텀 ApiError 클래스)
  • 자동 재시도 및 요청 취소 (AbortSignal, 지수 백오프)
  • 개발 환경 로깅 (디버깅 편의성)