[Next.js] fetch() vs 써드파티 라이브러리 비교




웹 애플리케이션을 개발할 때 가장 기본적이면서도 중요한 결정 중 하나는 “어떤 HTTP 클라이언트를 사용할 것인가?”입니다. 브라우저 내장 fetch() API를 사용할 것인가, 아니면 Axios, Ky, SWR, TanStack Query 같은 써드파티 라이브러리를 선택할 것인가?

각 도구는 고유한 장단점과 적합한 사용 사례를 가지고 있으며, 잘못된 선택은 프로젝트의 복잡도를 불필요하게 증가시킬 수 있습니다. 이 가이드에서는 각 솔루션의 특징을 실제 코드와 함께 상세히 비교하여, 여러분의 프로젝트에 최적의 선택을 할 수 있도록 돕겠습니다.


Native fetch() API 완벽 이해

fetch()란 무엇인가?

fetch()는 현대 브라우저에 내장된 네이티브 API로, 네트워크 요청을 처리하는 표준 방법입니다. Promise 기반으로 동작하며, 별도의 라이브러리 설치 없이 사용할 수 있습니다.

// 가장 기본적인 fetch 사용법
fetch('https://api.example.com/data')
  .then(response => response.json())
  .then(data => console.log(data))
  .catch(error => console.error('Error:', error));

// async/await를 사용한 현대적인 방법
async function getData() {
  try {
    const response = await fetch('https://api.example.com/data');
    
    // ⚠️ 중요: fetch는 네트워크 에러만 catch로 잡음
    // HTTP 에러 (404, 500 등)는 수동으로 체크해야 함
    if (!response.ok) {
      throw new Error(`HTTP error! status: ${response.status}`);
    }
    
    const data = await response.json();
    return data;
  } catch (error) {
    console.error('Fetch error:', error);
    throw error;
  }
}

Next.js에서 확장된 fetch()

Next.js는 fetch() API를 확장하여 캐싱과 재검증 기능을 추가했습니다:

// Next.js의 확장 fetch - 캐싱과 재검증 옵션
async function getProducts() {
  const response = await fetch('https://api.example.com/products', {
    // Next.js 전용 옵션
    next: {
      revalidate: 3600, // 1시간마다 재검증
      tags: ['products'] // 태그 기반 캐시 무효화
    }
  });
  
  return response.json();
}

// 캐싱 비활성화
async function getRealTimeData() {
  const response = await fetch('https://api.example.com/live-data', {
    cache: 'no-store' // 매번 새로운 데이터 가져오기
  });
  
  return response.json();
}

fetch()의 한계와 해결책

fetch()는 간단하지만 실제 프로덕션에서는 많은 추가 작업이 필요합니다:

// 실전에서 필요한 fetch 래퍼 함수
class FetchWrapper {
  private baseURL: string;
  private defaultHeaders: HeadersInit;
  
  constructor(baseURL: string = '') {
    this.baseURL = baseURL;
    this.defaultHeaders = {
      'Content-Type': 'application/json',
    };
  }
  
  // 타임아웃 기능 추가 (fetch는 기본적으로 타임아웃이 없음!)
  private async fetchWithTimeout(
    url: string,
    options: RequestInit = {},
    timeout: number = 10000
  ): Promise<Response> {
    const controller = new AbortController();
    const timeoutId = setTimeout(() => {
      controller.abort();
      console.log(`⏱️ Request timeout after ${timeout}ms`);
    }, timeout);
    
    try {
      const response = await fetch(url, {
        ...options,
        signal: controller.signal
      });
      clearTimeout(timeoutId);
      return response;
    } catch (error) {
      clearTimeout(timeoutId);
      if (error.name === 'AbortError') {
        throw new Error(`Request timeout after ${timeout}ms`);
      }
      throw error;
    }
  }
  
