← 목록으로

프론트엔드 API 통신

프론트엔드 API 통신 완벽 가이드: Fetch부터 React Query까지

서론

프론트엔드 개발에서 백엔드 API와 통신하는 것은 핵심 기능입니다. 데이터를 가져오고, 생성하고, 수정하고, 삭제하는 모든 작업이 API 통신을 통해 이루어집니다. 이 글에서는 네이티브 Fetch API부터 Axios, React Query까지 다양한 방법으로 API를 호출하고 데이터를 관리하는 방법을 다루겠습니다.

1. Fetch API 기본 사용법

1.1 기본 GET 요청

// 기본 fetch 사용
async function fetchUsers() {
  const response = await fetch('https://api.example.com/users');
  const data = await response.json();
  return data;
}

// 에러 처리 포함
async function fetchUsersWithErrorHandling() {
  try {
    const response = await fetch('https://api.example.com/users');
    
    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;
  }
}

1.2 POST 요청

async function createUser(userData) {
  const response = await fetch('https://api.example.com/users', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
    },
    body: JSON.stringify(userData),
  });

  if (!response.ok) {
    throw new Error('Failed to create user');
  }

  return await response.json();
}

// 사용 예시
const newUser = await createUser({
  name: 'John Doe',
  email: 'john@example.com',
});

1.3 헤더와 인증

async function fetchProtectedData(token) {
  const response = await fetch('https://api.example.com/protected', {
    headers: {
      'Authorization': `Bearer ${token}`,
      'Content-Type': 'application/json',
    },
  });

  return await response.json();
}

2. Axios 라이브러리

2.1 설치 및 기본 사용

npm install axios
import axios from 'axios';

// 기본 GET 요청
const response = await axios.get('https://api.example.com/users');
console.log(response.data);

// POST 요청
const newUser = await axios.post('https://api.example.com/users', {
  name: 'John Doe',
  email: 'john@example.com',
});

// 헤더 설정
const response = await axios.get('https://api.example.com/users', {
  headers: {
    'Authorization': `Bearer ${token}`,
  },
});

2.2 Axios 인스턴스 생성

// lib/api.js
import axios from 'axios';

const api = axios.create({
  baseURL: 'https://api.example.com',
  timeout: 10000,
  headers: {
    'Content-Type': 'application/json',
  },
});

// 요청 인터셉터
api.interceptors.request.use(
  (config) => {
    const token = localStorage.getItem('token');
    if (token) {
      config.headers.Authorization = `Bearer ${token}`;
    }
    return config;
  },
  (error) => {
    return Promise.reject(error);
  }
);

// 응답 인터셉터
api.interceptors.response.use(
  (response) => response,
  (error) => {
    if (error.response?.status === 401) {
      // 토큰 만료 처리
      localStorage.removeItem('token');
      window.location.href = '/login';
    }
    return Promise.reject(error);
  }
);

export default api;

2.3 Axios 사용 예제

import api from '@/lib/api';

// GET 요청
export async function getUsers() {
  const response = await api.get('/users');
  return response.data;
}

// POST 요청
export async function createUser(userData) {
  const response = await api.post('/users', userData);
  return response.data;
}

// PUT 요청
export async function updateUser(id, userData) {
  const response = await api.put(`/users/${id}`, userData);
  return response.data;
}

// DELETE 요청
export async function deleteUser(id) {
  const response = await api.delete(`/users/${id}`);
  return response.data;
}

3. React에서 API 호출

3.1 useState와 useEffect 사용

'use client';

import { useState, useEffect } from 'react';

export default function UserList() {
  const [users, setUsers] = useState([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    async function fetchUsers() {
      try {
        setLoading(true);
        const response = await fetch('/api/users');
        if (!response.ok) {
          throw new Error('Failed to fetch users');
        }
        const data = await response.json();
        setUsers(data);
      } catch (err) {
        setError(err.message);
      } finally {
        setLoading(false);
      }
    }

    fetchUsers();
  }, []);

  if (loading) return <div>로딩 중...</div>;
  if (error) return <div>에러: {error}</div>;

  return (
    <ul>
      {users.map((user) => (
        <li key={user.id}>{user.name}</li>
      ))}
    </ul>
  );
}

