본문 바로가기

React

React 앱에 GraphQL 캐싱 전략 도입하기

GraphQL은 필요한 데이터만 요청할 수 있다는 장점이 있지만, 캐싱 전략을 설계하지 않으면 네트워크 비용과 렌더링 지연이 커집니다. 이 글은 React 앱에 실무적으로 적용 가능한 GraphQL 캐싱 전략을 단계별로 정리합니다. Apollo Client 중심으로 설명하고, urql/Relay 대안도 간단히 비교합니다.

1. 캐싱이 어려운 이유와 목표

GraphQL 응답은 뷰에 최적화된 형태로 조합되기 때문에, 전통적인 REST 경로 기반 캐싱이 어렵습니다. 목표는 다음과 같습니다: 1) 엔티티 단위 정규화로 중복을 제거합니다. 2) fetchPolicy로 UX와 신선도를 균형 있게 관리합니다. 3) 페이지네이션/뮤테이션 시 캐시 정합성을 유지합니다. 4) 앱 재실행/SSR에도 캐시를 재활용합니다.

2. 어떤 클라이언트를 선택할까?

- Apollo Client: 가장 풍부한 생태계와 도구, 정규화 캐시, typePolicies, optimistic UI, 캐시 지속화 지원이 강합니다. - urql: 가볍고 유연합니다. 기본 캐시는 문서 캐시이고, graphcache(정규화 플러그인)로 확장 가능합니다. - Relay: 강력한 정규화/규모 확장에 최적화되었으며, 스키마 규율과 컴파일러 의존성이 있습니다. 큰 팀/장기 프로젝트에 적합합니다.

3. Apollo Client 기본 설정과 정규화

InMemoryCache는 타입/ID 기반으로 데이터를 정규화합니다. 서버가 안정적인 ID를 제공하도록 협업이 중요합니다.

import { ApolloClient, InMemoryCache, ApolloProvider, HttpLink } from '@apollo/client';

const cache = new InMemoryCache({
  typePolicies: {
    Query: {
      fields: {
        post: { keyArgs: ['id'] },
        posts: { keyArgs: ['filter'] },
      },
    },
    Post: {
      keyFields: ['id'], // 고유 ID 명시
    },
    User: {
      keyFields: ['id'],
    },
  },
});

const client = new ApolloClient({
  link: new HttpLink({ uri: '/graphql', credentials: 'include' }),
  cache,
});

export default function App({ children }) {
  return <ApolloProvider client={client}>{children}</ApolloProvider>;
}

핵심: keyFields로 엔티티 식별자를 고정하고, Query 필드에 keyArgs로 쿼리 변수에 따른 캐시 키를 분리합니다.

4. fetchPolicy로 UX 조절하기

- cache-first: 기본값. 초기 로드가 빠릅니다. 데이터가 자주 변하지 않을 때 적합합니다. - cache-and-network: 캐시로 즉시 렌더 후 백그라운드로 최신 데이터 가져옵니다. 목록/홈 화면에 추천합니다. - network-only, no-cache: 항상 신선한 데이터가 필요한 상세 작업(결제, 보안 정보)에 사용합니다.

import { useQuery, gql } from '@apollo/client';

const GET_POSTS = gql`
  query Posts($filter: PostFilter, $cursor: String) {
    posts(filter: $filter, cursor: $cursor) { edges { id title } pageInfo { endCursor hasNextPage } }
  }
`;

function PostsList() {
  const { data, loading, fetchMore } = useQuery(GET_POSTS, {
    variables: { filter: { published: true } },
    fetchPolicy: 'cache-and-network',
    nextFetchPolicy: 'cache-first', // 이후 동일 쿼리는 캐시 우선
    notifyOnNetworkStatusChange: true,
  });
  // ...
}

5. 페이지네이션 캐싱: merge/read

커서 기반 페이지네이션은 field policy로 병합 로직을 정의합니다.

const cache = new InMemoryCache({
  typePolicies: {
    Query: {
      fields: {
        posts: {
          keyArgs: ['filter'],
          merge(existing = { edges: [] }, incoming) {
            return {
              ...incoming,
              edges: [...(existing.edges || []), ...incoming.edges],
            };
          },
        },
      },
    },
  },
});

주의: 필터가 바뀌면 keyArgs에 의해 다른 캐시 엔트리로 분리됩니다. 오프셋 기반은 중복/순서 문제가 커서 기반보다 많습니다.

6. 뮤테이션 후 캐시 업데이트와 낙관적 UI

뮤테이션이 엔티티를 변경하면 캐시를 업데이트해야 UI가 즉시 반영됩니다. optimisticResponse로 체감 속도를 높입니다.

import { useMutation, gql } from '@apollo/client';

const LIKE_POST = gql`
  mutation LikePost($id: ID!) { likePost(id: $id) { id liked likeCount } }
`;

function LikeButton({ id }) {
  const [like] = useMutation(LIKE_POST, {
    optimisticResponse: {
      likePost: { __typename: 'Post', id, liked: true, likeCount: 9999 }, // 대략값
    },
    update(cache, { data }) {
      if (!data?.likePost) return;
      cache.modify({
        id: cache.identify({ __typename: 'Post', id }),
        fields: {
          liked() { return data.likePost.liked; },
          likeCount() { return data.likePost.likeCount; },
        },
      });
    },
  });

  return <button onClick={() => like({ variables: { id } })}>Like</button>;
}

캐시 수정은 cache.modify 또는 writeQuery/writeFragment를 사용합니다. 경쟁 상태를 줄이려면 서버가 최신 수치를 반환하도록 합의합니다.