  // 재시도 로직 추가
  private async fetchWithRetry(
    url: string,
    options: RequestInit = {},
    retries: number = 3
  ): Promise<Response> {
    for (let i = 0; i < retries; i++) {
      try {
        console.log(`🔄 시도 ${i + 1}/${retries}: ${url}`);
        
        const response = await this.fetchWithTimeout(url, options);
        
        // 성공하면 즉시 반환
        if (response.ok) {
          console.log(`✅ 성공: ${url}`);
          return response;
        }
        
        // 5xx 에러는 재시도, 4xx는 즉시 실패
        if (response.status < 500) {
          throw new Error(`HTTP ${response.status}: ${response.statusText}`);
        }
        
        // 마지막 시도가 아니면 대기 후 재시도
        if (i < retries - 1) {
          const delay = Math.pow(2, i) * 1000; // 지수 백오프
          console.log(`⏳ ${delay}ms 대기 후 재시도...`);
          await new Promise(resolve => setTimeout(resolve, delay));
        }
      } catch (error) {
        if (i === retries - 1) throw error;
      }
    }
    
    throw new Error('Max retries exceeded');
  }
  
  // 통합 request 메서드
  async request<T>(
    endpoint: string,
    options: RequestInit = {}
  ): Promise<T> {
    const url = this.baseURL + endpoint;
    
    console.log(`📡 Request: ${options.method || 'GET'} ${url}`);
    
    const response = await this.fetchWithRetry(url, {
      ...options,
      headers: {
        ...this.defaultHeaders,
        ...options.headers
      }
    });
    
    // 에러 처리
    if (!response.ok) {
      const errorBody = await response.text();
      console.error(`❌ Error: ${response.status} - ${errorBody}`);
      throw new Error(errorBody || `HTTP ${response.status}`);
    }
    
    // 응답 파싱
    const data = await response.json();
    console.log(`✅ Response received`);
    
    return data;
  }
  
  // 편의 메서드들
  get<T>(endpoint: string): Promise<T> {
    return this.request<T>(endpoint, { method: 'GET' });
  }
  
  post<T>(endpoint: string, data?: any): Promise<T> {
    return this.request<T>(endpoint, {
      method: 'POST',
      body: JSON.stringify(data)
    });
  }
}

// 사용 예시
const api = new FetchWrapper('https://api.example.com');
const users = await api.get<User[]>('/users');
const newUser = await api.post<User>('/users', { name: 'John' });

fetch()의 주요 한계:

  • 타임아웃 기능 없음
  • 자동 재시도 없음
  • 요청/응답 인터셉터 없음
  • 진행률 추적 어려움
  • 요청 취소가 복잡함

Axios – 강력한 기능의 대표 주자

Axios가 특별한 이유

Axios는 가장 인기 있는 HTTP 클라이언트 라이브러리로, fetch()의 한계를 극복한 다양한 기능을 제공합니다:

import axios, { AxiosInstance } from 'axios';

// Axios 인스턴스 생성 - 설정 중앙화
const axiosInstance: AxiosInstance = axios.create({
  baseURL: process.env.NEXT_PUBLIC_API_URL,
  timeout: 10000, // 10초 타임아웃 (fetch에는 없는 기능!)
  headers: {
    'Content-Type': 'application/json',
  }
});

// Request Interceptor - 모든 요청 전에 실행
axiosInstance.interceptors.request.use(
  async (config) => {
    console.log(`🚀 요청: ${config.method?.toUpperCase()} ${config.url}`);
    
    // 자동 토큰 추가
    const token = localStorage.getItem('accessToken');
    if (token) {
      config.headers.Authorization = `Bearer ${token}`;
      console.log('🔐 토큰 추가됨');
    }
    
    // 요청 시작 시간 기록 (응답 시간 측정용)
    config.metadata = { startTime: Date.now() };
    
    return config;
  },
  (error) => {
    console.error('❌ Request interceptor error:', error);
    return Promise.reject(error);
  }
);

