exchange 배열 한 줄로 표현돼 디버깅 시 흐름을 머릿속에 그리기 쉽다. 단순 화면은 document cache 로 가볍게, 캐시 공유가 필요한 화면만 graphcache 로 격상하는 점진적 도입이 가능하다.__typename:id 키, 화면 간 캐시 공유). 내 프로젝트는 @urql/exchange-graphcache 의 4블록(keys/resolvers/optimistic/updates) 으로 정책을 한 파일에 모은다.mapExchange + Me 쿼리 예외) 가 프로덕션에서 가장 자주 다듬게 되는 두 자리.REST API 로 사용자 프로필 페이지 한 화면을 그릴 때 자주 마주치는 패턴이 있다. 위에는 사용자 정보, 가운데는 그 사람이 쓴 글 목록, 아래는 팔로워 미리보기 다섯 명. 백엔드에는 엔드포인트가 셋 마련돼 있다.
GET /api/users/42
GET /api/users/42/posts?limit=10
GET /api/users/42/followers?limit=5
요청 셋을 띄우고, 응답을 Promise.all 로 묶고, 로딩 상태 셋을 합쳐서 화면에 그린다.
여기서 두 가지 비효율이 생긴다.
첫째, over-fetching. GET /api/users/42 응답에는 이메일, 가입일, 마지막 로그인 시각, 권한 플래그까지 들어 있다. 화면에 쓰는 건 닉네임과 프로필 이미지 둘뿐이다. 나머지 필드는 네트워크를 타고 와서 곧장 버려진다.
둘째, under-fetching. 글 목록 한 항목마다 댓글 수를 표시해야 하는데 그 정보가 /posts 응답에 없다. 글이 10 개면 GET /posts/{id}/comments/count 를 10 번 더 호출한다. 백엔드에서 N+1 이라고 부르는 함정의 프론트엔드 버전이다.
GraphQL 은 두 통증을 같은 방식으로 해결한다. 클라이언트가 필요한 모양을 직접 적어 보낸다.
query UserPage($id: ID!) {
user(id: $id) {
nickname
avatarUrl
posts(first: 10) {
edges { node { id title commentCount } }
}
followers(first: 5) {
edges { node { id nickname avatarUrl } }
}
}
}
요청은 한 번. 응답은 정확히 이 모양으로 돌아온다. 위 REST 시나리오의 세 요청과 댓글 수 N 개 추가 요청이 한 라운드트립으로 합쳐진다.
REST 와 GraphQL 의 차이는 세트 메뉴와 카페테리아 의 차이로 비유할 수 있다. 세트 메뉴는 식당이 미리 짜 둔 조합이라 주문은 빠르지만 안 먹는 반찬이 늘 두세 개 남는다. 카페테리아는 트레이를 들고 다니며 본인이 먹을 것만 골라 담는다. 트레이는 한 번만 들고 가면 된다.
GraphQL 의 단점은 셋이다.
posts.commentCount 를 글마다 풀면 그것이 곧 N+1 쿼리다. DataLoader 같은 배칭 도구를 깔거나 쿼리 복잡도 제한을 거는 운영 비용이 새로 붙는다.GET /users/42 가 곧 캐시 키라 브라우저, CDN, 서비스 워커가 알아서 캐시한다. GraphQL 은 모든 요청이 POST /graphql 한 엔드포인트로 가고 본문에 쿼리 문자열이 실려 가는 구조라 같은 메커니즘이 통하지 않는다.세 번째 항목 — 캐시가 까다롭다 — 은 6 과 7 두 절에 걸쳐 다룬다.
GraphQL 문서에는 세 종류의 작업이 있다.
세 작업의 문법은 동일하다. 클라이언트가 필요한 모양을 적어 보내면 서버가 그 모양으로 응답한다. 차이는 의미와 트랜스포트뿐이다.
쿼리 본문에 값을 박아 넣지 않고 $이름 으로 분리한다.
query Tombstone($id: ID!) {
tombstone(id: $id) {
title
respectCount
}
}
$id 는 쿼리 시그니처(첫 줄) 에 타입과 함께 선언하고, 실제 값은 요청 body 의 variables 객체로 따로 보낸다. 이렇게 분리해야 같은 쿼리 문서가 다른 입력값으로 재사용되고, 클라이언트·서버 양쪽에서 캐시 키로 다루기에도 안정적이다.
타입 끝의 ! 는 non-null. ID! 는 "ID 타입이고 null 불가". 배열은 [Tombstone!]! 처럼 두 위치에 ! 를 따로 붙일 수 있다 — 배열 자체의 null 가능 여부와 원소 null 가능 여부가 별개로 표현된다.
여러 쿼리에서 같은 필드 셋을 반복해 쓰면 fragment 로 빼낸다.
fragment TombstoneCard on Tombstone {
id
title
respectCount
commentCount
}
query FeedTombstones {
tombstones(first: 10) { ...TombstoneCard }
}
query MyTombstones {
myTombstones { ...TombstoneCard }
}
타입 시스템 위에서 동작하므로 on Tombstone 으로 적용 대상 타입을 명시한다. fragment 는 GraphQL Codegen 의 Client Preset 에서 Fragment Masking 의 단위이기도 하다(8 절 참고).
한 쿼리 안에서 같은 필드를 다른 인자로 여러 번 호출해야 할 때, 응답 키 충돌을 alias 로 푼다.
query MyAndOthers {
mine: tombstones(filter: { isOwner: true }) { ...TombstoneCard }
others: tombstones(filter: { isOwner: false }) { ...TombstoneCard }
}
alias 가 없으면 두 tombstones 응답이 같은 키로 충돌한다.
디렉티브(@include, @skip, 커스텀)와 인터페이스/유니온 타입도 존재한다. 일반 화면 쿼리에서 자주 등장하는 영역은 아니므로 본 가이드 범위에서 제외한다.
명예의 전당 화면용 쿼리. 위 문법이 한 장에 다 들어 있다.
// src/features/hall-of-fame/api/queries.ts:3-49
query AscendedTombstones(
$first: Int!
$after: String
$sort: AscendedSortInput
$filter: AscendedFilterInput
) {
ascendedTombstones(first: $first, after: $after, sort: $sort, filter: $filter) {
totalCount
pageInfo { hasNextPage endCursor }
edges {
cursor
node {
id title story deathDate burialStyle anonymousName hashtags
respectCount commentCount isAscended ascensionStory ascensionImageUrl
ascendedAt imageUrl createdAt category isOwner
location { lat lng }
}
}
}
}
query AscendedTombstones).$first, $after, $sort, $filter). 페이지 크기 $first 만 non-null, 나머지는 옵션.edges / node / pageInfo / cursor.location { lat lng } 같은 중첩 객체는 GraphQL 의 일반 동작이다. 한 번의 응답에 트리째 받아 온다.이 쿼리는 4 절에서 useAscendedTombstones 훅의 입력으로 다시 등장한다.
GraphQL 클라이언트 라이브러리는 React 기준 셋이 사실상 표준이다. Apollo Client, Relay, urql.
| 항목 | Apollo Client | Relay | urql |
|---|---|---|---|
| 메인테이너 | Apollo GraphQL (회사) | Meta | Phil Pluckthun · Nearform |
| 번들 크기 (코어, gzip) | 약 33 KB | 약 30 KB+ (런타임만, 컴파일러 별도) | 약 7 KB |
| 기본 캐시 | normalized (InMemoryCache) | normalized (컴파일러 강제) | document cache (옵션으로 normalized) |
| 확장 모델 | ApolloLink 체인 | 컴파일러 + 스키마 강제 | exchange 체인 |
| 강점 | 도구·생태계·튜토리얼 풍부 | 대규모 앱의 데이터 일관성 | 작고 명시적 |
| 약점 | 번들 크고 고급 캐시 디버깅 비용 | 컴파일러·fragment colocation 강제 | 직접 짜야 할 정책이 많음 |
번들 수치는 코어 패키지 기준이며, urql 에 graphcache 같은 부가 모듈을 더하면 그만큼 늘어난다. 정확한 값은 패키지 버전마다 변동된다.
세 옵션 모두 잘 만들어진 라이브러리고 어느 한쪽이 우월하지 않다. 선택 기준은 팀 규모와 캐시 요구의 깊이다.
urql 에서 데이터 흐름은 exchange 배열 로 표현된다.
const client = createClient({
url: '/graphql',
exchanges: [
cacheExchange,
fetchExchange,
],
});
exchanges 배열의 순서가 곧 파이프라인의 순서다. 배열 앞쪽에 둘수록 요청을 먼저 본다. 위 예시에서는 모든 요청이 먼저 cacheExchange 를 거쳐 캐시에 있으면 바로 반환되고, 없으면 fetchExchange 가 네트워크로 보낸다.
exchange 는 단방향 스트림 함수다. Operation 이 입력으로 들어와 OperationResult 가 출력으로 나가는 형태로 정의된다. 직접 작성할 수도 있다 — 공식 문서의 Authoring Exchanges 가이드 참고. Apollo 의 ApolloLink 와 같은 합성 모델이지만, 배열 순서가 그대로 파이프라인 순서라 디버깅 시 흐름을 머릿속에 그리기 쉽다.
내 프로젝트의 client 는 이 자리에 6 개의 exchange 를 넣는다. 어떤 순서로, 왜 그렇게 두는지는 5 절에서 한 줄씩 본다.
urql 의 표면 API 는 세 개다. createClient 로 client 인스턴스를 만들고, Provider 로 React 트리에 꽂고, 컴포넌트에서 useQuery / useMutation / useSubscription 으로 꺼내 쓴다. 이 절은 그 셋을 차례로 본다.
가장 작은 셋업은 다음과 같다.
import { createClient, Provider, cacheExchange, fetchExchange } from 'urql';
const client = createClient({
url: '/api/graphql',
exchanges: [cacheExchange, fetchExchange],
});
export function Root({ children }: { children: React.ReactNode }) {
return <Provider value={client}>{children}</Provider>;
}
url 은 GraphQL 엔드포인트, exchanges 는 요청이 통과할 파이프라인이다. Provider 는 React Context 로 client 를 자식 트리에 내려준다. 클라이언트 인스턴스는 한 번 만들어 재사용한다 — 매 렌더 새로 만들면 캐시가 매번 초기화된다.
Next.js App Router 환경에서 client 인스턴스는 클라이언트 컴포넌트 안에서만 만들어야 한다. 서버 컴포넌트 안에서 만들면 React 트리 단위로 인스턴스가 새로 생성되어 의도가 깨진다. 그래서 내 프로젝트는 'use client' 를 단 UrqlClientProvider 컴포넌트 하나에 client 생성을 가둔다 (자세한 구조는 5 절).
데이터를 읽는 자리에는 useQuery 를 쓴다. 반환은 [result, executeQuery] 튜플이다.
const [{ data, fetching, error }, reexecute] = useQuery({
query: SOME_QUERY,
variables: { id },
});
data — 응답이 도착하면 채워진다. 도착 전에는 undefined.fetching — 네트워크 요청이 진행 중이면 true. 캐시 hit 으로 즉시 반환되면 false 인 채로 data 가 차 있다.error — CombinedError 인스턴스. 네트워크 실패와 GraphQL 응답의 errors 배열을 한 자리에 합쳐 준다.reexecute — 같은 쿼리를 다시 실행한다. requestPolicy 를 일시적으로 바꿔 강제 갱신 시 쓴다.hall-of-fame 화면은 cursor 페이지네이션 변수를 그대로 useQuery 에 흘려 넣는다.
// src/features/hall-of-fame/hooks/useAscendedTombstones.ts:44-51
const [{ data, fetching }] = useQuery({
query: ASCENDED_TOMBSTONES_QUERY,
variables: {
first,
after: cursor,
sort: { field: sortFieldMap[sort] }
}
});
variables 가 바뀌면 urql 이 자동으로 새 요청을 발행한다. first, cursor, sort 중 하나라도 바뀐 시점에 새 응답이 들어와 data 가 다음 페이지로 교체된다. 화면 측 코드는 fetching 동안 스켈레톤을 그리고, data 가 채워지면 목록을 렌더하면 끝이다.
쿼리를 지금 쏘면 안 되는 시점이 있다. 변수가 아직 비어 있거나, 사용자가 패널을 열지 않은 상태거나, flyTo 같은 카메라 이동 중이라 매 프레임 변수가 바뀔 때다. 이때는 pause: true 를 넘긴다.
// src/features/map/hooks/useLiveFeed.ts:114-117
const [subscriptionResult] = useSubscription<TombstoneCreatedSubscription>({
query: TOMBSTONE_CREATED_SUBSCRIPTION,
pause: !enabled
});
위 코드는 구독이지만 옵션 이름과 동작은 useQuery 도 같다. pause 가 true 인 동안 urql 은 요청을 발행하지 않는다. false 로 바뀌면 그 시점의 변수로 한 번 발행한다. 지도 도메인에서 뷰포트가 빠르게 변하는 동안 pause: true 로 막아 두고, 안정되면 false 로 풀어 한 번만 쏘는 식의 패턴이 자주 나온다.
데이터를 바꾸는 자리에는 useMutation 을 쓴다. 반환은 [result, execute] 튜플이고, execute 가 promise 를 반환한다.
// src/features/burial/hooks/useSaveAiData.ts:20-39
const [, saveAiData] = useMutation(SAVE_AI_DATA_MUTATION);
const hasSaved = useRef(false);
return useCallback(
async (completedMessage: string) => {
if (hasSaved.current || !tombstoneId) return;
hasSaved.current = true;
try {
await saveAiData({
tombstoneId,
aiComfortMessage: completedMessage,
similarCountSnapshot: similarCount > 0 ? similarCount : null
});
} catch (err) {
console.error('Failed to save AI data:', err);
}
},
[tombstoneId, similarCount, saveAiData]
);
useQuery 와 다른 점이 두 가지 보인다. 첫째, 첫 번째 튜플 원소를 버린다 (const [, saveAiData]). mutation 의 결과 상태(fetching/error) 가 필요 없으면 두 번째 원소만 꺼낸다. 둘째, mutation 은 호출 시점이 명시적이다. useQuery 는 마운트 즉시 발행되지만, mutation 은 사용자가 버튼을 눌러야 발행된다.
hasSaved.current 는 urql 과 무관한 보호 장치다. AI 응답이 스트리밍 중간에 여러 번 완료 콜백을 부를 가능성이 있어서 한 번만 쏘게 막은 것이다. mutation 자체는 멱등이 아니라 호출 측에서 책임진다.
구독은 위에서 본 useLiveFeed 가 그대로 예시다. WebSocket 으로 새 묘비 생성 이벤트를 받아 라이브 피드 패널에 흘려 넣는다. 반환은 useQuery 와 비슷한 [result] 형태고, 스트림이 도착할 때마다 result.data 가 갱신된다.
여기서 한 가지만 짚고 가면 된다. 구독은 활성 상태 동안 WebSocket 연결을 점유하므로, 꼭 켜져야 하는 시점에만 켜는 게 기본이다. pause: !enabled 로 가드를 두는 패턴이 그래서 자연스럽다. 패널이 닫히면 enabled 가 false 가 되고, 구독도 함께 닫힌다.
createClient({ url, exchanges }) → <Provider value={client}> → 컴포넌트에서 useQuery / useMutation / useSubscription.useQuery 는 [{ data, fetching, error }, reexecute]. variables 가 바뀌면 자동 재발행.pause 옵션으로 조건부 쿼리. 변수가 미정인 시점·일시적으로 막아야 할 시점에 쓴다.useMutation 은 [result, execute]. execute 는 promise 를 반환하고, 호출 시점은 명시적이다.useSubscription 은 WebSocket 위에서 도는 useQuery. pause 로 켜고 끄는 게 기본.여기까지가 표면 API 다. 그 아래에서 실제로 무슨 일이 일어나는지 — cacheExchange, fetchExchange, 그리고 내 프로젝트가 추가로 끼워 넣은 4 개의 exchange — 가 다음 절의 주제다.
urql 의 데이터 흐름은 한 방향으로 흐르는 컨베이어 벨트로 비유할 수 있다. useQuery / useMutation 이 부른 작업(operation)이 벨트의 한쪽 끝에서 들어오고, 배열 순서대로 각 exchange 가 자기 검문소 차례에 그 작업을 본다. 어느 단계에서든 응답을 만들어 낼 수 있으면 그 자리에서 벨트의 방향이 뒤집힌다 — 응답은 들어왔던 길을 거꾸로 거슬러 컴포넌트로 돌아간다. 네트워크까지 갈 필요가 없으면 안 간다.
내 프로젝트의 client 는 이 벨트에 6 개의 검문소를 둔다.
// src/lib/UrqlClientProvider.tsx:86-147
const client = createClient({
url: process.env.NEXT_PUBLIC_GRAPHQL_URL ?? '/api/graphql',
exchanges: [
...(process.env.NODE_ENV === 'development' ? [devtoolsExchange] : []),
graphcacheExchange, // 1. 정규화 캐시 + optimistic
ssr, // 2. SSR dehydrate / hydrate
mapExchange({ // 3. 401·SESSION_EXPIRED 감지
onResult(result) {
if (shouldRedirectToSignIn(result) && typeof window !== 'undefined') {
routerRef.current.replace('/sign-in');
}
return result;
}
}),
retryExchange({ // 4. 네트워크 에러 재시도 (401 제외)
initialDelayMs: 1000,
maxNumberAttempts: 2,
retryIf: (error) => {
if (error?.response?.status === 401) return false;
return !!error?.networkError;
}
}),
subscriptionExchange({ /* ... WebSocket 라우팅 ... */ }),
fetchExchange // 6. 실제 HTTP
],
fetchOptions: { method: 'POST', credentials: 'include' }
});
순서가 곧 의미다. 한 줄씩 뜯어보면 왜 이 위치에 있어야 하는지가 드러난다.
graphcacheExchange 가 배열의 맨 앞에 있는 이유는 분명하다. 캐시는 네트워크에 가기 전에 해결되어야 한다. 같은 묘비를 두 번째로 여는 화면이 있다면 1 단계에서 끝나야 한다. 캐시 미스인 경우에만 작업이 다음 검문소로 흘러간다. optimistic 응답도 이 단계에서 발행된다 — 사용자가 헌화 버튼을 누른 직후 화면이 즉시 50 으로 갱신되는 것이 이 자리의 일이다.
ssr exchange 는 서버에서 채워 둔 데이터를 클라이언트가 첫 렌더 시 복원하는 역할을 한다. App Router 환경에서는 RSC 가 미리 받아 둔 응답을 직렬화해 HTML 에 심고, 브라우저가 마운트되면 이 exchange 가 그 데이터를 캐시에 hydrate 한다. 위치가 graphcache 바로 다음인 이유는, hydrate 된 데이터가 캐시 layer 와 정합을 맞춰야 하기 때문이다.
mapExchange 는 응답이 컴포넌트로 돌아가는 길에 결과를 들여다본다. 내 프로젝트는 이 자리에서 401 응답과 SESSION_EXPIRED 코드를 잡아 /sign-in 으로 라우팅한다. 위치가 retry 보다 앞인 이유는, 재시도하기 전에 401 인지 판별해야 하기 때문이다. 401 을 재시도해 봐야 다시 401 이 돌아올 뿐이다.
retryExchange 는 네트워크 자체가 실패한 경우에만 재시도한다. retryIf 콜백이 401 을 명시적으로 걸러 내고, error?.networkError 가 있을 때만 다시 보낸다. 위치가 fetch 직전인 이유는, 재시도가 곧 fetch 의 반복이기 때문이다.
여기서 작업의 종류에 따라 길이 갈린다. Subscription 은 HTTP 로 보낼 수 없고 WebSocket 으로 가야 한다. subscriptionExchange 가 subscription 만 가로채 graphql-ws 클라이언트로 흘려 보내고, query/mutation 은 통과시켜 다음 단계로 넘긴다.
fetchExchange 가 실제 HTTP 요청을 발행한다. 여기까지 도달했다는 건 위 다섯 검문소 모두에서 처리되지 못했다는 뜻이다. 응답이 돌아오면 같은 검문소들을 역순으로 다시 거쳐 가며 (fetch → retry → map → ssr → graphcache → 컴포넌트) 캐시를 갱신하고 컴포넌트에 전달된다.
배열 순서가 곧 의미라는 사실은 순서를 바꿔 보면 즉시 드러난다. mapExchange 를 retryExchange 뒤로 옮기면 401 응답이 두 번 재시도된 뒤에야 잡혀서 sign-in 리다이렉트가 늦어진다. graphcacheExchange 를 ssr 뒤로 옮기면 hydrate 된 데이터가 정규화 캐시에 들어가기 전에 컴포넌트가 그것을 먼저 보게 되어 cache miss 가 늘어난다. 검문소의 자리는 임의가 아니라 책임의 순서다.
요약하면 한 줄이다 — 캐시 먼저, SSR 다리, 응답 검사, 재시도, 트랜스포트 분기, 그리고 네트워크.
1 절 마지막에 미뤄 둔 문제 — GraphQL 은 캐시가 까다롭다 — 가 여기서 풀린다.
REST 는 URL 이 곧 캐시 키다. GET /users/42 라는 문자열을 브라우저, CDN, 서비스 워커가 그대로 키로 쓴다. 같은 URL 이 다시 요청되면 캐시에서 반환되고, 응답에 붙은 Cache-Control / ETag 헤더로 만료 정책까지 표준화돼 있다. HTTP 위에 쌓인 30 년 치 인프라가 그대로 동작한다.
GraphQL 은 이 메커니즘이 통하지 않는다. 모든 요청이 POST /graphql 한 엔드포인트로 가고, 어떤 데이터를 요청했는지는 본문의 쿼리 문자열에 들어 있다. URL 만 보면 모든 요청이 똑같다. POST 는 캐시 가능 메서드도 아니다. 캐시는 클라이언트 라이브러리가 자체적으로 풀어야 한다.
urql 은 이 문제에 대해 두 가지 옵션을 둔다.
Document cache (urql 기본 cacheExchange) — 키가 해시(쿼리 문서 + variables) 다. 같은 쿼리에 같은 변수면 캐시 hit, 어느 한쪽이라도 다르면 miss. 단순하다. 단순한 만큼 한계도 분명하다 — 같은 묘비 데이터를 두 화면이 서로 다른 쿼리로 받아 가면 두 사본이 캐시에 따로 저장된다. 한쪽에서 헌화해도 다른 쪽 캐시는 옛 값을 그대로 들고 있다.
Normalized cache (@urql/exchange-graphcache) — 응답을 받아 객체 단위로 쪼개 저장한다. { __typename, id } 가 키다. Tombstone:abc 한 자리에 그 묘비의 모든 필드가 모이고, 어느 화면에서 그 묘비를 만지든 같은 자리를 본다. Apollo 의 InMemoryCache 와 같은 모델이다.
운영에서 자주 만지는 손잡이는 requestPolicy 네 가지다.
cache-first — 기본. 캐시에 있으면 그걸 쓰고 끝. 없을 때만 네트워크.cache-and-network — 캐시를 먼저 보여주고, 동시에 네트워크 요청도 발행해 응답이 오면 갱신. 첫 화면 표시 속도를 살리면서도 stale 데이터를 잡고 싶을 때.network-only — 캐시 무시. 새로 받는다. 새로고침 버튼 같은 자리.cache-only — 네트워크 무시. 캐시에 없으면 그냥 비어 있다. 오프라인 모드 같은 자리.선택 기준은 단순하다. 한 데이터가 한 화면에서만 쓰이는 단순한 앱은 document cache 로 충분하다. 한 엔티티가 목록·상세·지도·공유 화면 등 여러 자리에서 동시에 쓰이는 앱은 normalized cache 가 필요하다. 내 프로젝트는 후자라 graphcache 를 쓴다. 그 4 블록의 정책을 다음 절에서 본다.
@urql/exchange-graphcache 의 설정은 네 개의 블록으로 구성된다 — keys, resolvers, optimistic, updates. 내 프로젝트의 정책은 한 파일에 모여 있다.
// src/lib/urql/graphcache.ts:13-237
export const graphcacheExchange = cacheExchange({
resolvers: { /* ... */ },
keys: { /* ... */ },
optimistic: { /* ... */ },
updates: { Mutation: { /* ... */ } }
});
네 블록은 각자 다른 시점에 호출된다. 한 화면 한 동작 — 묘비 상세 진입과 헌화 토글 — 을 따라가며 각 블록의 자리를 본다.
graphcache 의 정규화 단위는 __typename + id 다. 응답이 들어오면 graphcache 는 모든 객체를 훑으며 이 두 필드를 키로 만들어 캐시에 적재한다. 그러나 모든 타입이 id 를 갖고 있는 건 아니다. 위경도 좌표를 담는 Location 같은 값 객체는 id 가 없고, 정규화하면 오히려 충돌이 생긴다 — 좌표 (37.5, 127.0) 이 두 묘비에서 등장하면 같은 키로 묶여 한쪽이 다른 쪽을 덮어쓴다.
keys 블록은 이 예외를 명시적으로 제외한다.
// src/lib/urql/graphcache.ts:32-36
keys: {
Location: () => null,
PageInfo: () => null
}
null 을 반환하면 graphcache 는 그 타입을 정규화하지 않고 부모 객체 안에 인라인된 상태로 그대로 둔다. id 없는 값 객체는 keys 에 등록해야 한다. 등록하지 않으면 콘솔에 경고가 뜨고 캐시 정합성이 깨진다.
resolvers 는 쿼리가 들어왔을 때 캐시에서 답을 만들어 낼 수 있는지 시도하는 함수 셋이다. 내 프로젝트는 묘비 상세 쿼리에 한 함수를 둔다.
// src/lib/urql/graphcache.ts:17-27
resolvers: {
Query: {
tombstone: (parent, args, cache) => {
const entityKey = { __typename: 'Tombstone', id: args.id as string };
const key = cache.keyOfEntity(entityKey);
const cached = key ? cache.resolve(key, '__typename') : null;
return cached ? entityKey : undefined;
}
}
}
동작은 이렇다. 사용자가 목록 화면에서 묘비 카드 한 장을 받았다면 graphcache 캐시에 Tombstone:abc 가 이미 적재되어 있다. 그 묘비의 상세 화면에 진입하면 tombstone(id: "abc") 쿼리가 발행되는데, 이 resolver 가 먼저 캐시에 그 entity 가 있는지 확인한다. 있으면 entity key 를 반환해 graphcache 가 캐시에서 응답을 합성한다. 네트워크 요청은 발행되지 않는다. 없으면 undefined 를 반환해 평소대로 네트워크로 흘려 보낸다.
이 한 줄짜리 정책으로 목록 → 상세 진입의 첫 페인트가 즉시 그려진다. requestPolicy: 'cache-and-network' 와 함께 쓰면 즉시 표시 + 백그라운드 갱신이 동시에 일어난다.
헌화 버튼은 사용자 인지로는 즉시 반응해야 하지만, 실제 서버 round-trip 은 100~300ms 걸린다. optimistic 블록은 그 사이를 메운다.
// src/lib/urql/graphcache.ts:53-80
toggleRespect: (args, cache) => {
const tombstoneId = args.tombstoneId as string;
const currentCount = cache.resolve(
{ __typename: 'Tombstone', id: tombstoneId }, 'respectCount'
) as number | null;
const hasRespected = cache.resolve(
{ __typename: 'Tombstone', id: tombstoneId }, 'hasRespected'
) as boolean | null;
if (currentCount == null || hasRespected == null) return null;
return {
__typename: 'RespectResult',
tombstoneId,
respectCount: currentCount + (hasRespected ? -1 : 1),
hasRespected: !hasRespected
};
}
mutation 이 발행되는 즉시 graphcache 는 이 함수를 호출해 가짜 응답을 만들고, 그 응답으로 캐시를 미리 갱신한다. 화면은 49 → 50 으로 즉시 바뀐다. 그 뒤에 진짜 서버 응답이 오면 다음 블록인 updates 로 캐시가 확정된다. 서버가 실패하면 graphcache 가 이 가짜 갱신을 자동 롤백한다.
key 는 두 가지다. 첫째, 반환 타입은 mutation 의 실제 응답 타입(RespectResult)과 일치해야 한다 — graphcache 가 응답으로 다룰 수 있어야 하기 때문이다. 둘째, 캐시에 데이터가 없으면 null 을 반환해 optimistic 을 건너뛴다 — 가짜 응답을 만들 근거 자체가 없기 때문이다.
서버 응답이 도착하면 updates 가 호출된다. 단순한 필드 갱신은 writeFragment 한 번이면 끝난다.
// src/lib/urql/graphcache.ts:117-142
toggleRespect: (result, args, cache) => {
const data = result.toggleRespect as { /* ... */ } | null;
if (!data) return;
cache.writeFragment(
gql`fragment TombstoneRespectFields on Tombstone {
id respectCount hasRespected
}`,
{
__typename: 'Tombstone',
id: data.tombstoneId,
respectCount: data.respectCount,
hasRespected: data.hasRespected
}
);
}
writeFragment 가 받는 fragment 는 어떤 필드를 갱신할지를 graphcache 에게 알려 준다. 캐시에 이미 들어 있는 Tombstone:abc 의 respectCount / hasRespected 두 필드만 서버에서 온 진짜 값으로 덮어쓴다. 나머지 필드(title, story, location, ...) 는 건드리지 않는다.
목록 캐시 무효화도 같은 자리의 일이다. 새 묘비가 생성되면 모든 tombstones 쿼리가 stale 이 되므로 invalidate 한다.
// src/lib/urql/graphcache.ts:223-234
createTombstone: (result, args, cache) => {
const tombstone = result.createTombstone;
if (!tombstone) return;
const allFields = cache.inspectFields('Query');
allFields.forEach((field) => {
if (field.fieldName === 'tombstones') {
cache.invalidate('Query', field.fieldName, field.arguments);
}
});
}
inspectFields('Query') 로 캐시에 들어 있는 모든 tombstones(...) 호출(필터·정렬 조합 전부)을 찾고, 그 자리들을 일괄 무효화한다. 다음 렌더에 해당 쿼리가 발행되면 네트워크로 다시 받아 온다.
| 시점 | 호출되는 블록 | 하는 일 |
|---|---|---|
| 응답 적재 시 | keys | 정규화 단위 결정 (id 없는 타입 제외) |
| 쿼리 호출 시 | resolvers | 캐시에서 응답 만들 수 있는지 시도 |
| mutation 발행 직후 | optimistic | 서버 응답 전 가짜 응답으로 캐시 미리 갱신 |
| mutation 응답 도착 시 | updates | 진짜 응답으로 캐시 확정 + 관련 쿼리 invalidate |
네 블록 모두 한 파일에서 관리한다. 컴포넌트 측에서 cache.writeFragment 같은 호출을 직접 해서는 안 된다. 캐시 정책의 진원지가 두 곳으로 갈라지면 한쪽이 다른 쪽을 추월하기 시작하는 순간 디버깅 비용이 폭발한다 — graphcache 는 단일 소스로 운영해야 정합이 유지된다.
GraphQL 쿼리는 문자열이지만 TypeScript 환경에서는 그것을 타입과 묶어 두지 않으면 컴파일러가 응답 모양을 알 수 없다. 손으로 타입을 적으면 스키마가 바뀔 때마다 동기화가 깨진다. GraphQL Codegen 이 그 사이를 메운다 — 스키마와 쿼리 문서를 입력으로 받아 TypeScript 타입과 typed 헬퍼를 산출한다.
내 프로젝트의 설정은 다음과 같다.
// codegen.ts:3-35
const config: CodegenConfig = {
schema: process.env.CODEGEN_SCHEMA_URL || '../be/src/schema.graphql',
documents: ['src/**/*.tsx', 'src/**/*.ts', '!src/gql/**/*'],
ignoreNoDocuments: true,
generates: {
'./src/gql/': {
preset: 'client',
presetConfig: { fragmentMasking: false },
config: {
scalars: { DateTime: 'string', JSON: 'Record<string, unknown>', JWT: 'string' },
enumsAsTypes: true,
skipTypename: true
}
}
}
};
핵심은 preset: 'client' 다. 2024 년 이후 client preset 이 사실상의 표준이 됐고, pnpm codegen 한 번에 src/gql/graphql.ts 가 생성된다. 컴포넌트는 그 자리에서 import 해 쓴다.
client-preset 의 기본 설정은 Fragment Masking 이 활성된 상태인데, 내 프로젝트는 fragmentMasking: false 로 명시적으로 끈다. Fragment Masking 은 fragment 를 정의한 컴포넌트만 그 fragment 의 필드에 접근하도록 컴파일러로 강제하는 모델로, 대규모 앱의 데이터 일관성에는 도움이 되지만 단순 화면에서는 호출 측 코드를 한 단계 더 비워야 해서 trade-off 가 따른다. 단순성을 택했다.
scalars 는 GraphQL 스칼라(DateTime, JSON, JWT)를 TypeScript 어떤 타입으로 매핑할지 지정한다. enumsAsTypes: true 는 enum 을 union literal 타입으로 뽑는다 — enum BurialStyle 이 'COFFIN' | 'URN' | ... 형태로 나오는 식이다.
운영상 중요한 한 가지 — src/gql/ 산출물은 직접 편집하지 않는다. 다음 codegen 실행에 그대로 덮어써져 사라진다. 타입을 바꾸고 싶으면 .graphql 문서 또는 useQuery(graphql(...)) 호출 측을 고치고 pnpm codegen 을 다시 돌린다.
브라우저는 NEXT_PUBLIC_GRAPHQL_URL (보통 /api/graphql) 로 요청을 보낸다. 그 자리에는 같은 도메인에 떠 있는 BFF Route Handler 가 있고, BFF 가 인증 쿠키를 읽어 백엔드로 프록시한다. 같은 origin 이라 쿠키가 자동으로 실린다.
RSC 와 Route Handler 같은 서버 코드는 사정이 다르다. 자기 자신의 BFF 를 다시 부를 이유가 없고(같은 프로세스 안이다), 환경에 따라서는 사설망 안에서 백엔드에 직행하는 게 맞다. 내 프로젝트는 INTERNAL_GRAPHQL_URL 또는 BACKEND_URL 을 우선 사용한다.
// src/lib/urql/serverClient.ts:24-73
function makeServerClient() {
return createClient({
url: getServerGraphqlUrl(), // INTERNAL_GRAPHQL_URL || BACKEND_URL || localhost
exchanges: [cacheExchange, fetchExchange],
fetchOptions: { method: 'POST' }
});
}
export const { getClient } = registerUrql(makeServerClient);
export const getAuthenticatedClient = cache(async () => {
const cookieStore = await cookies();
const cookieHeader = cookieStore.getAll()
.map((c) => `${c.name}=${c.value}`).join('; ');
const bffUrl = process.env.NEXT_PUBLIC_APP_URL ?? 'http://localhost:3001';
return createClient({
url: `${bffUrl}/api/graphql`,
exchanges: [cacheExchange, fetchExchange],
fetchOptions: {
method: 'POST',
headers: { 'Content-Type': 'application/json', Cookie: cookieHeader }
}
});
});
두 자리가 분리된 이유는 책임이 다르기 때문이다. getClient 는 비인증 데이터(공개 묘비 목록 등)를 RSC 에서 직접 받기 위한 것으로, BFF 를 우회해 백엔드에 직행한다. getAuthenticatedClient 는 인증이 필요한 RSC 쿼리용으로, cookies() 로 쿠키를 읽어 명시적으로 헤더에 실어 자기 자신의 BFF 를 한 번 거쳐 간다 — BFF 안의 토큰 검증·refresh 로직을 그대로 활용하기 위해서다.
cache(...) 는 React 의 함수로, 같은 요청 안에서 getAuthenticatedClient 를 여러 번 불러도 클라이언트 인스턴스가 한 번만 생성되도록 메모이제이션한다.
세션이 만료된 채로 SPA 가 동작하다 mutation 이나 보호 쿼리를 발행하면 백엔드는 401 을 반환한다. urql 자체는 401 을 그냥 에러로만 다룬다 — 라우팅까지 처리해 주지 않는다. 내 프로젝트는 mapExchange 자리에 가드 함수를 둬 이 흐름을 메운다.
// src/lib/sessionGuard.ts:14-25
export function shouldRedirectToSignIn(result: OperationResult): boolean {
if (isMeOperation(result)) return false;
const status = (result.error?.response as { status?: number } | undefined)?.status;
if (status === 401) return true;
const data = result.data as { code?: string } | null | undefined;
if (data?.code === 'SESSION_EXPIRED') return true;
return false;
}
// src/lib/UrqlClientProvider.tsx:98-108
mapExchange({
onResult(result) {
if (shouldRedirectToSignIn(result) && typeof window !== 'undefined') {
routerRef.current.replace('/sign-in');
}
return result;
}
})
세 가지 디테일이 있다. 첫째, Me 쿼리는 401 을 에러가 아니라 "비로그인"의 정상 신호로 다뤄야 하므로 가드에서 명시적으로 제외한다. 둘째, 백엔드는 HTTP 401 외에도 data.code === 'SESSION_EXPIRED' 같은 GraphQL 레벨 에러로도 만료를 알릴 수 있어 두 채널을 모두 본다. 셋째, routerRef.current 로 router 를 참조하는 이유는 exchange 가 client 생성 시점의 클로저를 잡고 있어서 — ref 를 거치지 않으면 stale router 를 부른다.
GraphQL 은 한 문장으로 줄이면 "필요한 것만 받기" 다. urql 은 그 흐름을 어떻게 흘려보낼지를 명시적으로 짜는 라이브러리고, 핵심은 exchange 배열 — 캐시·SSR·인증·재시도·트랜스포트가 그 자리에 들어간다.
다음으로 더 들여다볼 만한 영역은 셋이다.
resolvers 를 직접 작성해 cursor 페이지네이션을 캐시 위에서 무한 스크롤로 합치기.@urql/next 의 SSR 통합과 registerUrql 이 hydrate 데이터를 직렬화하는 경로.