본문 바로가기

React

React와 GraphQL Apollo Client 연동하기

React에서 GraphQL을 사용한다면 Apollo Client는 요청, 캐싱, 상태 관리까지 깔끔하게 해결해주는 표준 도구입니다. 실무에서 바로 쓰기 좋은 최소 설정과 패턴을 정리합니다.

1. 설치

GraphQL 스키마가 준비되어 있다는 가정하에 Apollo Client와 graphql 패키지를 설치합니다.

// npm
yarn add @apollo/client graphql
// 또는
npm install @apollo/client graphql

2. Apollo Client와 Provider 설정

HTTP 링크, 캐시, 기본 fetch 정책을 설정하고 애플리케이션 루트에 ApolloProvider를 연결합니다.

// src/apollo.js
import { ApolloClient, InMemoryCache, HttpLink } from '@apollo/client'

const httpLink = new HttpLink({ uri: 'https://api.example.com/graphql' })

export const client = new ApolloClient({
  link: httpLink,
  cache: new InMemoryCache(),
  defaultOptions: {
    watchQuery: { fetchPolicy: 'cache-and-network' },
    query: { fetchPolicy: 'network-only' }
  }
})
// src/main.jsx
import React from 'react'
import { createRoot } from 'react-dom/client'
import { ApolloProvider } from '@apollo/client'
import { client } from './apollo'
import App from './App'

createRoot(document.getElementById('root')).render(
  <ApolloProvider client={client}>
    <App />
  </ApolloProvider>
)

3. 인증 헤더와 에러 처리 추가

토큰을 헤더에 붙이고 GraphQL/네트워크 에러를 한곳에서 로깅합니다.

// src/apollo.js (확장)
import { ApolloClient, InMemoryCache, HttpLink } from '@apollo/client'
import { setContext } from '@apollo/client/link/context'
import { onError } from '@apollo/client/link/error'

const httpLink = new HttpLink({ uri: 'https://api.example.com/graphql' })

const authLink = setContext((_, { headers }) => {
  const token = localStorage.getItem('token')
  return {
    headers: {
      ...headers,
      authorization: token ? `Bearer ${token}` : ''
    }
  }
})

const errorLink = onError(({ graphQLErrors, networkError }) => {
  graphQLErrors?.forEach(({ message, path }) => {
    console.error('[GraphQL error]', message, path)
  })
  if (networkError) console.error('[Network error]', networkError)
})

export const client = new ApolloClient({
  link: errorLink.concat(authLink).concat(httpLink),
  cache: new InMemoryCache()
})

4. 쿼리: 목록과 페이지네이션

useQuery 훅으로 데이터 조회, fetchMore로 커서 기반 페이지네이션을 구현합니다. 캐시 병합 규칙을 추가하면 깔끔하게 이어붙일 수 있습니다.

// src/features/posts/queries.js
import { gql, useQuery } from '@apollo/client'

export const GET_POSTS = gql`
  query Posts($after: String) {
    posts(after: $after, first: 10) {
      items { id title }
      pageInfo { endCursor hasNextPage }
    }
  }
`

export function PostList() {
  const { data, loading, error, fetchMore, refetch } = useQuery(GET_POSTS)
  if (loading) return <p>로딩...</p>
  if (error) return <p>에러: {error.message}</p>

  const { items, pageInfo } = data.posts

  return (
    <>
      <ul>
        {items.map(p => <li key={p.id}>{p.title}</li>)}
      </ul>
      {pageInfo.hasNextPage && (
        <button onClick={() => fetchMore({ variables: { after: pageInfo.endCursor } })}>
          더 보기
        </button>
      )}
      <button onClick={() => refetch()}>새로고침</button>
    </>
  )
}
// src/apollo.js (커서 병합 규칙)
import { ApolloClient, InMemoryCache } from '@apollo/client'

