테마 전환

SWR 완벽 가이드: 캐시 전략과 낙관적 업데이트 실전 기법

Easton editorial illustration: island architecture model

화면에 익숙한 loading 표시가 빙글빙글 도는 모습을 바라보며 사용자 목록 페이지를 또 새로고침합니다. 탭을 바꿀 때도 loading, 다른 페이지에서 돌아올 때도 loading, 전화를 받느라 브라우저 포커스가 잠깐 벗어났다가 돌아와도 다시 loading입니다. 가장 답답한 점은 무엇일까요? 바로 1초 전에 불러온 데이터인데도 그렇다는 사실입니다.

React 프로젝트에서는 데이터를 가져올 때마다 useState와 useEffect를 쓰고 loading과 error 처리까지 붙여야 합니다. 기본 코드만 20줄입니다. 더 골치 아픈 건 여러 컴포넌트가 같은 데이터를 필요로 할 때입니다. 상태를 부모 컴포넌트로 끌어올려 코드를 더 쓰거나, 각 컴포넌트가 별도로 요청해 네트워크 자원을 낭비해야 합니다.

SWR을 처음 발견했을 때의 “이렇게도 할 수 있구나”라는 느낌은 수동으로 폴더를 복사하다가 처음 Git을 썼을 때와 비슷했습니다. 코드 세 줄로 문제의 80%가 해결됐습니다. 과장이 아닙니다.

오늘은 Vercel이 만든 React 데이터 가져오기 라이브러리 SWR을 살펴보겠습니다. 핵심은 stale-while-revalidate라는 한마디로 요약됩니다. 기술적으로 들리나요? 원리는 아주 간단합니다. 먼저 캐시에 있는 “예전 사진”을 보여 주고(빠름), 백그라운드에서 새 사진을 찍은 뒤(정확함), 다 찍으면 조용히 바꿉니다(자연스러움).

SWR이 필요한 이유: 기존 데이터 가져오기의 세 가지 문제

먼저 전통적인 React 데이터 가져오기를 살펴보겠습니다. 사용자 목록을 표시한다고 가정해 봅시다.

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

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

  if (loading) return <div>Loading...</div>;
  if (error) return <div>Error: {error.message}</div>;

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

목록 하나를 표시하는 데만 24줄입니다. 그래도 괜찮다고 생각할 수 있습니다. 그렇다면 다음 상황을 생각해 보세요.

문제 1: 지겨울 만큼 반복되는 보일러플레이트 코드

데이터가 필요한 컴포넌트마다 이 코드를 써야 합니다. 사용자 목록, 게시글 목록, 댓글 목록 모두 마찬가지입니다. 상태 세 개와 수많은 if 문, 오류 처리까지 반복합니다. 복사해서 붙여 넣기조차 민망할 정도입니다. 어느 중형 프로젝트에서 세어 보니 이런 보일러플레이트 코드만 전체의 20%를 차지했습니다.

문제 2: 캐시가 없다

가장 뼈아픈 문제는 캐시가 없다는 것입니다. 사용자가 홈에서 상세 페이지로 이동했다가 다시 홈으로 돌아오면 긴 loading을 또 봐야 합니다. 30초 전에 가져온 데이터도 다시 요청합니다. 사용자는 사이트가 느리다고 불평하지만 실제로는 API가 느린 게 아니라 캐시가 전혀 없는 것입니다.

전역 상태 관리를 직접 만들면 된다고요? 물론 가능합니다. 그러면 Redux/Zustand의 store, action, reducer를 관리해야 합니다. 원래 3분이면 끝날 기능이 30분짜리 아키텍처 설계로 변합니다.

문제 3: 여러 컴포넌트의 데이터 동기화가 악몽이다

여러 컴포넌트가 같은 데이터를 사용할 때는 더 복잡합니다. 상단 내비게이션, 사이드바, 메시지 목록 페이지가 모두 읽지 않은 메시지 수를 표시한다고 해 봅시다. 상태를 최상위까지 끌어올리면 업데이트마다 십여 단계에 걸쳐 props를 전달해야 합니다. Context를 쓰면 메시지가 갱신될 때마다 전체 트리가 re-render될 수 있습니다.

예전에 메시지 알림 기능을 수정하면서 로직은 단순한데도 상태 동기화에 이틀을 쓴 적이 있습니다. 커밋할 때 화면 가득 setState와 useEffect가 보였고 저조차 보기 힘들었습니다.

SWR이 인기를 얻은 이유가 바로 이것입니다. 또 하나의 바퀴를 재발명한 것이 아니라 실제 문제를 해결했습니다. 위 코드를 SWR로 다시 쓰면 어떨까요?

import useSWR from 'swr';