// Response Interceptor - 모든 응답 후에 실행
axiosInstance.interceptors.response.use(
  (response) => {
    // 응답 시간 계산
    const duration = Date.now() - response.config.metadata.startTime;
    console.log(`✅ 응답: ${response.status} (${duration}ms)`);
    
    return response;
  },
  async (error) => {
    const originalRequest = error.config;
    
    // 401 에러 시 토큰 자동 갱신
    if (error.response?.status === 401 && !originalRequest._retry) {
      console.log('🔄 토큰 만료, 갱신 시도...');
      originalRequest._retry = true; // 무한 루프 방지
      
      try {
        const refreshToken = localStorage.getItem('refreshToken');
        const { data } = await axios.post('/api/auth/refresh', {
          refreshToken
        });
        
        localStorage.setItem('accessToken', data.accessToken);
        originalRequest.headers.Authorization = `Bearer ${data.accessToken}`;
        
        console.log('✅ 토큰 갱신 성공, 요청 재시도');
        return axiosInstance(originalRequest);
      } catch (refreshError) {
        console.error('❌ 토큰 갱신 실패, 로그아웃');
        localStorage.clear();
        window.location.href = '/login';
        return Promise.reject(refreshError);
      }
    }
    
    // 네트워크 에러 처리
    if (!error.response) {
      console.error('🌐 네트워크 에러:', error.message);
      alert('인터넷 연결을 확인해주세요.');
    }
    
    return Promise.reject(error);
  }
);

Axios의 고급 기능들

// 1. 파일 업로드 with 진행률
export async function uploadFileWithProgress(
  file: File,
  onProgress?: (percent: number) => void
) {
  const formData = new FormData();
  formData.append('file', file);
  
  try {
    const response = await axiosInstance.post('/upload', formData, {
      headers: {
        'Content-Type': 'multipart/form-data',
      },
      onUploadProgress: (progressEvent) => {
        const percentCompleted = Math.round(
          (progressEvent.loaded * 100) / (progressEvent.total || 1)
        );
        console.log(`📤 업로드 진행률: ${percentCompleted}%`);
        onProgress?.(percentCompleted);
      }
    });
    
    return response.data;
  } catch (error) {
    if (axios.isCancel(error)) {
      console.log('🚫 업로드 취소됨');
    }
    throw error;
  }
}

// 2. 요청 취소
const CancelToken = axios.CancelToken;
let cancelTokenSource = CancelToken.source();

export function cancelableRequest() {
  // 이전 요청 취소
  cancelTokenSource.cancel('새로운 요청으로 인한 취소');
  
  // 새 토큰 생성
  cancelTokenSource = CancelToken.source();
  
  return axiosInstance.get('/data', {
    cancelToken: cancelTokenSource.token
  });
}

// 3. 동시 요청 처리
export async function fetchMultipleData() {
  console.log('🔄 여러 데이터 동시 요청...');
  
  try {
    const [users, posts, comments] = await axios.all([
      axiosInstance.get('/users'),
      axiosInstance.get('/posts'),
      axiosInstance.get('/comments')
    ]);
    
    console.log('✅ 모든 데이터 로드 완료');
    
    return {
      users: users.data,
      posts: posts.data,
      comments: comments.data
    };
  } catch (error) {
    console.error('❌ 동시 요청 실패:', error);
    throw error;
  }
}

Axios의 장점:

  • 자동 JSON 변환
  • 요청/응답 인터셉터
  • 타임아웃 지원
  • 진행률 추적
  • 요청 취소
  • 브라우저와 Node.js 모두 지원

Axios의 단점:

  • 번들 사이즈 (15.4KB)
  • 추가 설치 필요
  • fetch()보다 약간 느림

Ky – 현대적이고 가벼운 대안

Ky의 철학: fetch()를 더 나은 방식으로

Ky는 fetch() API를 기반으로 하면서도 Axios의 편리한 기능들을 제공하는 현대적인 라이브러리입니다:

import ky from 'ky';

