GraphQL + urql 사용하기

hahhhm·2026년 3월 18일

핵심 요약

  • GraphQL 은 over/under-fetching 을 해결한다 — 클라이언트가 필요한 모양을 직접 적어 보내고 서버가 그 모양으로 응답한다. 대신 캐시 메커니즘은 클라이언트 라이브러리가 자체적으로 풀어야 한다 (POST 단일 엔드포인트라 HTTP 캐시가 통하지 않는다).
  • urql 을 고른 이유 — 코어가 작고(약 7 KB), 데이터 흐름이 exchange 배열 한 줄로 표현돼 디버깅 시 흐름을 머릿속에 그리기 쉽다. 단순 화면은 document cache 로 가볍게, 캐시 공유가 필요한 화면만 graphcache 로 격상하는 점진적 도입이 가능하다.
  • Exchange 체인 6단계 — graphcache → ssr → mapExchange(401 가드) → retry → subscription → fetch. 배열 순서가 곧 책임의 순서다.
  • 캐싱은 두 옵션 — document cache(쿼리+변수 해시 키, 단순) vs normalized cache(__typename:id 키, 화면 간 캐시 공유). 내 프로젝트는 @urql/exchange-graphcache 의 4블록(keys/resolvers/optimistic/updates) 으로 정책을 한 파일에 모은다.
  • codegen + client-preset 으로 쿼리와 타입을 묶고, SSR/BFF 분기(브라우저 → BFF, RSC → 백엔드 직행)와 401 자동 redirect(mapExchange + Me 쿼리 예외) 가 프로덕션에서 가장 자주 다듬게 되는 두 자리.

1. REST 의 한계와 GraphQL 의 등장

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 의 단점은 셋이다.

  1. 학습 곡선. 스키마, 리졸버, 타입 시스템, 도구 체인(codegen, 클라이언트 라이브러리) 까지 익혀야 하는 양이 REST 의 두 배쯤 된다.
  2. N+1 부담의 이전. 프론트엔드에서 사라진 N+1 이 백엔드 리졸버에서 다시 나타난다. posts.commentCount 를 글마다 풀면 그것이 곧 N+1 쿼리다. DataLoader 같은 배칭 도구를 깔거나 쿼리 복잡도 제한을 거는 운영 비용이 새로 붙는다.
  3. 캐시가 까다롭다. REST 는 GET /users/42 가 곧 캐시 키라 브라우저, CDN, 서비스 워커가 알아서 캐시한다. GraphQL 은 모든 요청이 POST /graphql 한 엔드포인트로 가고 본문에 쿼리 문자열이 실려 가는 구조라 같은 메커니즘이 통하지 않는다.

세 번째 항목 — 캐시가 까다롭다 — 은 6 과 7 두 절에 걸쳐 다룬다.

2. GraphQL 문법 최소 셋

GraphQL 문서에는 세 종류의 작업이 있다.

  • Query — 데이터를 읽는다. 부수 효과 없음.
  • Mutation — 데이터를 쓴다. 생성·수정·삭제.
  • Subscription — 서버가 푸시하는 이벤트 스트림을 구독한다. WebSocket 위에서 동작한다.

세 작업의 문법은 동일하다. 클라이언트가 필요한 모양을 적어 보내면 서버가 그 모양으로 응답한다. 차이는 의미와 트랜스포트뿐이다.

변수

쿼리 본문에 값을 박아 넣지 않고 $이름 으로 분리한다.

query Tombstone($id: ID!) {
  tombstone(id: $id) {
    title
    respectCount
  }
}

$id 는 쿼리 시그니처(첫 줄) 에 타입과 함께 선언하고, 실제 값은 요청 body 의 variables 객체로 따로 보낸다. 이렇게 분리해야 같은 쿼리 문서가 다른 입력값으로 재사용되고, 클라이언트·서버 양쪽에서 캐시 키로 다루기에도 안정적이다.

타입 끝의 ! 는 non-null. ID! 는 "ID 타입이고 null 불가". 배열은 [Tombstone!]! 처럼 두 위치에 ! 를 따로 붙일 수 있다 — 배열 자체의 null 가능 여부와 원소 null 가능 여부가 별개로 표현된다.