function UserList() {
  const { data, error, isLoading } = useSWR('/api/users', fetcher);

  if (isLoading) return <div>Loading...</div>;
  if (error) return <div>Error!</div>;

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

9줄이면 끝입니다. 캐시, 자동 revalidate, 컴포넌트 간 공유까지 기본으로 제공됩니다. 참고로 fetcher는 fetch 함수이며 보통 전역에서 한 번만 정의하면 됩니다.

const fetcher = url => fetch(url).then(r => r.json());

차이는 한눈에 드러납니다.

SWR의 핵심 개념: Stale-While-Revalidate 전략

SWR이라는 이름은 HTTP RFC 5861에 정의된 캐시 만료 전략에서 왔습니다. “RFC”라는 말에 겁먹을 필요는 없습니다. 원리는 매우 쉽습니다.

사진에 비유해 보겠습니다. 친구의 최근 사진을 보고 싶습니다. 기존 방식에서는 친구에게 전화해 사진을 찍어 보내 달라고 하고 기다립니다. SWR 방식에서는 지난번에 만났을 때 찍은 예전 사진을 먼저 꺼내 보고, 동시에 새 사진을 보내 달라고 메시지를 보냅니다. 새 사진이 도착하면 그때 교체합니다.

핵심 차이는 기다릴 필요가 없다는 것입니다. 조금 오래됐을 수는 있어도 콘텐츠를 즉시 보고, 결국에는 최신 콘텐츠를 보게 됩니다. 이것이 stale-while-revalidate, 즉 오래된 데이터를 보여 주는 동안 다시 검증하는 방식입니다.

SWR의 전체 동작 흐름:

  1. 1단계: 캐시를 즉시 반환(stale)

    • 컴포넌트가 마운트되면 SWR이 먼저 로컬 캐시를 확인합니다.
    • 캐시가 있으면 즉시 반환해 페이지가 바로 열립니다.
    • 캐시가 없으면 undefined를 반환하고 loading을 표시합니다.
  2. 2단계: 백그라운드 요청(revalidate)

    • 캐시 유무와 관계없이 API 요청을 보냅니다.
    • 사용자는 이미 콘텐츠를 보고 있으므로 요청을 인지하지 못합니다.
  3. 3단계: 데이터 업데이트

    • API 응답이 오면 캐시와 UI를 조용히 업데이트합니다.
    • 데이터가 바뀌면 React가 자동으로 re-render합니다.
    • 바뀌지 않았다면 아무 작업도 하지 않습니다.

주가 표시 페이지의 실제 예를 보겠습니다.

function StockPrice({ symbol }) {
  const { data, error } = useSWR(`/api/stock/${symbol}`, fetcher);

  return (
    <div>
      <h2>{symbol}</h2>
      <p>가격: {data ? `$${data.price}` : 'Loading...'}</p>
      <span>업데이트 시간: {data?.updatedAt}</span>
    </div>
  );
}

사용자가 처음 페이지를 열면 data가 undefined라서 “Loading…”이 표시됩니다. 1초 뒤 API 응답이 오면 가격이 나타납니다.

다른 탭에서 이메일을 확인하다가 10분 뒤 돌아오면 캐시된 가격이 즉시 표시되어 loading이 깜빡이지 않습니다. 동시에 SWR은 백그라운드에서 새 요청을 시작합니다. 주가가 바뀌면 페이지가 자동으로 갱신되고, 그대로라면 아무 변화도 없습니다.

매우 매끄러운 경험입니다.

자동 revalidation이 일어나는 세 가지 시점:

SWR은 기본적으로 다음 상황에서 데이터를 자동으로 다시 가져옵니다.

  1. 컴포넌트 재마운트(revalidateOnMount): 페이지를 새로고침하거나 라우트를 전환했다가 돌아올 때
  2. 창에 다시 포커스(revalidateOnFocus): 사용자가 브라우저 탭으로 돌아올 때
  3. 네트워크 복구(revalidateOnReconnect): 연결이 끊겼다가 다시 연결될 때

즉, 별도로 관리하지 않아도 SWR이 데이터의 신선도를 유지합니다. 사용자가 이메일을 30분 동안 보고 사이트로 돌아오면 SWR이 자동 새로고침합니다. 지하철에서 연결이 끊겼다가 밖으로 나와 4G가 복구되면 자동으로 다시 불러옵니다.

처음 이것들이 기본 동작이라는 사실을 알고 정말 놀랐습니다. 예전에는 이런 상황을 처리하지 않아 오래된 데이터를 보여 주거나, visibilitychange와 online 이벤트를 직접 감시하며 코드를 잔뜩 써야 했습니다. 이제는 기본으로 따라옵니다.

중요한 key 개념:

useSWR의 첫 번째 인수가 '/api/users' 같은 문자열이라는 점을 눈치챘을 것입니다. 이를 “key”라고 하며 매우 중요합니다.

// 두 컴포넌트에서 같은 key 사용
function Header() {
  const { data } = useSWR('/api/user', fetcher);
  return <div>환영합니다, {data?.name}</div>;
}

function Profile() {
  const { data } = useSWR('/api/user', fetcher);
  return <div>프로필: {data?.email}</div>;
}

key가 같으면 데이터도 공유됩니다. SWR은 API를 한 번만 요청하고 두 컴포넌트에 같은 데이터를 제공하며, 업데이트도 자동으로 동기화합니다.

이것이 “여러 컴포넌트의 데이터 동기화” 문제를 해결하는 방식입니다. 상태 관리도 Context도 필요 없이 key 하나면 됩니다.

캐시 전략 심화: 데이터 가져오기를 더 똑똑하게

앞에서 SWR이 자동으로 캐시하고 revalidate한다고 설명했습니다. 하지만 실제 프로젝트에서는 데이터마다 필요한 “신선도”가 다릅니다. 사용자 아바타는 일주일 동안 그대로일 수 있지만 주가는 초 단위로 변합니다. 이때 캐시 전략을 조정해야 합니다.

기본 동작은 똑똑하지만 만능은 아니다

SWR의 기본 설정은 꽤 적극적입니다.

  • 컴포넌트가 마운트될 때마다 revalidate
  • 창에 포커스가 올 때마다 revalidate
  • 네트워크가 복구될 때마다 revalidate

채팅 메시지나 온라인 상태처럼 실시간성이 중요한 데이터에는 완벽합니다. 하지만 게시글 목록이나 사용자 프로필처럼 비교적 안정적인 데이터에는 낭비일 수 있습니다. 사용자가 두 탭을 오갈 때마다 요청을 보낼 필요는 없습니다.

핵심 설정 옵션

SWR에는 다양한 설정이 있지만 실제로 자주 쓰는 것은 다음과 같습니다.

const { data } = useSWR('/api/articles', fetcher, {
  revalidateOnFocus: false,      // 창 포커스 시 재요청하지 않음
  revalidateOnReconnect: false,  // 네트워크 복구 시 재요청하지 않음
  refreshInterval: 0,            // 폴링 간격(ms), 0이면 폴링 안 함
  dedupingInterval: 2000,        // 2초 안의 같은 요청을 중복 제거
});

옵션 이름이 직관적이라 이름만 봐도 대체로 이해할 수 있습니다.

상황 1: 실시간 데이터(주가, 온라인 사용자 수)

const { data } = useSWR('/api/stock/AAPL', fetcher, {
  refreshInterval: 1000,  // 1초마다 폴링
  revalidateOnFocus: true // 페이지로 돌아오면 즉시 갱신
});

이런 데이터는 최신성이 매우 중요합니다. 폴링이 가장 간단한 방법입니다. 물론 WebSocket이 더 나은 선택일 수 있지만 그건 다른 주제입니다.

상황 2: 비교적 안정적인 데이터(사용자 정보, 게시글 목록)

const { data } = useSWR('/api/profile', fetcher, {
  revalidateOnFocus: false,     // 포커스마다 새로고침하지 않음
  refreshInterval: 0,           // 폴링하지 않음
  dedupingInterval: 60000,      // 1분 안에는 중복 요청하지 않음
});

사용자 프로필은 보통 자주 바뀌지 않습니다. 탭을 전환할 때 새로고침하지 않아 요청을 절약할 수 있습니다. 사용자가 프로필을 직접 수정했다면 나중에 설명할 mutate로 수동 업데이트하면 됩니다.

상황 3: 거의 정적인 콘텐츠(문서, 도움말 페이지)

const { data } = useSWR('/api/docs', fetcher, {
  revalidateOnFocus: false,
  revalidateOnReconnect: false,
  revalidateOnMount: false,     // 마운트할 때도 검증하지 않음
  revalidateIfStale: false,     // 데이터가 stale이어도 검증하지 않음
});

이런 데이터는 한 달 동안 바뀌지 않을 수도 있습니다. 최초 로드 후에는 사실상 영구 캐시됩니다. 사용자가 페이지를 직접 새로고침할 때만 다시 가져옵니다.

전역 설정과 로컬 설정

애플리케이션 전체의 전략이 비슷하다면 SWRConfig로 전역 기본값을 설정할 수 있습니다.

import { SWRConfig } from 'swr';

function App() {
  return (
    <SWRConfig value={{
      refreshInterval: 3000,
      fetcher: (url) => fetch(url).then(r => r.json()),
      revalidateOnFocus: false,
    }}>
      <Dashboard />
    </SWRConfig>
  );
}

모든 하위 컴포넌트의 useSWR이 이 설정을 상속합니다. 물론 개별 useSWR에서 override할 수도 있습니다.

// 이 컴포넌트에서는 전역 false를 덮어써 revalidateOnFocus가 true가 됨
const { data } = useSWR('/api/realtime', fetcher, {
  revalidateOnFocus: true
});

요청 중복 제거: API 할당량 절약

자동 요청 중복 제거도 매우 유용합니다. 컴포넌트 세 개가 동시에 마운트되고 모두 useSWR('/api/user')로 사용자 데이터를 가져온다고 합시다. SWR은 요청을 세 번 보내지 않고 한 번만 보내며 결과를 공유합니다.

“중복 제거 창”의 기본값은 2초(dedupingInterval: 2000)입니다. 즉, 2초 안에 발생한 같은 요청이 하나로 합쳐집니다. 빠른 탭 전환처럼 컴포넌트가 연달아 재마운트될 수 있다면 이 값을 조금 늘릴 수 있습니다.

이 기능 덕분에 위기를 넘긴 적이 있습니다. 예전에 목록 페이지에서 사용자가 빠르게 스크롤하면 제 로직 문제로 데이터 로드가 여러 번 발생해 같은 요청이 짧은 시간에 십여 번 전송됐습니다. 중복 제거 설정을 추가하자 즉시 정상으로 돌아왔습니다. 근본 해결은 아니었지만 적어도 API 할당량이 바닥나지는 않았습니다(웃음).

조건부 fetching: 다른 데이터에 의존하는 요청

먼저 A 데이터를 가져온 뒤 그 결과에 따라 B를 요청해야 할 때가 있습니다. SWR에서는 key로 null을 넘기면 요청을 멈출 수 있습니다.

// 먼저 사용자 정보 가져오기
const { data: user } = useSWR('/api/user', fetcher);

// user 로드가 끝난 뒤 해당 사용자의 프로젝트 목록 가져오기
const { data: projects } = useSWR(
  user ? `/api/projects?userId=${user.id}` : null,
  fetcher
);

user가 undefined이면 key가 null이므로 SWR은 요청하지 않습니다. user가 로드되어 key가 유효해진 뒤에야 projects 요청을 시작합니다. 직렬 의존 관계를 이렇게 간단하게 처리할 수 있습니다.

낙관적 업데이트: 사용자 경험을 높이는 비밀 무기

캐시는 “속도” 문제를 해결하지만 사용자 동작이라는 또 다른 상황이 남습니다. 사용자가 “좋아요”를 눌렀을 때 API 응답을 기다린 뒤 하트를 빨갛게 만들 것인가요, 아니면 즉시 빨갛게 표시하고 백그라운드에서 요청을 보낼 것인가요?

낙관적 업데이트(Optimistic Update)는 작업이 성공할 것이라고 가정하고 UI를 즉시 갱신한 뒤 실패하면 롤백하는 방식입니다. 다소 위험하게 들릴 수 있지만 네트워크나 서버 문제가 없다면 대부분의 작업은 성공하며, 사용자 경험 향상은 분명합니다.

기존 방식과 낙관적 업데이트 비교

기존 방식:

  1. 사용자가 “좋아요” 클릭
  2. 버튼이 loading 상태가 되거나 비활성화됨
  3. 네트워크 속도에 따라 500ms~2초 대기
  4. API 성공 응답 후 하트가 켜짐
  5. 사용자: “이 사이트 조금 느린데?”

낙관적 업데이트:

  1. 사용자가 “좋아요” 클릭
  2. 하트가 즉시 켜짐(로컬 업데이트)
  3. 백그라운드에서 API 요청 전송
  4. 보통은 성공하므로 추가 작업 없음
  5. 드물게 실패하면 하트를 회색으로 되돌리고 “작업 실패” 안내

후자의 지연은 0ms입니다. 사용자는 아주 빠르다고 느낍니다.

mutate 함수: 캐시 수동 제어

SWR은 캐시를 수동으로 업데이트할 수 있는 mutate 함수를 제공합니다. 가장 간단한 사용법은 다음과 같습니다.

import { mutate } from 'swr';

// /api/user를 수동으로 재검증
mutate('/api/user');

낙관적 업데이트에는 더 많은 제어가 필요합니다. 완전한 Todo 애플리케이션 예제를 보겠습니다.

import useSWR, { mutate } from 'swr';

function TodoList() {
  const { data: todos } = useSWR('/api/todos', fetcher);

  const addTodo = async (text) => {
    const newTodo = { id: Date.now(), text, completed: false };

    // 핵심: 낙관적 업데이트 설정
    mutate(
      '/api/todos',
      async (currentTodos) => {
        // 1. 새 Todo를 UI에 즉시 표시(낙관적)
        const optimisticData = [...currentTodos, newTodo];

        // 2. 백그라운드에서 실제 요청 전송
        const savedTodo = await fetch('/api/todos', {
          method: 'POST',
          body: JSON.stringify(newTodo)
        }).then(r => r.json());

        // 3. 임시 데이터를 실제 데이터로 교체
        return [...currentTodos, savedTodo];
      },
      {
        optimisticData: [...todos, newTodo],  // 즉시 표시
        rollbackOnError: true,                // 실패 시 롤백
        revalidate: false,                    // 추가 검증 불필요
      }
    );
  };

  return (
    <div>
      {todos?.map(todo => <div key={todo.id}>{todo.text}</div>)}
      <button onClick={() => addTodo('New task')}>Add</button>
    </div>
  );
}

코드는 조금 길지만 논리는 명확합니다.

  1. 사용자가 “Add” 클릭
  2. 새 Todo를 목록에 즉시 표시(optimisticData)
  3. 백그라운드에서 POST 요청 전송
  4. 성공하면 서버가 반환한 실제 데이터로 교체(id, timestamp 등의 필드가 있을 수 있음)
  5. 실패하면 이전 상태로 롤백(rollbackOnError: true)

네 가지 핵심 옵션

mutate의 options 객체에는 네 가지 중요한 설정이 있습니다.

1. optimisticData: 즉시 업데이트할 데이터

optimisticData: [...todos, newTodo]  // 또는 함수 전달

고정값이나 함수를 전달할 수 있습니다. 함수 형태가 더 유연합니다.

optimisticData: (currentTodos) => [...currentTodos, newTodo]

2. populateCache: 반환값으로 캐시를 갱신할지 여부

기본값은 true입니다. 서버가 id, createdAt 등의 필드를 추가할 수 있으므로 대부분은 API 반환 데이터로 캐시를 갱신하는 편이 좋습니다. API가 완전한 데이터를 반환하지 않는다면 false로 설정할 수 있습니다.

3. revalidate: 다시 검증할지 여부

보통 false로 설정합니다. 이미 데이터를 직접 갱신했으므로 SWR이 요청을 한 번 더 보낼 필요가 없습니다. 업데이트 로직을 신뢰하기 어려워 SWR로 다시 확인하고 싶을 때만 사용하면 됩니다.

4. rollbackOnError: 실패 시 롤백할지 여부

매우 중요합니다. true로 설정하면 mutate 함수가 오류를 던질 때 SWR이 업데이트 이전 데이터로 자동 롤백합니다. 사용자가 “좋아요”를 눌러 하트가 켜졌지만 API가 실패하면 SWR이 하트를 자동으로 회색으로 되돌립니다.

더 세밀하게 제어하려면 함수를 전달할 수도 있습니다.

rollbackOnError: (error) => {
  // 네트워크 시간 초과는 롤백하지 않고 다른 오류는 롤백
  return error.name !== 'AbortError';
}

실전: Todo 삭제

삭제도 낙관적 업데이트의 대표적인 활용 사례입니다.

const deleteTodo = async (id) => {
  mutate(
    '/api/todos',
    async (currentTodos) => {
      // 목록에서 즉시 제거
      const optimistic = currentTodos.filter(t => t.id !== id);

      // 백그라운드에서 삭제 요청 전송
      await fetch(`/api/todos/${id}`, { method: 'DELETE' });

      // 업데이트된 데이터 반환
      return optimistic;
    },
    {
      optimisticData: todos.filter(t => t.id !== id),
      rollbackOnError: true,
    }
  );
};

사용자가 삭제를 클릭하면 Todo가 즉시 사라집니다. 서버가 해당 Todo가 없다고 응답하거나 요청이 실패하면 Todo가 다시 나타나고 오류 안내가 표시됩니다.

useSWRMutation: 더 우아한 방식

SWR 2.0에는 전용 useSWRMutation Hook이 추가되어 코드를 더 간결하게 쓸 수 있습니다.

import useSWRMutation from 'swr/mutation';

async function updateUser(url, { arg }) {
  await fetch(url, {
    method: 'POST',
    body: JSON.stringify(arg)
  });
}

function Profile() {
  const { trigger, isMutating } = useSWRMutation('/api/user', updateUser);

  return (
    <button
      onClick={() => trigger({ name: 'John' })}
      disabled={isMutating}
    >
      Update Name
    </button>
  );
}

trigger는 mutation을 실행하고 isMutating은 요청 진행 여부를 알려 줍니다. loading 상태를 직접 관리하는 것보다 훨씬 편합니다.

언제 낙관적 업데이트를 사용해야 할까?

모든 작업에 낙관적 업데이트가 적합한 것은 아닙니다. 경험에 따른 기준은 다음과 같습니다.

낙관적 업데이트에 적합:

  • 좋아요/즐겨찾기(실패율이 낮고 되돌릴 수 있음)
  • 목록 항목 추가/삭제(사용자가 결과를 즉시 보는 것이 중요함)
  • 스위치 상태 전환(알림 설정 전환 등)
  • 폼 입력 저장(초안 기능)

낙관적 업데이트에 부적합:

  • 결제(반드시 확인을 기다려야 함)
  • 계정 삭제(되돌릴 수 없어 신중해야 함)
  • 민감한 데이터 변경(비밀번호, 권한)
  • 서버 계산이 필요한 작업(예: 보고서 생성은 결과를 미리 알 수 없음)

원칙은 간단합니다. 실패율이 낮고 되돌릴 수 있으며 사용자 경험이 우선인 작업에는 낙관적 업데이트를 사용하세요. 핵심 비즈니스 로직이나 되돌릴 수 없는 작업은 서버 응답을 기다려야 합니다.

제가 겪은 실수도 하나 있습니다. 예전에 댓글 기능에 낙관적 업데이트를 적용했습니다. 사용자가 댓글을 작성하면 즉시 표시돼 경험이 좋았습니다. 그런데 네트워크가 느린 사용자가 “전송”을 눌러도 반응이 없는 것처럼 보여 버튼을 십여 번 연속으로 누르는 일이 생겼습니다. 클릭할 때마다 optimistic 업데이트가 일어나 화면에 같은 댓글이 십여 개 나타났습니다. 백엔드 API에 중복 방지 로직이 있었지만 프론트엔드 UI는 엉망이 됐습니다.

나중에 isMutating 검사를 추가해 요청 중에는 버튼을 비활성화하자 문제가 해결됐습니다. 낙관적 업데이트는 훌륭하지만 경계 상황도 고려해야 한다는 교훈을 얻었습니다.

SWR vs React Query: 무엇을 선택할까?

SWR의 장점을 많이 살펴봤지만 React Query는 어떤지 궁금할 수 있습니다. “React 데이터 가져오기”를 검색하면 React Query 글도 아주 많이 나옵니다. 어느 쪽을 선택해야 할까요?

솔직히 두 라이브러리 모두 훌륭하고 2025년에도 활발히 유지보수되고 있습니다. 잘못 골라도 큰일은 없지만 알맞은 선택은 개발을 더 편하게 만듭니다.

번들 크기 비교: SWR이 더 가볍다

  • SWR: 5.3KB(gzip 압축 후)
  • React Query(TanStack Query): 16.2KB

마케팅 랜딩 페이지나 모바일 H5처럼 번들 크기에 매우 민감한 프로젝트라면 SWR의 장점이 분명합니다. 하지만 대부분의 프로젝트에서 10KB 차이가 치명적이지는 않습니다.

복잡도 비교: SWR이 더 단순하다

SWR의 API 설계는 매우 단순하며 핵심은 useSWR Hook 하나입니다. 5분이면 배우고 10분이면 사용할 수 있습니다.

React Query는 더 강력하지만 학습 곡선도 가파릅니다. QueryClient, useQuery, useMutation, queryKeys, cache time과 stale time 같은 개념을 이해해야 하며 문서도 깁니다.

팀의 기술 스택이 비교적 새롭거나 프론트엔드 경험이 많지 않다면 SWR이 더 친절합니다.

기능 비교: React Query가 더 포괄적이다

React Query에는 SWR에 없는 기능이 몇 가지 있습니다.

  1. 공식 DevTools: 모든 query 상태, 캐시 내용, refetch 주기를 시각적으로 볼 수 있어 디버깅 경험이 매우 좋습니다. SWR에는 공식 DevTools가 없습니다.

  2. 더 세밀한 캐시 제어: cache time(데이터를 메모리에 보관하는 시간)과 stale time(데이터가 stale 상태가 되기까지의 시간)을 별도로 설정할 수 있습니다. SWR은 cache와 revalidate 중심으로 비교적 단순합니다.

  3. paginated/infinite queries: React Query에는 전용 useInfiniteQuery가 있고 SWR에는 useSWRInfinite가 있습니다. 기능은 비슷하지만 React Query의 API가 조금 더 성숙했습니다.

  4. 더 강력한 mutation 지원: React Query의 useMutation은 전역 mutation state, retry 전략, onSettled 콜백 등을 체계적으로 지원합니다. SWR의 useSWRMutation은 2.0에서 추가되어 상대적으로 단순합니다.

커뮤니티와 생태계

React Query(현재 TanStack Query)는 커뮤니티가 더 크고 NPM 주간 다운로드 수도 많습니다. 문제가 생겼을 때 StackOverflow에서 답을 찾기도 더 쉽습니다.

SWR과 Next.js는 모두 Vercel 제품입니다. 공식 문서에 Next.js 전용 통합 가이드가 있으므로 Next.js를 사용한다면 SWR은 특히 자연스러운 선택입니다.

제 선택 기준

SWR 선택React Query 선택
프로젝트가 비교적 단순하고 데이터 가져오기 로직이 복잡하지 않음프로젝트가 복잡하고 캐시를 세밀하게 제어해야 함
번들 크기에 민감함(모바일)10KB 정도의 추가 크기가 중요하지 않음
Next.js 사용다른 프레임워크(CRA, Vite 등) 사용
팀의 프론트엔드 경험이 많지 않음팀이 복잡한 라이브러리에 익숙하고 풍부한 기능을 선호함
빠르게 시작하고 싶음전체 생태계를 학습하는 데 시간을 쓸 수 있음

제 경험으로는 작은 프로젝트나 MVP 단계에서는 단순하고 빠른 SWR을 우선 선택합니다. 프로젝트가 커지고 요구 사항이 복잡해지면 React Query로 옮길 수 있습니다. 하지만 대부분의 프로젝트에서는 SWR만으로도 충분합니다.

또 한 가지, 두 라이브러리 사이의 마이그레이션 비용은 그리 높지 않습니다. 핵심 개념이 cache, revalidate, mutation으로 비슷하기 때문입니다. 언젠가 React Query의 고급 기능이 정말 필요해져도 전환이 아주 고통스럽지는 않습니다. 지나치게 고민하지 않아도 됩니다.

Next.js와 SWR: 자연스러운 조합

SWR과 Next.js는 모두 Vercel 제품이라 처음부터 잘 어울립니다. Next.js를 쓴다면 특히 편리한 통합 지점이 몇 가지 있습니다.

App Router에서 SWR 사용

Next.js 13+의 App Router는 기본적으로 서버 컴포넌트(Server Components)를 사용합니다. SWR은 클라이언트 라이브러리이므로 Client Component에서 사용해야 합니다.

'use client';  // 클라이언트 컴포넌트 표시 필수

import useSWR from 'swr';

export default function Profile() {
  const { data } = useSWR('/api/user', fetcher);
  return <div>{data?.name}</div>;
}

'use client'를 반드시 추가하세요. 초보자가 자주 놓치는 부분입니다.

SSR + SWR: fallback data 활용

Next.js의 강점은 서버 측 렌더링(SSR)입니다. getStaticPropsgetServerSideProps에서 초기 데이터를 가져와 SWR의 fallback으로 전달할 수 있습니다.

// pages/profile.js
export async function getStaticProps() {
  const user = await fetch('https://api.example.com/user').then(r => r.json());

  return {
    props: {
      fallback: {
        '/api/user': user  // key는 SWR의 key와 대응
      }
    },
    revalidate: 60  // ISR: 60초마다 다시 생성
  }
}

export default function Profile({ fallback }) {
  return (
    <SWRConfig value={{ fallback }}>
      <UserProfile />
    </SWRConfig>
  );
}

function UserProfile() {
  // 첫 렌더링은 fallback 데이터를 사용하므로 loading 불필요
  const { data } = useSWR('/api/user', fetcher);
  return <div>{data.name}</div>;
}

사용자는 첫 방문부터 완전한 콘텐츠를 볼 수 있어 SEO에도 유리하고, 클라이언트에서 SWR이 이어받아 계속 revalidate합니다. 두 장점을 모두 얻습니다.

미리 가져오기(Prefetching)

중요한 데이터는 사용자가 클릭하기 전 미리 불러올 수 있습니다.

import { mutate } from 'swr';

function ArticleLink({ id }) {
  const prefetch = () => {
    mutate(`/api/article/${id}`, fetch(`/api/article/${id}`).then(r => r.json()));
  };

  return (
    <Link href={`/article/${id}`} onMouseEnter={prefetch}>
      Read more
    </Link>
  );
}

사용자가 링크 위에 마우스를 올리면 게시글 데이터를 불러오기 시작합니다. 클릭할 때쯤 데이터가 이미 캐시에 있을 수 있어 페이지가 즉시 열립니다.

무한 스크롤: useSWRInfinite

목록 페이지에서 흔한 요구 사항인 스크롤 추가 로드는 다음처럼 구현합니다.

import useSWRInfinite from 'swr/infinite';

function ArticleList() {
  const getKey = (pageIndex, previousPageData) => {
    if (previousPageData && !previousPageData.length) return null; // 더 이상 데이터 없음
    return `/api/articles?page=${pageIndex + 1}&limit=10`;
  };

  const { data, size, setSize, isLoading } = useSWRInfinite(getKey, fetcher);

  const articles = data ? data.flat() : [];
  const isLoadingMore = isLoading || (size > 0 && data && typeof data[size - 1] === 'undefined');

  return (
    <div>
      {articles.map(article => (
        <div key={article.id}>{article.title}</div>
      ))}
      <button
        onClick={() => setSize(size + 1)}
        disabled={isLoadingMore}
      >
        {isLoadingMore ? 'Loading...' : 'Load More'}
      </button>
    </div>
  );
}

setSize(size + 1)를 호출하면 다음 페이지를 불러옵니다. SWR은 각 페이지의 데이터를 캐시하므로 사용자가 위로 다시 스크롤해도 재로딩할 필요가 없습니다.

성능 최적화 팁

Next.js에서 SWR을 사용할 때는 다음 최적화를 고려하세요.

  1. revalidate 주기를 합리적으로 설정

    • 정적 콘텐츠(문서, 도움말): 자동 revalidate 끄기
    • 사용자 관련 데이터: 기본 focus revalidate 유지
    • 실시간 데이터: refreshInterval로 폴링
  2. API 라우트 캐시

    • Next.js API 라우트에서 Cache-Control header 활용
    • SWR은 HTTP 캐시 헤더를 따름
  3. 폭포형 요청 방지

    • 여러 데이터에 의존 관계가 있다면 서버에서 요청을 합치는 방안 고려
    • 또는 Next.js의 병렬 데이터 가져오기 사용

실제 사례를 하나 들겠습니다. 예전에 블로그 사이트의 게시글 목록 페이지에 SWR + ISR(Incremental Static Regeneration)을 적용했습니다. 첫 화면은 정적으로 생성된 HTML이라 바로 열렸고, 사용자가 콘텐츠를 보는 동안 SWR이 백그라운드에서 새 글을 확인해 있으면 목록을 자동 업데이트했습니다. 경험이 매우 매끄러웠고 SEO도 훌륭했습니다.

이것이 Next.js + SWR의 매력입니다. 정적 사이트의 성능과 동적 애플리케이션의 실시간성을 함께 얻을 수 있습니다.

결론

긴 내용을 간단히 정리해 보겠습니다.

SWR은 React 데이터 가져오기의 세 가지 문제인 코드 중복, 캐시 부족, 여러 컴포넌트 간 동기화를 해결합니다. 핵심 전략 stale-while-revalidate는 캐시를 즉시 표시해 빠르고, 백그라운드에서 갱신해 정확합니다. 자동 revalidation, 요청 중복 제거, 낙관적 업데이트까지 더하면 데이터 가져오기 상황의 90%를 처리할 수 있습니다.

제가 꼽는 모범 사례는 다음과 같습니다.

  1. 데이터 특성에 맞게 캐시 전략 조정: 실시간 데이터는 폴링하고 안정적인 데이터는 오래 캐시합니다.
  2. 사용자 작업에는 낙관적 업데이트 적용: 실패율이 낮고 되돌릴 수 있을 때 사용합니다.
  3. key로 데이터 공유: 데이터 공유만을 위해 상태 관리 도구를 도입하지 마세요.
  4. Next.js 프로젝트에서는 SWR 우선 고려: fallback data로 SSR과 결합합니다.
  5. 단순한 프로젝트는 SWR, 복잡한 프로젝트는 React Query: 과도하게 설계하지 마세요.

마지막으로 솔직히 말하면 SWR은 만능 해결책이 아닙니다. 잘못 설계된 API를 해결할 수도, 합리적인 아키텍처를 대신할 수도 없습니다. 하지만 백엔드 API가 잘 설계되어 있다면 SWR은 프론트엔드 코드를 훨씬 우아하게 만들어 줍니다.

다음 프로젝트에서 SWR을 사용해 보세요. 분명 만족할 것입니다.

SWR 전체 사용 절차

설치부터 설정, 사용, 캐시 최적화까지의 전체 단계

⏱️ Estimated time: 1 hr

  1. 1

    Step 1: 설치 및 기본 사용

    설치:
    ```bash
    npm install swr
    ```

    기본 사용:
    ```tsx
    'use client'
    import useSWR from 'swr'

    const fetcher = (url: string) => fetch(url).then(r => r.json())

    export function UserList() {
    const { data, error, isLoading } = useSWR('/api/users', fetcher)

    if (error) return <div>Failed to load</div>
    if (isLoading) return <div>Loading...</div>

    return <div>{data.map(user => <div key={user.id}>{user.name}</div>)}</div>
    }
    ```

    핵심:
    • 'use client'로 클라이언트 컴포넌트를 표시합니다.
    • fetcher 함수가 데이터 가져오기를 처리합니다.
    • useSWR은 data, error, isLoading을 반환합니다.
  2. 2

    Step 2: 전역 옵션 설정

    SWRConfig 사용:
    ```tsx
    'use client'
    import { SWRConfig } from 'swr'

    const fetcher = (url: string) => fetch(url).then(r => r.json())

    export function Providers({ children }) {
    return (
    <SWRConfig
    value={{
    fetcher,
    revalidateOnFocus: true,
    revalidateOnReconnect: true,
    refreshInterval: 0,
    }}
    >
    {children}
    </SWRConfig>
    )
    }
    ```

    자주 쓰는 옵션:
    • revalidateOnFocus: 창에 포커스가 돌아오면 재검증
    • revalidateOnReconnect: 네트워크가 다시 연결되면 재검증
    • refreshInterval: 주기적 새로고침(0이면 새로고침 안 함)
    • dedupingInterval: 중복 제거 간격(기본값 2000ms)

    핵심: 루트 컴포넌트에서 설정하면 모든 하위 컴포넌트가 설정을 공유합니다.
  3. 3

    Step 3: Next.js 통합(SSR)

    fallback data와 함께 사용:
    ```tsx
    // app/users/page.tsx (Server Component)
    import { getUsers } from '@/lib/users'

    export default async function UsersPage() {
    const initialUsers = await getUsers()

    return <UsersList initialUsers={initialUsers} />
    }

    // components/UsersList.tsx (Client Component)
    'use client'
    import useSWR from 'swr'

    export function UsersList({ initialUsers }) {
    const { data } = useSWR('/api/users', fetcher, {
    fallbackData: initialUsers // SSR 데이터를 초기값으로 사용
    })

    return <div>{data.map(...)}</div>
    }
    ```

    장점:
    • 첫 화면 데이터가 SSR에서 오므로 빠릅니다.
    • 이후 업데이트는 SWR이 맡아 경험이 좋습니다.
    • SSR과 클라이언트 캐시의 장점을 결합합니다.

    핵심: initialData 대신 fallbackData를 사용합니다(fallbackData는 재검증을 일으키지 않습니다).
  4. 4

    Step 4: 낙관적 업데이트

    mutate로 낙관적 업데이트 구현:
    ```tsx
    import { mutate } from 'swr'

    async function updateUser(id: string, name: string) {
    // 낙관적 업데이트: UI를 즉시 갱신
    mutate(`/api/users/${id}`, { ...user, name }, false)

    // 요청 전송
    await fetch(`/api/users/${id}`, {
    method: 'PATCH',
    body: JSON.stringify({ name })
    })

    // 재검증: 데이터가 정확한지 확인
    mutate(`/api/users/${id}`)
    }
    ```

    핵심:
    • mutate의 세 번째 인수 false는 재검증하지 않는다는 뜻입니다.
    • 요청 성공 후 mutate를 다시 호출해 재검증합니다.
    • 사용자는 업데이트를 즉시 확인할 수 있습니다.

FAQ

SWR이란 무엇이며 왜 필요한가요?
SWR은 Vercel이 만든 React 데이터 가져오기 라이브러리로, 핵심은 stale-while-revalidate입니다.

기존 방식의 문제:
• 매번 useState, useEffect, loading, error 처리를 작성해야 함
• 기본 코드만 20줄 이상
• 여러 컴포넌트가 같은 데이터를 쓸 때 상태를 끌어올리거나 각각 요청해야 함

SWR의 장점:
• 자동 캐시: 같은 key의 요청이 캐시를 자동 공유
• 자동 재검증: 창 포커스 및 네트워크 재연결 시 자동 새로고침
• 자동 중복 제거: 같은 요청을 한 번만 전송
• 자동 오류 재시도: 요청 실패 시 자동 재시도
• 하나의 Hook으로 데이터 가져오기 코드의 90%를 단순화

세 줄로 문제의 80% 해결:
```tsx
const { data, error, isLoading } = useSWR('/api/users', fetcher)
```
SWR과 React Query는 무엇이 다른가요?
SWR:
• 단순하고 가벼우며 학습 비용이 낮음
• 대부분의 프로젝트에 적합
• 기능이 비교적 단순함
• Vercel 제품으로 Next.js와 잘 통합됨

React Query:
• 강력하고 풍부한 기능
• 복잡한 상황에 적합
• 학습 비용이 높음
• 더 유연하지만 설정도 더 복잡함

선택 기준:
• 단순한 프로젝트 → SWR
• 복잡한 프로젝트 → React Query
• 확신이 없다면 → SWR부터 사용하고 필요할 때 전환

핵심: 과도하게 설계하지 말고 단순하면 SWR, 복잡하면 React Query를 선택하세요.
Next.js에서 SWR을 어떻게 사용하나요?
fallback data로 SSR과 결합합니다:
```tsx
// app/users/page.tsx (Server Component)
export default async function UsersPage() {
const initialUsers = await getUsers()
return <UsersList initialUsers={initialUsers} />
}

// components/UsersList.tsx (Client Component)
'use client'
export function UsersList({ initialUsers }) {
const { data } = useSWR('/api/users', fetcher, {
fallbackData: initialUsers // SSR 데이터 사용
})
return <div>{data.map(...)}</div>
}
```

장점:
• 첫 화면 데이터가 SSR에서 와서 빠름
• 이후 업데이트는 SWR이 담당해 경험이 좋음
• SSR과 클라이언트 캐시의 장점을 결합

핵심:
• initialData 대신 fallbackData 사용
• fallbackData는 재검증을 일으키지 않음
• Next.js App Router에 적합
SWR의 캐시 전략은 무엇인가요?
핵심: stale-while-revalidate

흐름:
1. 첫 요청: loading 표시
2. 요청 성공: 데이터 표시 및 캐시
3. 재요청: 캐시 데이터를 즉시 표시(빠름)
4. 백그라운드 재검증: 데이터를 조용히 갱신(정확함)
5. 업데이트 완료: 새 데이터로 자연스럽게 교체

주요 기능:
• 자동 캐시: 같은 key가 자동 공유
• 자동 중복 제거: 같은 요청을 한 번만 전송
• 자동 재검증: 창 포커스 및 네트워크 재연결 시 새로고침
• 자동 오류 재시도: 요청 실패 시 자동 재시도

설정 옵션:
• revalidateOnFocus: 창 포커스 시 재검증
• revalidateOnReconnect: 네트워크 재연결 시 재검증
• refreshInterval: 주기적 새로고침
• dedupingInterval: 중복 제거 간격

장점: loading을 다시 보지 않으면서도 최신 데이터를 유지할 수 있습니다.
낙관적 업데이트는 어떻게 구현하나요?
mutate로 낙관적 업데이트를 구현합니다:
```tsx
import { mutate } from 'swr'

async function updateUser(id: string, name: string) {
// 낙관적 업데이트: UI를 즉시 갱신
mutate(`/api/users/${id}`, { ...user, name }, false)

// 요청 전송
await fetch(`/api/users/${id}`, {
method: 'PATCH',
body: JSON.stringify({ name })
})

// 재검증: 데이터가 정확한지 확인
mutate(`/api/users/${id}`)
}
```

핵심:
• mutate의 세 번째 인수 false는 재검증하지 않는다는 뜻
• 요청 성공 후 mutate를 다시 호출해 재검증
• 사용자는 업데이트를 즉시 확인 가능

주의: 요청에 실패하면 원래 데이터로 롤백해야 합니다.
SWR은 어떤 상황에 적합한가요?
적합한 상황:
• 자주 업데이트되는 데이터(사용자 목록, 알림 등)
• 여러 컴포넌트가 같은 데이터를 사용
• 자동 캐시와 재검증이 필요
• Next.js 프로젝트(통합이 좋음)

적합하지 않은 상황:
• 단순한 정적 데이터(캐시가 필요 없음)
• 한 번만 요청할 데이터(재검증이 필요 없음)
• 복잡한 캐시 요구 사항(React Query 사용)

선택 기준:
• 대부분의 프로젝트 → SWR
• 복잡한 상황 → React Query
• 단순한 데이터 → fetch 직접 사용

핵심: SWR을 쓰기 위해 억지로 쓰지 말고 실제 요구 사항에 따라 선택하세요.

5분 읽기 · 게시일: 2025년 12월 19일 · 수정일: 2026년 9월 8일

댓글

GitHub로 로그인하여 댓글을 남기세요

Easton BlogEaston Blog