// Ky 인스턴스 생성 - 더 간결한 설정
const kyInstance = ky.create({
  prefixUrl: process.env.NEXT_PUBLIC_API_URL,
  timeout: 10000,
  retry: {
    limit: 2, // 재시도 횟수
    methods: ['get', 'put', 'delete'], // 재시도할 메서드
    statusCodes: [408, 413, 429, 500, 502, 503, 504], // 재시도할 상태 코드
    backoffLimit: 3000 // 최대 대기 시간
  },
  hooks: {
    beforeRequest: [
      async (request) => {
        console.log(`🚀 Ky 요청: ${request.method} ${request.url}`);
        
        // 토큰 추가
        const token = await getAccessToken();
        if (token) {
          request.headers.set('Authorization', `Bearer ${token}`);
        }
        
        // Request ID 추가 (디버깅용)
        request.headers.set('X-Request-ID', crypto.randomUUID());
      }
    ],
    beforeRetry: [
      async ({ request, error, retryCount }) => {
        console.log(`🔄 재시도 ${retryCount}회: ${request.url}`);
        
        // 429 (Too Many Requests) 처리
        if (error.response?.status === 429) {
          const retryAfter = error.response.headers.get('Retry-After');
          if (retryAfter) {
            const delay = parseInt(retryAfter) * 1000;
            console.log(`⏳ ${delay}ms 대기 (Rate Limit)`);
            await new Promise(resolve => setTimeout(resolve, delay));
          }
        }
      }
    ],
    afterResponse: [
      async (request, options, response) => {
        // Rate Limit 정보 로깅
        const remaining = response.headers.get('X-RateLimit-Remaining');
        if (remaining) {
          console.log(`📊 API 호출 잔여 횟수: ${remaining}`);
        }
        
        // 응답 시간 로깅
        const duration = Date.now() - request.startTime;
        console.log(`✅ 응답 시간: ${duration}ms`);
      }
    ]
  }
});

// Ky의 간편한 사용법
async function fetchWithKy() {
  try {
    // GET 요청
    const users = await kyInstance.get('users').json();
    
    // POST 요청 - 자동 JSON 직렬화
    const newUser = await kyInstance.post('users', {
      json: { name: 'John', email: 'john@example.com' }
    }).json();
    
    // 검색 파라미터 처리
    const filtered = await kyInstance.get('users', {
      searchParams: {
        role: 'admin',
        active: true
      }
    }).json();
    
    return { users, newUser, filtered };
  } catch (error) {
    if (error.response) {
      const errorBody = await error.response.json();
      console.error('API 에러:', errorBody);
    }
    throw error;
  }
}

Ky의 장점:

  • fetch() 기반 (표준 준수)
  • 작은 번들 사이즈 (9.7KB)
  • 자동 재시도
  • 간결한 API
  • TypeScript 완벽 지원

Ky의 단점:

  • 브라우저 전용 (Node.js는 ky-universal 필요)
  • Axios보다 커뮤니티 작음

SWR – 데이터 페칭의 새로운 패러다임

SWR이란? Stale-While-Revalidate

SWR은 단순한 HTTP 클라이언트가 아닌, 데이터 페칭 훅입니다. 캐싱, 재검증, 포커스 추적, 리페칭 등을 자동으로 처리합니다:

import useSWR from 'swr';

// 기본 fetcher 함수
const fetcher = async (url: string) => {
  const response = await fetch(url);
  
  if (!response.ok) {
    throw new Error(`HTTP ${response.status}`);
  }
  
  return response.json();
};

// SWR 사용 예시
function UserProfile({ userId }: { userId: string }) {
  // SWR의 마법! 자동 캐싱, 재검증, 에러 처리
  const { 
    data: user, 
    error, 
    isLoading, 
    isValidating,
    mutate 
  } = useSWR(
    userId ? `/api/users/${userId}` : null, // 조건부 페칭
    fetcher,
    {
      revalidateOnFocus: true,     // 포커스 시 재검증
      revalidateOnReconnect: true,  // 재연결 시 재검증
      refreshInterval: 0,           // 자동 갱신 간격 (0 = 비활성화)
      dedupingInterval: 2000,       // 중복 요청 제거 간격
      
      onSuccess: (data) => {
        console.log('✅ 사용자 데이터 로드:', data);
      },
      onError: (error) => {
        console.error('❌ 로드 실패:', error);
      }
    }
  );
  
  // 로딩 상태
  if (isLoading) {
    return (
      <div className="skeleton">
        <div className="skeleton-avatar" />
        <div className="skeleton-text" />
      </div>
    );
  }
  
  // 에러 상태
  if (error) {
    return (
      <div className="error">
        <p>데이터를 불러올 수 없습니다.</p>
        <button onClick={() => mutate()}>다시 시도</button>
      </div>
    );
  }
  
  // 성공 상태
  return (
    <div className="user-profile">
      <img src={user.avatar} alt={user.name} />
      <h2>{user.name}</h2>
      <p>{user.bio}</p>
      {isValidating && <span className="updating">업데이트 중...</span>}
    </div>
  );
}

SWR의 고급 패턴들