fragment

여러 쿼리에서 같은 필드 셋을 반복해 쓰면 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

한 쿼리 안에서 같은 필드를 다른 인자로 여러 번 호출해야 할 때, 응답 키 충돌을 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 (query AscendedTombstones).
  • 변수 4 개 ($first, $after, $sort, $filter). 페이지 크기 $first 만 non-null, 나머지는 옵션.
  • 응답 구조는 cursor 기반 페이지네이션 표준 — edges / node / pageInfo / cursor.
  • 내부의 location { lat lng } 같은 중첩 객체는 GraphQL 의 일반 동작이다. 한 번의 응답에 트리째 받아 온다.

이 쿼리는 4 절에서 useAscendedTombstones 훅의 입력으로 다시 등장한다.

3. 왜 urql 인가

GraphQL 클라이언트 라이브러리는 React 기준 셋이 사실상 표준이다. Apollo Client, Relay, urql.

사실 비교

항목Apollo ClientRelayurql
메인테이너Apollo GraphQL (회사)MetaPhil Pluckthun · Nearform
번들 크기 (코어, gzip)약 33 KB약 30 KB+ (런타임만, 컴파일러 별도)약 7 KB
기본 캐시normalized (InMemoryCache)normalized (컴파일러 강제)document cache (옵션으로 normalized)
확장 모델ApolloLink 체인컴파일러 + 스키마 강제exchange 체인
강점도구·생태계·튜토리얼 풍부대규모 앱의 데이터 일관성작고 명시적
약점번들 크고 고급 캐시 디버깅 비용컴파일러·fragment colocation 강제직접 짜야 할 정책이 많음

번들 수치는 코어 패키지 기준이며, urql 에 graphcache 같은 부가 모듈을 더하면 그만큼 늘어난다. 정확한 값은 패키지 버전마다 변동된다.

셋의 선택 기준

  • Apollo — 풍부한 문서와 학습 자료가 필요한 팀, 캐시 정책을 깊게 손대지 않을 화면이 많은 앱에 적합하다.
  • Relay — 데이터 일관성을 컴파일러가 강제해 주길 원하는 팀. fragment colocation 과 페이지네이션 표준화가 큰 도움이 되는 대규모 앱에 어울린다.
  • urql — 번들이 작아야 하고, 데이터 흐름의 각 단계를 명시적으로 짜기를 선호하는 팀. 단순 화면은 document cache 로 가볍게 굴리고, 캐시 공유가 필요한 화면만 graphcache 로 격상하는 식의 점진적 도입이 가능하다.

세 옵션 모두 잘 만들어진 라이브러리고 어느 한쪽이 우월하지 않다. 선택 기준은 팀 규모와 캐시 요구의 깊이다.

urql 의 핵심 컨셉: Exchange 체인

urql 에서 데이터 흐름은 exchange 배열 로 표현된다.

const client = createClient({
  url: '/graphql',
  exchanges: [
    cacheExchange,
    fetchExchange,
  ],
});

exchanges 배열의 순서가 곧 파이프라인의 순서다. 배열 앞쪽에 둘수록 요청을 먼저 본다. 위 예시에서는 모든 요청이 먼저 cacheExchange 를 거쳐 캐시에 있으면 바로 반환되고, 없으면 fetchExchange 가 네트워크로 보낸다.

exchange 는 단방향 스트림 함수다. Operation 이 입력으로 들어와 OperationResult 가 출력으로 나가는 형태로 정의된다. 직접 작성할 수도 있다 — 공식 문서의 Authoring Exchanges 가이드 참고. Apollo 의 ApolloLink 와 같은 합성 모델이지만, 배열 순서가 그대로 파이프라인 순서라 디버깅 시 흐름을 머릿속에 그리기 쉽다.

내 프로젝트의 client 는 이 자리에 6 개의 exchange 를 넣는다. 어떤 순서로, 왜 그렇게 두는지는 5 절에서 한 줄씩 본다.

4. urql 기본 사용법

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 — 읽기

데이터를 읽는 자리에는 useQuery 를 쓴다. 반환은 [result, executeQuery] 튜플이다.