7. 로컬 상태와 Reactive Variables

UI 전용 상태는 reactive var로 캐시에 공존시킬 수 있습니다.

import { makeVar, InMemoryCache } from '@apollo/client';

export const uiVar = makeVar({ theme: 'light' });

const cache = new InMemoryCache({
  typePolicies: {
    Query: {
      fields: {
        ui: {
          read() { return uiVar(); },
        },
      },
    },
  },
});

이렇게 하면 useQuery로 서버 데이터와 함께 읽고 반응형으로 사용할 수 있습니다.

8. 캐시 지속화와 Next.js 하이드레이션

앱 재실행 시 캐시를 복원하면 콜드 스타트가 빨라집니다. Next.js에서는 SSR 데이터로 캐시를 미리 채우고 클라이언트에 하이드레이트합니다.

import { persistCache, LocalStorageWrapper } from 'apollo3-cache-persist';

const cache = new InMemoryCache();
await persistCache({ cache, storage: new LocalStorageWrapper(window.localStorage) });

const client = new ApolloClient({ cache, link });
// Next.js 서버에서
const client = initApollo();
await client.query({ query: GET_POSTS });
return { props: { initialApolloState: client.extract() } };

// 클라이언트에서
const client = new ApolloClient({ cache: new InMemoryCache().restore(initialApolloState), link });

9. Stale-While-Revalidate 패턴

사용자는 즉시 캐시를 보고, 백그라운드에서 최신 데이터를 동기화합니다. Apollo에서는 cache-and-network와 refetch 합성으로 구현합니다.

const { data, refetch } = useQuery(GET_POSTS, { fetchPolicy: 'cache-and-network' });
useEffect(() => { const i = setInterval(() => refetch(), 60_000); return () => clearInterval(i); }, [refetch]);

빈번한 갱신이 필요한 리스트/알림에 적용하고, 세션 비용이 큰 요청에는 주기를 길게 합니다.

10. 캐시 무효화와 정리

특정 엔티티를 삭제하거나 로그아웃 시 캐시를 정리합니다.

// 개별 엔티티 제거
cache.evict({ id: cache.identify({ __typename: 'Post', id }) });
cache.gc();

// 로그아웃 후 전체 리셋
await client.clearStore(); // 캐시 비우고 활성 쿼리 refetch

refetchQueries 옵션으로 뮤테이션 후 중요한 쿼리만 선택적으로 갱신하는 것도 좋습니다.

11. 서버 협업 포인트

- 안정적인 전역 ID 제공: 모든 노드 타입에 id 필드 고정입니다. - 리스트 응답에 cursor와 pageInfo 포함: 캐시 병합이 쉬워집니다. - 낙관적 업데이트 호환: 뮤테이션 결과에 변경된 엔티티를 충분히 반환합니다. - Persisted Query/APQ + GET 허용: 쿼리를 GET으로 서빙하면 CDN 캐시/ETag 활용이 가능합니다(쿼리 안전 시).

12. urql와 Relay에서의 캐싱 요약

- urql: 기본은 문서 캐시라 정규화가 없습니다. 정규화가 필요하면 @urql/exchange-graphcache를 추가하고 keys, updates 설정으로 병합/무효화를 관리합니다. - Relay: 스키마 엄격성과 정규화 스토어가 강력합니다. 서버 커넥션 스펙(cursor/pageInfo) 준수, fragment 중심 설계, 컴파일 단계 도입이 필요합니다.

13. React Query로 GraphQL을 쓸 때

React Query는 프로토콜 불문 데이터 동기화 도구입니다. 정규화는 제공하지 않으므로, 캐시 키 설계와 수동 무효화가 중요합니다. 간단한 화면/작은 앱에서는 구현이 가볍습니다.

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

const fetcher = (q, vars) => fetch('/graphql', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ query: q, variables: vars }) }).then(r => r.json()).then(r => r.data);

function usePosts(filter) {
  return useQuery({
    queryKey: ['posts', filter],
    queryFn: () => fetcher(`query($filter: PostFilter){ posts(filter:$filter){ edges{ id title } } }`, { filter }),
    staleTime: 60_000,
  });
}

엔티티 단위 업데이트가 필요하면 쿼리 키를 잘게 나누거나, 서버에 재요청해 일관성을 맞춥니다.

14. 디버깅/모니터링

- Apollo DevTools로 캐시 트리 확인, 필드 정책 검증 - 네트워크 탭에서 fetchPolicy별 요청 패턴 점검 - 로깅: update/modify 경로에 로그를 남겨 캐시 레이스 컨디션 추적

15. 실무 체크리스트

- [ ] 모든 타입에 keyFields 정의, 서버 id 안정화 - [ ] 주요 Query에 keyArgs 명시, 페이지네이션 merge 구현 - [ ] 화면별 fetchPolicy 결정: 목록(cache-and-network), 민감 데이터(network-only) - [ ] 뮤테이션에 optimisticResponse와 update 적용, 또는 refetchQueries 최소화 - [ ] 캐시 지속화/SSR 하이드레이션 구성 - [ ] 정기 refetch로 SWR 패턴 도입, 주기/조건 최적화 - [ ] DevTools로 캐시 상태 정기 점검

요약: GraphQL 캐싱의 핵심은 정규화와 정책화입니다. 클라이언트의 캐시 규칙(typePolicies, fetchPolicy)과 서버의 ID/페이지네이션 규약을 맞추면, React 앱의 체감 속도와 안정성이 크게 향상됩니다.