// 1. 무한 스크롤 구현
function InfiniteUserList() {
  const { 
    data, 
    error, 
    size, 
    setSize, 
    isValidating, 
    isLoading 
  } = useSWRInfinite(
    (pageIndex, previousPageData) => {
      // 마지막 페이지 도달
      if (previousPageData && !previousPageData.length) return null;
      
      // 페이지 URL 생성
      return `/api/users?page=${pageIndex}&limit=10`;
    },
    fetcher
  );
  
  const users = data ? data.flat() : [];
  const isLoadingMore = isLoading || (size > 0 && data && typeof data[size - 1] === 'undefined');
  const isEmpty = data?.[0]?.length === 0;
  const isReachingEnd = isEmpty || (data && data[data.length - 1]?.length < 10);
  
  return (
    <div>
      {users.map(user => (
        <UserCard key={user.id} user={user} />
      ))}
      
      <button
        disabled={isLoadingMore || isReachingEnd}
        onClick={() => setSize(size + 1)}
      >
        {isLoadingMore 
          ? '로딩 중...' 
          : isReachingEnd 
          ? '더 이상 없습니다' 
          : '더 보기'}
      </button>
    </div>
  );
}

// 2. 낙관적 업데이트
function TodoList() {
  const { data: todos, mutate } = useSWR('/api/todos', fetcher);
  
  const addTodo = async (text: string) => {
    const newTodo = {
      id: Date.now(), // 임시 ID
      text,
      completed: false,
      optimistic: true // 낙관적 플래그
    };
    
    // 낙관적 업데이트 - UI 즉시 반영
    mutate(
      async (todos) => {
        // 서버에 요청
        const created = await fetch('/api/todos', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({ text })
        }).then(r => r.json());
        
        // 임시 항목을 실제 항목으로 교체
        return todos.map(t => 
          t.id === newTodo.id ? created : t
        );
      },
      {
        optimisticData: [...(todos || []), newTodo],
        rollbackOnError: true, // 에러 시 자동 롤백
        populateCache: true,
        revalidate: false
      }
    );
  };
  
  return (
    <div>
      {todos?.map(todo => (
        <TodoItem 
          key={todo.id} 
          todo={todo} 
          isOptimistic={todo.optimistic} 
        />
      ))}
      <AddTodoForm onAdd={addTodo} />
    </div>
  );
}

// 3. 글로벌 설정
import { SWRConfig } from 'swr';

export function App({ children }) {
  return (
    <SWRConfig
      value={{
        fetcher,
        revalidateOnFocus: false,
        shouldRetryOnError: true,
        errorRetryCount: 3,
        errorRetryInterval: 1000,
        
        onError: (error, key) => {
          console.error(`SWR Error for ${key}:`, error);
          
          if (error.status === 401) {
            // 인증 에러 - 로그인 페이지로
            window.location.href = '/login';
          }
        }
      }}
    >
      {children}
    </SWRConfig>
  );
}

SWR의 장점:

  • 자동 캐싱과 재검증
  • 포커스/재연결 시 자동 갱신
  • 낙관적 업데이트
  • 중복 요청 제거
  • React Suspense 지원

SWR의 단점:

  • React 전용
  • 단순 HTTP 요청엔 과도함

TanStack Query – 엔터프라이즈급 솔루션

왜 TanStack Query인가?

TanStack Query(구 React Query)는 가장 강력하고 유연한 데이터 페칭 라이브러리입니다:

import { 
  useQuery, 
  useMutation, 
  useQueryClient,
  QueryClient,
  QueryClientProvider 
} from '@tanstack/react-query';

// QueryClient 설정
const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 60 * 1000,      // 1분간 fresh 상태 유지
      gcTime: 5 * 60 * 1000,      // 5분간 캐시 유지 (구 cacheTime)
      retry: (failureCount, error: any) => {
        // 404는 재시도 안 함
        if (error.status === 404) return false;
        // 최대 3번 재시도
        return failureCount < 3;
      },
      retryDelay: attemptIndex => Math.min(1000 * 2 ** attemptIndex, 30000),
    },
    mutations: {
      onError: (error) => {
        console.error('Mutation 에러:', error);
      }
    }
  }
});