const [{ data, fetching, error }, reexecute] = useQuery({
  query: SOME_QUERY,
  variables: { id },
});
  • data — 응답이 도착하면 채워진다. 도착 전에는 undefined.
  • fetching — 네트워크 요청이 진행 중이면 true. 캐시 hit 으로 즉시 반환되면 false 인 채로 data 가 차 있다.
  • errorCombinedError 인스턴스. 네트워크 실패와 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 가 채워지면 목록을 렌더하면 끝이다.

pause — 조건부 쿼리

쿼리를 지금 쏘면 안 되는 시점이 있다. 변수가 아직 비어 있거나, 사용자가 패널을 열지 않은 상태거나, flyTo 같은 카메라 이동 중이라 매 프레임 변수가 바뀔 때다. 이때는 pause: true 를 넘긴다.

// src/features/map/hooks/useLiveFeed.ts:114-117
const [subscriptionResult] = useSubscription<TombstoneCreatedSubscription>({
  query: TOMBSTONE_CREATED_SUBSCRIPTION,
  pause: !enabled
});

위 코드는 구독이지만 옵션 이름과 동작은 useQuery 도 같다. pausetrue 인 동안 urql 은 요청을 발행하지 않는다. false 로 바뀌면 그 시점의 변수로 한 번 발행한다. 지도 도메인에서 뷰포트가 빠르게 변하는 동안 pause: true 로 막아 두고, 안정되면 false 로 풀어 한 번만 쏘는 식의 패턴이 자주 나온다.

useMutation — 쓰기

데이터를 바꾸는 자리에는 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 자체는 멱등이 아니라 호출 측에서 책임진다.

useSubscription — 실시간 스트림

구독은 위에서 본 useLiveFeed 가 그대로 예시다. WebSocket 으로 새 묘비 생성 이벤트를 받아 라이브 피드 패널에 흘려 넣는다. 반환은 useQuery 와 비슷한 [result] 형태고, 스트림이 도착할 때마다 result.data 가 갱신된다.

여기서 한 가지만 짚고 가면 된다. 구독은 활성 상태 동안 WebSocket 연결을 점유하므로, 꼭 켜져야 하는 시점에만 켜는 게 기본이다. pause: !enabled 로 가드를 두는 패턴이 그래서 자연스럽다. 패널이 닫히면 enabledfalse 가 되고, 구독도 함께 닫힌다.

정리

  • 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 — 가 다음 절의 주제다.

5. Exchange 체인 6단계

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' }
});

순서가 곧 의미다. 한 줄씩 뜯어보면 왜 이 위치에 있어야 하는지가 드러난다.

1. graphcache — 가장 먼저

graphcacheExchange 가 배열의 맨 앞에 있는 이유는 분명하다. 캐시는 네트워크에 가기 전에 해결되어야 한다. 같은 묘비를 두 번째로 여는 화면이 있다면 1 단계에서 끝나야 한다. 캐시 미스인 경우에만 작업이 다음 검문소로 흘러간다. optimistic 응답도 이 단계에서 발행된다 — 사용자가 헌화 버튼을 누른 직후 화면이 즉시 50 으로 갱신되는 것이 이 자리의 일이다.

2. ssr — Next.js 와의 다리

ssr exchange 는 서버에서 채워 둔 데이터를 클라이언트가 첫 렌더 시 복원하는 역할을 한다. App Router 환경에서는 RSC 가 미리 받아 둔 응답을 직렬화해 HTML 에 심고, 브라우저가 마운트되면 이 exchange 가 그 데이터를 캐시에 hydrate 한다. 위치가 graphcache 바로 다음인 이유는, hydrate 된 데이터가 캐시 layer 와 정합을 맞춰야 하기 때문이다.

3. mapExchange — 응답 후처리

mapExchange 는 응답이 컴포넌트로 돌아가는 길에 결과를 들여다본다. 내 프로젝트는 이 자리에서 401 응답과 SESSION_EXPIRED 코드를 잡아 /sign-in 으로 라우팅한다. 위치가 retry 보다 앞인 이유는, 재시도하기 전에 401 인지 판별해야 하기 때문이다. 401 을 재시도해 봐야 다시 401 이 돌아올 뿐이다.

