PUBLISHED

Apollo Client

작성일: 2024.11.14

Apollo Client

Apollo Client는 클라이언트 환경에서 GraphQL을 사용해 서버와 데이터를 효율적으로 주고받을 수 있도록 돕는 강력한 라이브러리이다. 주로 React를 비롯한 다양한 프레임워크와 잘 통합되며, 클라이언트에서 발생하는 요청을 효율적으로 관리하고 데이터 상태를 예측 가능하게 유지하는 데 도움을 준다.

Apollo Client가 제공하는 useQueryuseMutation은 React Query의 그것과 유사하게 동작한다. 중복된 요청을 캐싱하여 네트워크 사용을 최소화하고, 데이터의 일관성을 유지하는 데 기여한다. 서버 상태를 관리하기 위한 이 두 도구는 각자 장단점이 명확하기 때문에, 일반적인 REST API 요청에는 React Query를 사용하고 GraphQL 요청에는 Apollo Client를 사용하는 방식이 자연스럽다고 느껴진다.

Apollo Client 생성

여기서 이야기하는 client는 React Query의 QueryClient와 유사한 역할을 한다. GraphQL 요청 전반에 대한 설정을 이 객체에서 관리하게 된다. 다만 Apollo Client의 client는 React Query의 QueryClient와 달리 전역적인 retry 설정을 기본적으로 지원하지 않는다. (apollo-link-retry 라이브러리를 사용하면 가능하긴 하다.) 또한 staleTime 역시 각 useQuery마다 개별적으로 선언해야 한다.

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

const client = new ApolloClient({
  link: new HttpLink({
    uri: process.env.NEXT_PUBLIC_BACK_END_GRAPHQL,
    credentials: 'include', // 쿠키를 포함한 요청 허용
  }),
  cache: new InMemoryCache(),
});

export default client;
untitled
JSX
import { ApolloProvider } from '@apollo/client';
import client from '@/component/graphqlClient';

export default function App({ Component, pageProps }: AppProps) {
  return (
    <ApolloProvider client={client}>
      <Component {...pageProps} />
    </ApolloProvider>
  );
}

쿼리 생성

쿼리 문법 자체는 GraphQL 공식 문서에서 확인할 수 있으므로 여기서는 다루지 않는다. Apollo Client는 gql 함수를 사용해 쿼리 리터럴을 파싱하고 이를 변수에 할당한다. gql 없이 문자열로 쿼리를 선언할 수도 있지만, 이후에 살펴볼 자동 타입 생성 기능을 활용하려면 반드시 gql을 사용해야 한다.

untitled
CSS
const GET_BOOK_QUERY = gql`
  query getBook($isbn13: String!) {
    book: getBook(isbn13: $isbn13) {
      isbn13
      title
      cover
      description
    }
  }
`;

공통된 필드를 여러 쿼리에서 재사용하고 싶다면 fragment를 활용할 수 있다.

untitled
TS
const bookFragment = gql`
  fragment bookF on Book {
    isbn13
    title
    cover
    description
  }
`;

const GET_BOOK_QUERY = gql`
  query getBook($isbn13: String!) {
    book: getBook(isbn13: $isbn13) {
      ...bookF
    }
  }
  ${bookFragment}
`;

const GET_BOOKS_QUERY = gql`
  query getBooks($isbn13s: [String!]!) {
    books: getBooks(isbn13s: $isbn13s) {
      ...bookF
    }
  }
  ${bookFragment}
`;

useQuery

Apollo Client의 useQuery는 쿼리 리터럴과 함께 옵션 객체를 인자로 받는다. 공식 문서에는 다양한 옵션이 소개되어 있지만, 실제로 자주 사용하는 것은 variablespollInterval 정도였다.
variables는 쿼리에서 선언한 변수를 전달하는 용도이고, pollInterval은 일정 주기마다 자동으로 데이터를 재요청하도록 만드는 옵션이다. React Query의 staleTime과는 개념적으로 유사하지만 동작 방식은 다르다.

untitled
CSS
const { loading, error, data, refetch } = useQuery<
  QueryType,
  QueryVariablesType
>(
  GET_BOOK_QUERY,
  {
    pollInterval: 5 * 60 * 1000,
    variables: { isbn13: '9788950993283' },
  }
);

React Query의 useQuery와 마찬가지로 내부적으로 useEffect를 사용하기 때문에, data의 타입은 QueryType | undefined로 추론된다.

useQuery는 요청 상태에 따라 사용할 수 있는 loading, error, data를 반환하며, 필요 시 수동으로 다시 요청할 수 있는 refetch 함수도 함께 제공한다. 만약 쿼리 리터럴에 변수가 선언되어 있는데 variables를 전달하지 않으면 에러가 발생한다.

variables를 올바르게 전달하면 cover, description, isbn13, title로 구성된 book 객체를 응답으로 받게 된다. 이때 응답에 포함된 __typename은 Apollo Client가 타입 식별을 위해 자동으로 추가하는 필드로, Tagged Union과 유사한 역할을 한다.

useMutation

useMutation 역시 쿼리 리터럴과 옵션 객체를 인자로 받는다. variables 외에도 refetchQueries, update 같은 옵션을 사용할 수 있다.
refetchQueries는 React Query의 queryClient.invalidateQueries와 유사하게, 특정 쿼리를 무효화하고 다시 요청하도록 만든다. update는 낙관적 업데이트처럼 캐시를 직접 수정해야 할 때 사용한다.