// 사용자 데이터 페칭
function UserProfile({ userId }: { userId: string }) {
  const { 
    data: user, 
    isLoading, 
    isError, 
    error,
    refetch,
    isRefetching 
  } = useQuery({
    queryKey: ['user', userId], // 캐시 키
    queryFn: async () => {
      console.log(`📡 사용자 ${userId} 정보 페칭...`);
      const response = await fetch(`/api/users/${userId}`);
      
      if (!response.ok) {
        throw new Error('Failed to fetch user');
      }
      
      return response.json();
    },
    enabled: !!userId, // userId가 있을 때만 실행
    
    // 데이터 변환
    select: (data) => ({
      ...data,
      fullName: `${data.firstName} ${data.lastName}`
    })
  });
  
  // Mutation - 사용자 업데이트
  const updateUserMutation = useMutation({
    mutationFn: async (updates: Partial<User>) => {
      const response = await fetch(`/api/users/${userId}`, {
        method: 'PATCH',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(updates)
      });
      
      if (!response.ok) throw new Error('Update failed');
      
      return response.json();
    },
    
    // 낙관적 업데이트
    onMutate: async (updates) => {
      // 진행 중인 리페치 취소
      await queryClient.cancelQueries({ queryKey: ['user', userId] });
      
      // 이전 값 백업
      const previousUser = queryClient.getQueryData(['user', userId]);
      
      // 낙관적 업데이트
      queryClient.setQueryData(['user', userId], old => ({
        ...old,
        ...updates
      }));
      
      return { previousUser };
    },
    
    // 에러 시 롤백
    onError: (err, updates, context) => {
      if (context?.previousUser) {
        queryClient.setQueryData(
          ['user', userId], 
          context.previousUser
        );
      }
      console.error('업데이트 실패:', err);
    },
    
    // 성공 시 재검증
    onSettled: () => {
      queryClient.invalidateQueries({ queryKey: ['user', userId] });
    }
  });
  
  if (isLoading) return <div>로딩 중...</div>;
  if (isError) return <div>에러: {error.message}</div>;
  
  return (
    <div>
      <h1>{user.fullName}</h1>
      <button 
        onClick={() => refetch()}
        disabled={isRefetching}
      >
        {isRefetching ? '새로고침 중...' : '새로고침'}
      </button>
      
      <button
        onClick={() => updateUserMutation.mutate({ name: 'New Name' })}
        disabled={updateUserMutation.isPending}
      >
        {updateUserMutation.isPending ? '업데이트 중...' : '이름 변경'}
      </button>
    </div>
  );
}

TanStack Query의 고급 기능

// 1. 의존적 쿼리
function UserWithPosts({ userId }: { userId: string }) {
  // 첫 번째 쿼리: 사용자 정보
  const userQuery = useQuery({
    queryKey: ['user', userId],
    queryFn: () => fetchUser(userId)
  });
  
  // 두 번째 쿼리: 사용자의 포스트 (user 데이터가 있을 때만 실행)
  const postsQuery = useQuery({
    queryKey: ['posts', userId],
    queryFn: () => fetchUserPosts(userId),
    enabled: !!userQuery.data // 의존성
  });
  
  if (userQuery.isLoading) return <div>사용자 로딩 중...</div>;
  if (postsQuery.isLoading) return <div>포스트 로딩 중...</div>;
  
  return (
    <div>
      <h1>{userQuery.data.name}의 포스트</h1>
      {postsQuery.data.map(post => (
        <PostCard key={post.id} post={post} />
      ))}
    </div>
  );
}

// 2. 병렬 쿼리
function Dashboard() {
  const queries = useQueries({
    queries: [
      { 
        queryKey: ['stats'], 
        queryFn: fetchStats,
        staleTime: 5 * 60 * 1000 // 5분
      },
      { 
        queryKey: ['users'], 
        queryFn: fetchUsers,
        staleTime: 60 * 1000 // 1분
      },
      { 
        queryKey: ['activities'], 
        queryFn: fetchActivities,
        staleTime: 30 * 1000 // 30초
      }
    ]
  });
  
  const isLoading = queries.some(q => q.isLoading);
  const isError = queries.some(q => q.isError);
  
  if (isLoading) return <div>대시보드 로딩 중...</div>;
  if (isError) return <div>일부 데이터 로드 실패</div>;
  
  const [stats, users, activities] = queries.map(q => q.data);
  
  return (
    <div>
      <StatsWidget data={stats} />
      <UsersTable users={users} />
      <ActivityFeed activities={activities} />
    </div>
  );
}