3.2 커스텀 훅으로 분리

// hooks/useUsers.js
import { useState, useEffect } from 'react';

export function useUsers() {
  const [users, setUsers] = useState([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    async function fetchUsers() {
      try {
        setLoading(true);
        const response = await fetch('/api/users');
        if (!response.ok) throw new Error('Failed to fetch');
        const data = await response.json();
        setUsers(data);
      } catch (err) {
        setError(err.message);
      } finally {
        setLoading(false);
      }
    }

    fetchUsers();
  }, []);

  return { users, loading, error };
}

// 컴포넌트에서 사용
'use client';

import { useUsers } from '@/hooks/useUsers';

export default function UserList() {
  const { users, loading, error } = useUsers();

  if (loading) return <div>로딩 중...</div>;
  if (error) return <div>에러: {error}</div>;

  return (
    <ul>
      {users.map((user) => (
        <li key={user.id}>{user.name}</li>
      ))}
    </ul>
  );
}

4. React Query (TanStack Query)

4.1 설치 및 설정

npm install @tanstack/react-query
// app/providers.js
'use client';

import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 60 * 1000, // 1분
      cacheTime: 5 * 60 * 1000, // 5분
    },
  },
});

export function Providers({ children }) {
  return (
    <QueryClientProvider client={queryClient}>
      {children}
      <ReactQueryDevtools initialIsOpen={false} />
    </QueryClientProvider>
  );
}

4.2 useQuery 사용

'use client';

import { useQuery } from '@tanstack/react-query';

async function fetchUsers() {
  const response = await fetch('/api/users');
  if (!response.ok) throw new Error('Failed to fetch');
  return response.json();
}

export default function UserList() {
  const { data, isLoading, error, refetch } = useQuery({
    queryKey: ['users'],
    queryFn: fetchUsers,
  });

  if (isLoading) return <div>로딩 중...</div>;
  if (error) return <div>에러: {error.message}</div>;

  return (
    <div>
      <button onClick={() => refetch()}>새로고침</button>
      <ul>
        {data?.map((user) => (
          <li key={user.id}>{user.name}</li>
        ))}
      </ul>
    </div>
  );
}

4.3 useMutation 사용

'use client';

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

async function createUser(userData) {
  const response = await fetch('/api/users', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(userData),
  });
  if (!response.ok) throw new Error('Failed to create');
  return response.json();
}

export default function CreateUserForm() {
  const queryClient = useQueryClient();

  const mutation = useMutation({
    mutationFn: createUser,
    onSuccess: () => {
      // 사용자 목록 캐시 무효화
      queryClient.invalidateQueries({ queryKey: ['users'] });
    },
  });

  const handleSubmit = (e) => {
    e.preventDefault();
    const formData = new FormData(e.target);
    mutation.mutate({
      name: formData.get('name'),
      email: formData.get('email'),
    });
  };

  return (
    <form onSubmit={handleSubmit}>
      <input name="name" placeholder="이름" required />
      <input name="email" type="email" placeholder="이메일" required />
      <button type="submit" disabled={mutation.isPending}>
        {mutation.isPending ? '생성 중...' : '생성'}
      </button>
      {mutation.isError && <p>에러: {mutation.error.message}</p>}
    </form>
  );
}

5. Next.js에서의 API 호출

5.1 서버 컴포넌트에서 API 호출

// app/posts/page.js (서버 컴포넌트)
async function getPosts() {
  const res = await fetch('https://api.example.com/posts', {
    cache: 'no-store', // 항상 최신 데이터
  });
  return res.json();
}

export default async function PostsPage() {
  const posts = await getPosts();

  return (
    <div>
      {posts.map((post) => (
        <article key={post.id}>
          <h2>{post.title}</h2>
          <p>{post.content}</p>
        </article>
      ))}
    </div>
  );
}

5.2 클라이언트 컴포넌트에서 API 호출

'use client';

import { useEffect, useState } from 'react';

