← 블로그 목록
ReactAPI 캐싱SWRReact Query성능 최적화

API 응답 캐싱: SWR vs React Query vs 직접 구현, Cursor로 만드는 웹앱에서 뭘 고를까

API 응답 캐싱을 직접 구현했을 때의 문제점과 SWR, React Query의 실제 이득을 비교. Cursor로 웹앱을 만들 때 어떤 선택이 최적인지 알아본다.

Vibeollio 팀-

캐싱 없이 API를 부르면 뭐가 문제일까

많은 개발자들이 useEffect와 useState만으로 API 데이터를 관리하려고 한다. 간단해 보이니까. 하지만 실제로는 몇 줄의 코드로 끝나지 않는다.

function UserProfile({ userId }) {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState(null);

  useEffect(() => {
    setLoading(true);
    fetch(`/api/users/${userId}`)
      .then(res => res.json())
      .then(data => setUser(data))
      .catch(err => setError(err))
      .finally(() => setLoading(false));
  }, [userId]);

  if (loading) return <div>로딩 중...</div>;
  if (error) return <div>오류 발생</div>;
  return <div>{user.name}</div>;
}

이 코드는 언뜻 동작하지만, 실무에서는 여러 문제를 만난다. 같은 사용자 정보를 여러 컴포넌트에서 요청하면 중복 API 호출이 발생한다. 탭을 왔다갔다 하면서 같은 데이터를 다시 요청한다. 네트워크가 느리면 로딩 상태가 깜빡인다. 이런 것들을 하나하나 손으로 처리하려면 코드가 복잡해진다.

직접 구현할 때 마주치는 현실적 문제들

캐싱을 직접 만들려고 하면 어떻게 될까. 많은 개발자들이 이런 식으로 시작한다.

const cache = {};

function fetchUser(userId) {
  if (cache[userId]) {
    return Promise.resolve(cache[userId]);
  }
  return fetch(`/api/users/${userId}`)
    .then(res => res.json())
    .then(data => {
      cache[userId] = data;
      return data;
    });
}

이것도 충분하지 않다. 캐시 만료 시간은 어떻게 관리할까? 사용자 정보가 5분 후 업데이트되어야 한다면? 타이머를 직접 관리해야 한다. 동시 요청은? 같은 데이터를 두 개의 useEffect가 동시에 요청하면 API가 두 번 불린다. 이를 막으려면 요청 상태를 추적하는 맵을 따로 만들어야 한다. 에러 재시도는? 네트워크 오류 후 자동으로 다시 시도하려면? 백그라운드 리페칭은? 사용자가 창을 다시 활성화했을 때 자동으로 최신 데이터를 가져오려면?

이 모든 것을 직접 구현하면 상당히 복잡한 로직이 된다. 그리고 버그를 찾기는 더 어렵다.

SWR: 간단함을 추구하는 라이브러리

SWR(stale-while-revalidate)은 Vercel에서 만들었고, "간단한 캐싱"에 집중한다.

import useSWR from 'swr';

function UserProfile({ userId }) {
  const { data: user, error, isLoading } = useSWR(
    `/api/users/${userId}`,
    url => fetch(url).then(res => res.json())
  );

  if (isLoading) return <div>로딩 중...</div>;
  if (error) return <div>오류 발생</div>;
  return <div>{user.name}</div>;
}

이게 전부다. SWR은 자동으로:

  • 같은 URL에 대한 중복 요청을 합친다 (짧은 시간 안에 여러 컴포넌트가 요청해도 API는 한 번만 불린다)
  • 캐시를 유지한다 (캐시된 데이터를 먼저 보여주고, 뒤에서 최신 데이터로 다시 검증한다)
  • 창을 다시 활성화하면 자동으로 리페칭한다
  • 포커스 복구 후 데이터를 다시 검증한다

장점: 설정이 거의 필요 없다. 코드가 짧고 직관적이다.

단점: 복잡한 상태 관리가 필요하면 한계가 있다. 여러 쿼리를 조합하거나 mutation·무효화 흐름이 복잡해지면 React Query 쪽이 제공하는 기능이 더 풍부하다.

React Query: 엔터프라이즈급 제어

React Query(현재는 TanStack Query)는 더 강력한 기능을 제공한다.

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

function UserProfile({ userId }) {
  const { data: user, error, isLoading } = useQuery({
    queryKey: ['users', userId],
    queryFn: () => fetch(`/api/users/${userId}`).then(res => res.json()),
  });

  if (isLoading) return <div>로딩 중...</div>;
  if (error) return <div>오류 발생</div>;
  return <div>{user.name}</div>;
}

기본 사용법은 SWR과 비슷하지만, 훨씬 더 세밀한 제어가 가능하다.

// 특정 쿼리만 무효화
const queryClient = useQueryClient();
queryClient.invalidateQueries({ queryKey: ['users', userId] });

// 캐시 시간 설정
const { data } = useQuery({
  queryKey: ['users', userId],
  queryFn: () => fetch(...),
  staleTime: 1000 * 60 * 10, // 10분
  gcTime: 1000 * 60 * 5, // 가비지 컬렉션 5분
});

// 여러 쿼리 조합
const user = useQuery({ queryKey: ['user', id], ... });
const posts = useQuery({ queryKey: ['posts', id], ... });
const combined = { user: user.data, posts: posts.data };

