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 앱의 체감 속도와 안정성이 크게 향상됩니다.
'React' 카테고리의 다른 글
| React 앱에서 마크다운(Markdown) 렌더링 구현하기 (0) | 2026.05.21 |
|---|---|
| React에서 지도 API(Google Maps, Leaflet) 통합하기 (0) | 2026.05.21 |
| React에서 WebGL 직접 구현하여 인터랙티브 그래픽 만들기 (0) | 2026.05.20 |
| React에서 이미지 최적화를 위한 Responsive Image 구현하기 (0) | 2026.05.19 |
| React 앱에서 브라우저 저장소(Local Storage와 Session Storage) 활용하기 (0) | 2026.05.18 |