untitled
CSS
const [patchBook, { loading, error, data, reset }] =
  useMutation<BookMutation, BookMutationVariables>(
    PATCH_BOOK_MUTATION,
    {
      variables: {
        bookInput: {
          isbn13: '9788950993283',
          title: '좋은 책',
        },
      },
      refetchQueries: [
        {
          query: GET_BOOK_QUERY,
          variables: { isbn13: '9788950993283' },
        },
      ],
      update: (cache, { data }) => {},
    }
  );

useMutation은 두 개의 값을 가진 튜플을 반환한다. 첫 번째는 mutation을 실행하는 함수이고, 두 번째는 loading, error, data와 함께 이를 초기 상태로 되돌릴 수 있는 reset 함수를 포함한 객체이다.

흥미로운 점은 옵션 객체를 useMutation이 아니라, 반환된 mutate 함수 호출 시점에 전달할 수도 있다는 것이다. 또한 옵션을 양쪽에 나누어 제공하는 것도 가능하다. 예를 들어 updaterefetchQueries는 훅 선언 시에, variables는 mutate 함수 호출 시에 전달하는 방식으로 사용할 수 있다.

untitled
CSS
const [patchBook, { loading, error, data, reset }] =
  useMutation<BookMutation, BookMutationVariables>(
    PATCH_BOOK_MUTATION
  );

return (
  <button
    onClick={() => {
      patchBook({
        variables: {
          bookInput: {
            isbn13: '9788950993283',
            title: '좋은 책',
          },
        },
        refetchQueries: [
          {
            query: GET_BOOK_QUERY,
            variables: { isbn13: '9788950993283' },
          },
        ],
        awaitRefetchQueries: true,
        update: (cache, { data }) => {},
      });
    }}
  >
    버튼
  </button>
);

optimisticResponse 옵션을 사용하면 mutation 결과로 예상되는 데이터를 미리 지정할 수 있다. 이는 낙관적 업데이트와 유사하지만, 캐시를 직접 수정하는 update와는 결이 조금 다르다. 이 외에도 onError, onCompleted 등 lifecycle에 대응하는 콜백 옵션을 제공한다.

자동 타입 및 함수 생성

여기까지 보면 Apollo Client가 React Query보다 특별히 나을 것이 없어 보이고, 단순히 React Query로 GraphQL 요청을 보내도 되지 않나 하는 생각이 들 수도 있다. 하지만 Apollo Client의 가장 강력한 장점은 서버가 제공하는 스키마와 리졸버 정보를 기반으로 타입과 훅을 자동 생성할 수 있다는 점이다.

graphql-codegen과 관련 플러그인을 설치한 뒤, 프로젝트 루트에 codegen.yml 파일을 작성한다. npx graphql-codegen을 실행하면 설정에 따라 코드가 자동으로 생성된다.

untitled
SH
schema: process.env.NEXT_PUBLIC_BACK_END_GRAPHQL
documents: "src/schema/**/*.tsx"
generates:
  src/types/graphql.ts:
    plugins:
      - typescript
      - typescript-operations
  src/hooks/graphql.ts:
    plugins:
      - typescript-react-apollo

이 설정은 백엔드 GraphQL API로부터 스키마를 가져오고, src/schema 하위에 정의된 쿼리 리터럴들을 기반으로 타입과 커스텀 훅을 자동 생성하겠다는 의미이다. 타입 관련 코드는 types/graphql.ts에, useQuery·useMutation 기반의 커스텀 훅들은 hooks/graphql.ts에 각각 생성된다. 이를 통해 서버 스키마와 클라이언트 코드 간의 타입 정합성을 별도의 수작업 없이 유지할 수 있다.

자동 생성된 훅 파일을 살펴보면 하나의 쿼리 리터럴로부터 여러 형태의 훅이 만들어진 것을 확인할 수 있다. useGetBookQuery는 컴포넌트 렌더링 시 즉시 실행되는 일반적인 쿼리 훅이며, useGetBookLazyQuery는 execute 함수를 반환해 수동 실행이 가능하다는 점에서 useMutation과 유사한 사용성을 가진다. 또한 useGetBookSuspenseQuery는 React Suspense 환경에서 사용하기 위한 훅이다. 기존의 useQuery 방식에서는 쿼리 리터럴과 데이터 타입, 변수 타입을 모두 직접 연결해야 했지만, 자동 생성된 훅을 사용하면 이러한 과정이 내부적으로 추상화되어 훨씬 간결한 코드로 동일한 작업을 수행할 수 있다.

untitled
JS
// 쿼리 리터럴, 응답 타입, 변수 타입을 모두 직접 연결해야 한다.
// 쿼리가 늘어날수록 타입 선언과 훅 호출 코드가 반복된다.
const { data } = useQuery<GetBookQuery, GetBookQueryVariables>(
  GET_BOOK_QUERY,
  { variables: { isbn13: '9788950993283' } }
);

// 쿼리 리터럴과 타입 연결이 자동으로 처리된 커스텀 훅이다.
// 필요한 옵션만 전달하면 되므로 코드가 훨씬 간결해진다.
const { data } = useGetBookQuery({
  variables: { isbn13: '9788950993283' },
});

초기에는 REST API에 비해 코드와 설정에 투자해야 할 비용이 더 크다고 느껴질 수 있다. 하지만 한 번 구조가 잡히고 나면, 특히 타입 측면에서 신경 써야 할 부분이 크게 줄어든다. 서버 스키마를 기준으로 타입과 훅을 자동화할 수 있다는 점은 Apollo Client가 가진 가장 큰 매력이라고 생각한다.