장점: 복잡한 데이터 흐름을 세밀하게 제어할 수 있다. 낙관적 업데이트(optimistic update)도 쉽다. 디버깅 도구가 풍부하다.

단점: 학습 곡선이 가파르다. 초기 설정이 복잡할 수 있다. 번들 크기가 SWR보다 크다.

Cursor로 웹앱을 만들 때 선택 기준

Cursor 같은 AI 코드 에디터를 쓸 때는 선택이 더 중요하다. 왜냐하면 AI가 생성하는 코드의 패턴이 라이브러리 선택에 따라 크게 달라지기 때문이다.

SWR을 고르는 경우:

  • 프로토타입이나 소규모 앱을 빠르게 만들 때
  • 대부분의 API 호출이 단순한 GET 요청일 때
  • 팀의 React 경험이 적을 때
  • 번들 크기를 최소화해야 할 때

Cursor에게 "SWR로 사용자 목록을 가져와"라고 하면, 대부분 깔끔한 코드를 생성한다.

React Query를 고르는 경우:

  • 복잡한 데이터 흐름이 있을 때 (예: 필터링된 목록, 정렬, 페이지네이션)
  • POST/PUT/DELETE 요청을 많이 다룰 때
  • 낙관적 업데이트가 필요할 때
  • 여러 엔드포인트의 데이터를 조합해야 할 때

Cursor에게 "React Query로 사용자 생성 폼을 만들고, 성공 후 목록을 새로고침해"라고 하면, 제대로 된 useMutation과 queryClient.invalidateQueries를 자동으로 포함한다.

성능 비교: 실제로는 얼마나 다를까

직접 구현 vs SWR vs React Query의 실제 성능 차이는 상황에 따라 다르다.

  • 초기 로드: 거의 같다 (네트워크가 병목이므로)
  • 중복 요청 방지: 직접 구현이 없으면 여러 번 불린다. SWR과 React Query는 자동으로 합친다
  • 캐시 히트: 모두 빠르다 (메모리에서 읽음)
  • 메모리 관리: React Query는 gcTime으로 사용하지 않는 캐시를 정리하고, 직접 구현은 정리 로직을 직접 만들어야 한다
  • 번들 크기: 직접 구현 < SWR < React Query 순으로 커진다 (정확한 크기는 버전마다 다르다)

작은 앱이라면 차이는 무시할 수 있다. 하지만 사용자가 많거나 복잡한 상호작용이 있으면, 라이브러리를 쓰는 것이 명확히 유리하다.

어떤 라이브러리를 고를까요?

초보자나 빠른 프로토타입이라면 SWR을 추천한다. 코드가 짧고, 에러 처리와 캐싱이 자동이며, 대부분의 경우 충분하다.

복잡한 폼, 다중 엔드포인트, 낙관적 업데이트가 필요하면 React Query를 쓴다. 처음엔 어렵지만, 한 번 익히면 코드가 더 명확해진다.

가능하면 직접 구현하지 말자. 캐싱 로직은 생각보다 복잡하고, 유지보수 비용이 크다. 라이브러리는 이미 많은 프로젝트에서 검증됐다.

FAQ

SWR과 React Query를 동시에 쓸 수 있나요?

기술적으로는 가능하지만, 같은 데이터를 두 라이브러리가 각각 캐싱하면 메모리 낭비가 되고 동기화 문제가 생긴다. 한 프로젝트에서는 하나만 선택하는 것이 낫다.

Cursor가 생성한 코드가 이 라이브러리들을 쓰지 않으면 어떻게 하나요?

Cursor에게 명확히 지시하면 된다. "SWR을 사용해서 이 기능을 만들어"라고 하거나, 프롬프트에 "React Query의 useQuery와 useMutation을 사용해"라고 쓴다. 최신 AI 에디터들은 이런 지시를 잘 따른다.

캐시 시간을 얼마로 설정해야 하나요?

데이터의 변경 빈도에 따라 다르다. 사용자 프로필처럼 자주 바뀌지 않으면 510분, 실시간 데이터처럼 자주 바뀌면 1030초, 매우 자주 바뀌면 캐싱 자체를 하지 않는 것이 낫다. SWR은 캐시된 데이터를 먼저 보여주고 다시 검증하는 방식이고, React Query는 staleTime을 따로 지정하지 않으면 데이터를 곧바로 오래된 것으로 취급해 필요할 때 다시 가져온다. 둘 다 옵션으로 조절할 수 있다.

결론: 라이브러리로 시간을 아껴라

API 캐싱은 모던 웹앱의 필수 요소다. 직접 구현하면 복잡한 코드가 되지만, SWR이나 React Query를 쓰면 훅 하나로 해결된다. Cursor 같은 AI 코드 에디터를 사용한다면 더욱 그렇다. 라이브러리가 제공하는 기능(중복 요청 제거, 자동 리페칭, 에러 처리)은 직접 구현하기 너무 번거롭다.

작은 프로젝트는 SWR로 시작하고, 복잡해지면 React Query로 마이그레이션하는 것도 좋은 전략이다. 두 라이브러리 모두 API 기반 설계가 비슷해서 전환이 어렵지 않다.

당신이 만든 웹앱을 Vibeollio에 프로젝트로 등록하면, 실제 사용자들이 직접 체험하고 피드백을 남길 수 있다. SWR이나 React Query로 만든 데이터 페칭 기능이 얼마나 부드럽게 동작하는지 보여줄 좋은 기회다.