// 3. 무한 쿼리
function InfinitePosts() {
  const {
    data,
    fetchNextPage,
    hasNextPage,
    isFetchingNextPage,
    isLoading,
    isError
  } = useInfiniteQuery({
    queryKey: ['posts', 'infinite'],
    queryFn: async ({ pageParam = 0 }) => {
      const response = await fetch(
        `/api/posts?offset=${pageParam}&limit=20`
      );
      return response.json();
    },
    getNextPageParam: (lastPage, pages) => {
      // 다음 페이지 파라미터 계산
      if (lastPage.length < 20) return undefined;
      return pages.length * 20;
    },
    initialPageParam: 0
  });
  
  if (isLoading) return <div>로딩 중...</div>;
  if (isError) return <div>에러 발생</div>;
  
  return (
    <div>
      {data.pages.map((page, i) => (
        <div key={i}>
          {page.map(post => (
            <PostCard key={post.id} post={post} />
          ))}
        </div>
      ))}
      
      <button
        onClick={() => fetchNextPage()}
        disabled={!hasNextPage || isFetchingNextPage}
      >
        {isFetchingNextPage 
          ? '로딩 중...' 
          : hasNextPage 
          ? '더 보기' 
          : '더 이상 없습니다'}
      </button>
    </div>
  );
}

TanStack Query의 장점:

  • 가장 강력한 캐싱 시스템
  • 의존적/병렬 쿼리
  • 무한 스크롤 내장
  • DevTools 제공
  • SSR/SSG 지원

TanStack Query의 단점:

  • 큰 번들 사이즈 (23.4KB)
  • 학습 곡선이 가파름
  • 단순한 요청엔 과도함

성능 비교와 선택 가이드

번들 사이즈 비교

// 각 라이브러리의 번들 사이즈 영향
const bundleSizes = {
  'fetch': {
    size: '0 KB',
    gzipped: '0 KB',
    notes: '브라우저 내장, 추가 다운로드 없음'
  },
  'axios': {
    size: '15.4 KB',
    gzipped: '5.9 KB',
    notes: '인터셉터, 취소 토큰 등 모든 기능 포함'
  },
  'ky': {
    size: '9.7 KB',
    gzipped: '3.3 KB',
    notes: 'fetch 기반, 핵심 기능만 포함'
  },
  'swr': {
    size: '12.1 KB',
    gzipped: '4.5 KB',
    notes: '캐싱, 재검증 로직 포함'
  },
  'tanstack-query': {
    size: '23.4 KB',
    gzipped: '8.2 KB',
    notes: '전체 기능 포함 (캐싱, 무한 스크롤 등)'
  }
};

// 초기 로딩 시간 영향 (3G 네트워크 기준)
// fetch: 0ms
// ky: ~100ms
// swr: ~150ms
// axios: ~200ms
// tanstack-query: ~300ms

선택 가이드: 언제 무엇을 사용할까?

// 1. SSR/SSG 전용 (Next.js Server Components)
// 추천: Native fetch() with Next.js 확장
async function ServerComponent() {
  // Next.js의 캐싱과 재검증 활용
  const data = await fetch('/api/data', {
    next: { revalidate: 3600 }
  });
  
  return <div>{/* 서버에서 렌더링 */}</div>;
}

// 2. 간단한 클라이언트 요청
// 추천: fetch() 또는 Ky
function SimpleComponent() {
  const handleClick = async () => {
    // fetch로 충분
    const response = await fetch('/api/action');
    const data = await response.json();
    
    // 또는 Ky로 더 간편하게
    const data = await ky.post('/api/action').json();
  };
}

// 3. 복잡한 상태 관리가 필요한 경우
// 추천: TanStack Query
function ComplexDataComponent() {
  const { data, isLoading } = useQuery({
    queryKey: ['complex-data'],
    queryFn: fetchComplexData,
    staleTime: 5 * 60 * 1000,
    gcTime: 10 * 60 * 1000
  });
}