export const client = new ApolloClient({
  link: /* ... */,
  cache: new InMemoryCache({
    typePolicies: {
      Query: {
        fields: {
          posts: {
            keyArgs: false, // after/first를 키로 쓰지 않고 수동 병합
            merge(existing = { items: [] }, incoming) {
              return {
                ...incoming,
                items: [ ...(existing.items || []), ...incoming.items ]
              }
            }
          }
        }
      }
    }
  })
})

5. 뮤테이션: 낙관적 UI와 캐시 업데이트

useMutation으로 생성/수정 요청을 보내고 optimisticResponse로 즉시 UI를 반영합니다. cache.modify로 리스트 캐시를 안전하게 갱신합니다.

// src/features/posts/mutations.js
import React from 'react'
import { gql, useMutation } from '@apollo/client'

const CREATE_POST = gql`
  mutation CreatePost($input: CreatePostInput!) {
    createPost(input: $input) { id title }
  }
`

export function NewPostForm() {
  const [title, setTitle] = React.useState('')
  const [createPost, { loading }] = useMutation(CREATE_POST, {
    optimisticResponse: {
      createPost: { __typename: 'Post', id: `temp-${Date.now()}`, title }
    },
    update(cache, { data }) {
      const newPost = data?.createPost
      if (!newPost) return
      cache.modify({
        fields: {
          posts(existing = { items: [], pageInfo: {} }) {
            return { ...existing, items: [newPost, ...(existing.items || [])] }
          }
        }
      })
    }
  })

  const handleSubmit = async e => {
    e.preventDefault()
    await createPost({ variables: { input: { title } } })
    setTitle('')
  }

  return (
    <form onSubmit={handleSubmit}>
      <input value={title} onChange={e => setTitle(e.target.value)} placeholder="제목" />
      <button disabled={loading}>추가</button>
    </form>
  )
}

6. 프래그먼트로 필드 재사용

중복되는 필드를 프래그먼트로 묶어 쿼리/뮤테이션에서 재사용합니다. 캐시에서도 동일한 필드 구성이 유지되어 예측 가능성이 높아집니다.

import { gql } from '@apollo/client'

export const POST_FIELDS = gql`
  fragment PostFields on Post {
    id
    title
    body
  }
`

export const GET_POST = gql`
  ${POST_FIELDS}
  query Post($id: ID!) {
    post(id: $id) { ...PostFields }
  }
`

7. 성능/캐시 팁

- fetchPolicy: UI 초기 렌더는 cache-and-network로 빠르게, 중요 단건 조회는 network-only로 정확성을 확보합니다.

- BatchHttpLink: 짧은 간격의 여러 요청을 배치하여 네트워크 비용을 줄입니다.

import { ApolloClient, InMemoryCache } from '@apollo/client'
import { BatchHttpLink } from '@apollo/client/link/batch-http'

const batchLink = new BatchHttpLink({ uri: 'https://api.example.com/graphql', batchInterval: 10, batchMax: 10 })

export const client = new ApolloClient({ link: batchLink, cache: new InMemoryCache() })

- Field policy: 리스트 병합/고유 키 관리로 중복과 깜빡임을 줄입니다.

- DevTools: Apollo Client DevTools로 캐시 상태를 확인하며 업데이트 로직을 검증합니다.

8. 디버깅과 흔한 이슈

- 타입 미스매치: optimisticResponse의 __typename, 필드 구조가 실제 스키마와 달라지면 캐시 오류가 발생합니다.

- keyArgs 설정: 페이지네이션 시 keyArgs를 잘못 두면 페이지가 덮어씌워집니다. 커서 기반이면 false로 두고 merge를 구현합니다.

- 토큰 만료: onError에서 401 처리 후 로그인으로 리다이렉트하거나 토큰 갱신 로직을 추가합니다.

- SSR/보안: 브라우저 저장소 토큰은 XSS에 취약합니다. 민감한 서비스는 쿠키 기반 인증과 HttpOnly를 고려합니다.

위 설정을 시작점으로 도메인 요구사항에 맞춰 typePolicies, fetchPolicy, 링크 체인을 조정하면 대부분의 GraphQL 클라이언트 요구사항을 안정적으로 처리할 수 있습니다.