export default function ClientPosts() {
  const [posts, setPosts] = useState([]);

  useEffect(() => {
    async function fetchPosts() {
      const res = await fetch('/api/posts');
      const data = await res.json();
      setPosts(data);
    }
    fetchPosts();
  }, []);

  return (
    <div>
      {posts.map((post) => (
        <article key={post.id}>
          <h2>{post.title}</h2>
        </article>
      ))}
    </div>
  );
}

5.3 Server Actions 사용 (App Router)

// app/actions.js
'use server';

export async function createPost(formData) {
  const title = formData.get('title');
  const content = formData.get('content');

  const response = await fetch('https://api.example.com/posts', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ title, content }),
  });

  return await response.json();
}

// 컴포넌트에서 사용
import { createPost } from './actions';

export default function PostForm() {
  async function handleSubmit(formData) {
    'use server';
    await createPost(formData);
  }

  return (
    <form action={handleSubmit}>
      <input name="title" required />
      <textarea name="content" required />
      <button type="submit">작성</button>
    </form>
  );
}

6. 에러 처리 패턴

6.1 통합 에러 처리

// lib/api-client.js
class ApiError extends Error {
  constructor(message, status) {
    super(message);
    this.status = status;
  }
}

async function apiRequest(url, options = {}) {
  try {
    const response = await fetch(url, {
      ...options,
      headers: {
        'Content-Type': 'application/json',
        ...options.headers,
      },
    });

    if (!response.ok) {
      throw new ApiError(
        `API Error: ${response.statusText}`,
        response.status
      );
    }

    return await response.json();
  } catch (error) {
    if (error instanceof ApiError) {
      throw error;
    }
    throw new ApiError('Network error', 0);
  }
}

export default apiRequest;

6.2 에러 바운더리와 함께 사용

'use client';

import { ErrorBoundary } from 'react-error-boundary';

function ErrorFallback({ error, resetErrorBoundary }) {
  return (
    <div role="alert">
      <h2>문제가 발생했습니다</h2>
      <pre>{error.message}</pre>
      <button onClick={resetErrorBoundary}>다시 시도</button>
    </div>
  );
}

export default function UserListWithErrorBoundary() {
  return (
    <ErrorBoundary FallbackComponent={ErrorFallback}>
      <UserList />
    </ErrorBoundary>
  );
}

7. 로딩 상태 관리

7.1 Suspense와 함께 사용

// app/posts/page.js
import { Suspense } from 'react';

async function Posts() {
  const posts = await fetch('https://api.example.com/posts').then(res => res.json());
  return (
    <div>
      {posts.map(post => (
        <article key={post.id}>{post.title}</article>
      ))}
    </div>
  );
}

export default function PostsPage() {
  return (
    <Suspense fallback={<div>로딩 중...</div>}>
      <Posts />
    </Suspense>
  );
}

7.2 로딩 스켈레톤

'use client';

import { useState, useEffect } from 'react';

function SkeletonLoader() {
  return (
    <div className="animate-pulse space-y-4">
      {[1, 2, 3].map((i) => (
        <div key={i} className="h-20 bg-gray-200 rounded" />
      ))}
    </div>
  );
}

export default function UserList() {
  const [users, setUsers] = useState([]);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    fetch('/api/users')
      .then(res => res.json())
      .then(data => {
        setUsers(data);
        setLoading(false);
      });
  }, []);

  if (loading) return <SkeletonLoader />;

  return (
    <ul>
      {users.map(user => (
        <li key={user.id}>{user.name}</li>
      ))}
    </ul>
  );
}

8. 실전 예제: 완전한 CRUD 구현

8.1 React Query를 사용한 CRUD

'use client';

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

// 조회
function useUsers() {
  return useQuery({
    queryKey: ['users'],
    queryFn: () => api.get('/users').then(res => res.data),
  });
}

// 생성
function useCreateUser() {
  const queryClient = useQueryClient();
  
  return useMutation({
    mutationFn: (userData) => api.post('/users', userData).then(res => res.data),
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ['users'] });
    },
  });
}