4. retry — 네트워크 에러만

retryExchange 는 네트워크 자체가 실패한 경우에만 재시도한다. retryIf 콜백이 401 을 명시적으로 걸러 내고, error?.networkError 가 있을 때만 다시 보낸다. 위치가 fetch 직전인 이유는, 재시도가 곧 fetch 의 반복이기 때문이다.

5. subscriptionExchange — 갈림길

여기서 작업의 종류에 따라 길이 갈린다. Subscription 은 HTTP 로 보낼 수 없고 WebSocket 으로 가야 한다. subscriptionExchange 가 subscription 만 가로채 graphql-ws 클라이언트로 흘려 보내고, query/mutation 은 통과시켜 다음 단계로 넘긴다.

6. fetch — 마지막 보루

fetchExchange 가 실제 HTTP 요청을 발행한다. 여기까지 도달했다는 건 위 다섯 검문소 모두에서 처리되지 못했다는 뜻이다. 응답이 돌아오면 같은 검문소들을 역순으로 다시 거쳐 가며 (fetch → retry → map → ssr → graphcache → 컴포넌트) 캐시를 갱신하고 컴포넌트에 전달된다.

순서를 바꾸면 어떻게 되나

배열 순서가 곧 의미라는 사실은 순서를 바꿔 보면 즉시 드러난다. mapExchangeretryExchange 뒤로 옮기면 401 응답이 두 번 재시도된 뒤에야 잡혀서 sign-in 리다이렉트가 늦어진다. graphcacheExchangessr 뒤로 옮기면 hydrate 된 데이터가 정규화 캐시에 들어가기 전에 컴포넌트가 그것을 먼저 보게 되어 cache miss 가 늘어난다. 검문소의 자리는 임의가 아니라 책임의 순서다.

요약하면 한 줄이다 — 캐시 먼저, SSR 다리, 응답 검사, 재시도, 트랜스포트 분기, 그리고 네트워크.

6. urql 의 캐싱: 두 옵션 중 고르기

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 블록의 정책을 다음 절에서 본다.

7. 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: { /* ... */ } }
});

네 블록은 각자 다른 시점에 호출된다. 한 화면 한 동작 — 묘비 상세 진입과 헌화 토글 — 을 따라가며 각 블록의 자리를 본다.

keys — 무엇을 정규화할 것인가

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 — 캐시에서 데이터 끌어 쓰기

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' 와 함께 쓰면 즉시 표시 + 백그라운드 갱신이 동시에 일어난다.

optimistic — 서버 응답 전에 미리

헌화 버튼은 사용자 인지로는 즉시 반응해야 하지만, 실제 서버 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 — 서버 응답으로 캐시 확정

서버 응답이 도착하면 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:abcrespectCount / 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 는 단일 소스로 운영해야 정합이 유지된다.

8. codegen + TypeScript

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 을 다시 돌린다.

9. 프로덕션 함정 2개

SSR / BFF 분기

브라우저는 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 를 여러 번 불러도 클라이언트 인스턴스가 한 번만 생성되도록 메모이제이션한다.

401 자동 redirect

세션이 만료된 채로 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 를 부른다.

10. 마무리

GraphQL 은 한 문장으로 줄이면 "필요한 것만 받기" 다. urql 은 그 흐름을 어떻게 흘려보낼지를 명시적으로 짜는 라이브러리고, 핵심은 exchange 배열 — 캐시·SSR·인증·재시도·트랜스포트가 그 자리에 들어간다.

다음으로 더 들여다볼 만한 영역은 셋이다.

  • graphcache 의 resolvers 를 직접 작성해 cursor 페이지네이션을 캐시 위에서 무한 스크롤로 합치기.
  • @urql/next 의 SSR 통합과 registerUrql 이 hydrate 데이터를 직렬화하는 경로.
  • Persisted Query — 쿼리 문서를 해시로 치환해 네트워크 페이로드와 백엔드 보안 표면을 줄이는 방식.

References

1차 소스 — 공식 문서

비교 / 배경

Next.js 통합

한국어

0개의 댓글