// 4. 실시간 데이터 동기화
// 추천: SWR
function RealtimeComponent() {
  const { data } = useSWR('/api/realtime', fetcher, {
    refreshInterval: 1000, // 1초마다 갱신
    revalidateOnFocus: true
  });
}

// 5. 엔터프라이즈 애플리케이션
// 추천: Axios (인터셉터와 에러 처리가 중요)
const enterpriseAPI = axios.create({
  baseURL: process.env.API_URL,
  timeout: 30000
});

// 복잡한 인증, 로깅, 에러 처리
enterpriseAPI.interceptors.request.use(authInterceptor);
enterpriseAPI.interceptors.response.use(loggingInterceptor);

의사결정 트리

프로젝트 시작
    │
    ├─ Server-side only? (SSR/SSG)
    │   └─ fetch() with Next.js
    │
    ├─ 간단한 요청? (1-2개 엔드포인트)
    │   ├─ 번들 크기 중요?
    │   │   └─ fetch()
    │   └─ 개발 편의성 중요?
    │       └─ Ky
    │
    ├─ 캐싱/재검증 필요?
    │   ├─ 실시간 동기화 중요?
    │   │   └─ SWR
    │   └─ 복잡한 쿼리 관리?
    │       └─ TanStack Query
    │
    └─ 엔터프라이즈/레거시?
        └─ Axios

마이그레이션 전략

Axios에서 fetch()로 마이그레이션

// 기존 Axios 코드를 fetch로 변환하는 어댑터
class AxiosToFetchAdapter {
  private baseURL: string;
  
  constructor(config: { baseURL: string }) {
    this.baseURL = config.baseURL;
  }
  
  // Axios 스타일 API를 fetch로 구현
  async request(config: any) {
    const url = this.baseURL + config.url;
    
    const response = await fetch(url, {
      method: config.method,
      headers: config.headers,
      body: config.data ? JSON.stringify(config.data) : undefined
    });
    
    if (!response.ok) {
      // Axios 스타일 에러 객체
      const error: any = new Error(`HTTP ${response.status}`);
      error.response = {
        status: response.status,
        data: await response.json()
      };
      throw error;
    }
    
    return {
      data: await response.json(),
      status: response.status,
      headers: response.headers
    };
  }
  
  get(url: string, config?: any) {
    return this.request({ ...config, method: 'GET', url });
  }
  
  post(url: string, data?: any, config?: any) {
    return this.request({ ...config, method: 'POST', url, data });
  }
}

// 점진적 마이그레이션
const api = process.env.USE_FETCH 
  ? new AxiosToFetchAdapter({ baseURL: 'https://api.example.com' })
  : axios.create({ baseURL: 'https://api.example.com' });

// 기존 코드가 그대로 작동
const response = await api.get('/users');
const data = response.data;

결론: 현명한 선택을 위한 체크리스트

HTTP 클라이언트 선택은 프로젝트의 성공에 중요한 영향을 미칩니다. 다음 체크리스트를 통해 최적의 선택을 하세요:

fetch()를 선택하세요:

  • Next.js Server Components를 사용 중
  • 번들 사이즈가 매우 중요
  • 간단한 요청만 필요
  • 표준을 중시

Axios를 선택하세요:

  • 복잡한 인터셉터 로직 필요
  • 파일 업로드 진행률 추적
  • 레거시 브라우저 지원
  • Node.js와 브라우저 모두 사용

Ky를 선택하세요:

  • fetch()의 단점만 보완하고 싶음
  • 작은 번들 사이즈 선호
  • 현대적인 브라우저만 지원
  • TypeScript 프로젝트

SWR을 선택하세요:

  • React 프로젝트
  • 실시간 데이터 동기화
  • 간단한 캐싱 필요
  • Vercel 생태계

TanStack Query를 선택하세요:

  • 복잡한 데이터 관리
  • 무한 스크롤 구현
  • 의존적 쿼리 필요
  • 강력한 DevTools 필요

기억하세요: 과도한 기능은 복잡성과 번들 사이즈를 증가시킵니다. 프로젝트의 실제 필요에 맞는 도구를 선택하는 것이 가장 중요합니다.




댓글 남기기