// 수정
function useUpdateUser() {
  const queryClient = useQueryClient();
  
  return useMutation({
    mutationFn: ({ id, ...data }) => 
      api.put(`/users/${id}`, data).then(res => res.data),
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ['users'] });
    },
  });
}

// 삭제
function useDeleteUser() {
  const queryClient = useQueryClient();
  
  return useMutation({
    mutationFn: (id) => api.delete(`/users/${id}`),
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ['users'] });
    },
  });
}

// 컴포넌트
export default function UserManagement() {
  const { data: users, isLoading } = useUsers();
  const createUser = useCreateUser();
  const updateUser = useUpdateUser();
  const deleteUser = useDeleteUser();

  const handleCreate = () => {
    createUser.mutate({ name: 'New User', email: 'new@example.com' });
  };

  const handleUpdate = (id) => {
    updateUser.mutate({ id, name: 'Updated Name' });
  };

  const handleDelete = (id) => {
    if (confirm('정말 삭제하시겠습니까?')) {
      deleteUser.mutate(id);
    }
  };

  if (isLoading) return <div>로딩 중...</div>;

  return (
    <div>
      <button onClick={handleCreate}>사용자 추가</button>
      <ul>
        {users?.map(user => (
          <li key={user.id}>
            {user.name}
            <button onClick={() => handleUpdate(user.id)}>수정</button>
            <button onClick={() => handleDelete(user.id)}>삭제</button>
          </li>
        ))}
      </ul>
    </div>
  );
}

9. 성능 최적화

9.1 요청 취소

'use client';

import { useEffect, useRef } from 'react';

export default function SearchComponent() {
  const abortControllerRef = useRef(null);

  const handleSearch = async (query) => {
    // 이전 요청 취소
    if (abortControllerRef.current) {
      abortControllerRef.current.abort();
    }

    // 새 요청
    abortControllerRef.current = new AbortController();
    
    try {
      const response = await fetch(`/api/search?q=${query}`, {
        signal: abortControllerRef.current.signal,
      });
      const data = await response.json();
      // 결과 처리
    } catch (error) {
      if (error.name !== 'AbortError') {
        console.error(error);
      }
    }
  };

  useEffect(() => {
    return () => {
      // 컴포넌트 언마운트 시 요청 취소
      if (abortControllerRef.current) {
        abortControllerRef.current.abort();
      }
    };
  }, []);
}

9.2 디바운싱과 쓰로틀링

'use client';

import { useState, useEffect } from 'react';

function useDebounce(value, delay) {
  const [debouncedValue, setDebouncedValue] = useState(value);

  useEffect(() => {
    const handler = setTimeout(() => {
      setDebouncedValue(value);
    }, delay);

    return () => {
      clearTimeout(handler);
    };
  }, [value, delay]);

  return debouncedValue;
}

export default function SearchInput() {
  const [query, setQuery] = useState('');
  const debouncedQuery = useDebounce(query, 500);

  useEffect(() => {
    if (debouncedQuery) {
      fetch(`/api/search?q=${debouncedQuery}`)
        .then(res => res.json())
        .then(data => {
          // 검색 결과 처리
        });
    }
  }, [debouncedQuery]);

  return (
    <input
      value={query}
      onChange={(e) => setQuery(e.target.value)}
      placeholder="검색..."
    />
  );
}

10. 결론

프론트엔드에서 API를 통신하는 방법은 다양하며, 각 방법은 고유한 장단점이 있습니다. Fetch API는 네이티브이지만 설정이 필요하고, Axios는 편리하지만 추가 번들이 필요합니다. React Query는 캐싱과 상태 관리를 자동화하여 복잡한 데이터 페칭을 단순화합니다.

선택 가이드:

  • 간단한 요청: Fetch API
  • 인터셉터와 자동 설정 필요: Axios
  • 복잡한 데이터 관리: React Query
  • 서버 사이드 렌더링: Next.js 서버 컴포넌트

프로젝트의 요구사항에 맞는 도구를 선택하고, 일관된 패턴을 유지하는 것이 중요합니다. 적절한 에러 처리와 로딩 상태 관리로 사용자 경험을 향상시킬 수 있습니다.