Apollo Client
Apollo Client는 클라이언트 환경에서 GraphQL을 사용하여 서버와 데이터를 효율적으로 주고받을 수 있도록 돕는 강력한 라이브러리이다. 주로 React를 비롯한 다양한 프레임워크와 잘 통합되며, 클라이언트에서 발생하는 요청을 효율적으로 관리하고, 데이터 상태를 예측 가능하게 유지하는 데 도움을 준다.
Apollo Client가 제공하는 useQuery와 useMutation은 React Query의 그것과 유사하게 동작한다. 중복된 요청을 캐싱하여 네트워크 사용을 최소화하고, 데이터의 일관성을 유지하는 데 도움을 준다. 서버 상태를 관리하기 위한 이 두 도구는 각자 가진 장점이 달라서, 일반적인 REST API 요청의 경우에는 React Query를 쓰고, GraphQL 요청의 경우에는 Apollo Client를 쓸 것 같다.
Apollo Client 생성
여기서 이야기하는 client는 React Client의 QueryClient와 같은 일을 하는 녀석이다. GraphQL 요청에 대한 여러 설정을 이곳에서 처리하게 된다. 아쉽게도 Apollo Client의 client는 React Client의 QueryClient와 다르게 전역적으로 retry를 설정할 수도 없고 ─ apollo-link-retry 라이브러리를 사용하면 가능하긴 하다 ─ staleTime 역시 각 useQuery에서 별도로 선언해야 한다.
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;
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는 gql이라는 함수를 사용해 쿼리 리터를을 파싱하고, 이를 변수에 할당한다. gql 없이도 변수에 쿼리 리터럴을 할당할 수 있지만, gql을 사용해야만 추후 살펴볼 자동화된 타입 생성 기능을 활용할 수 있다.
const GET_BOOK_QUERY = gql`
query getBook($isbn13: String!) {
book: getBook(isbn13: $isbn13) {
isbn13
title
cover
description
}}`
공통된 요소를 리턴받고 싶다면 아래와 같이 fragment를 활용할 수 있다.
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에는 쿼리 리터럴과 함께 옵션 객체를 제공할 수 있다. 제공할 수 있는 옵션에 대해서는 공식 문서에 자세히 나와있지만, 나는 variables와 pollInterval 외에 다른 옵션을 제공해본 적이 없다. variables는 쿼리 리터럴에 선언했던 변수를 제공하는 것이며, pollInterval은 특정 시간이 지나면 자동으로 데이터를 다시 조회하도록 만드는 일종의 stateTime이라고 생각하면 되겠다.
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가 추가하는 Tagged Union 같은 것이다.
useMutation
useMutation에도 쿼리 리터럴과 함께 옵션 객체를 제공할 수 있다. 여기에는 위에서 살펴본 variables와 함께 refetchQueries와 update를 제공할 수 있다. refetchQueries의 경우 queryClient.invalidateQueries와 비슷하게 캐시를 invalidate하고 새롭게 값을 가져오도록 하며, update는 낙관적 업데이트와 같이 직접 캐시 내부를 수정해야 하는 일이 있을 때 사용하게 된다.
리턴하는 값은 두 개짜리 튜플인데, 0번에는 mutate 함수가 들어오고, 1번에는 loading, error, data와 함께 이들을 idle 상태로 초기화할 수 있는 reset 함수가 들어있는 객체가 들어온다.
const [patchBook, { loading, error, data, reset }] = useMutation<bookMutation, bookMutationVariables>(
PATCH_BOOK_MUTATION,
{
variables: {
bookInput: {
isbn13: '9788950993283', // 업데이트할 책의 ID
title: '좋은 책', // 업데이트할 책의 새로운 제목
}
},
refetchQueries: [
{
query: GET_BOOK_QUERY,
variables: { isbn13: "9788950993283" }
}
],
update: (cache, { data }) => {},
}
);
재미있는 것은 옵션 객체를 useMutation 대신 mutate 함수에 제공할 수도 있다는 점이다. 게다가 이 옵션은 양쪽에 나눠서 제공할 수도 있다. 따라서 update나 refetchQueries 등은 useMutation 함수를 호출할 때 제공하고, variables는 나중에 mutate 함수를 호출할 때 제공하는 식으로 사용할 수도 있다. optimisticResponse 프로퍼티의 경우 useMutation이 리턴할 것으로 예상되는 데이터를 미리 집어둘 수 있도록 하는 것으로, 낙관적 업데이트와는 결이 조금 다르다.
const [patchBook, { loading, error, data, reset }] = useMutation<bookMutation, bookMutationVariables>(
PATCH_BOOK_MUTATION
);
return <button onClick={() => {patchBook({
variables: {
bookInput: {
isbn13: '9788950993283', // 업데이트할 책의 ID
title: '좋은 책', // 업데이트할 책의 새로운 제목
}
},
refetchQueries: [
{
query: GET_BOOK_QUERY,
variables: { isbn13: "9788950993283" }
}
],
awaitRefetchQueries: true,
update: (cache, { data }) => {},
})}}>버튼</button>
그 밖에도 onError와 함께 onSuccess와 같은 일을 하는 onCompleted 등의 프로퍼티를 제공할 수 있다.
자동 타입 및 함수 생성
이렇게만 놓고보면 어쩐지 React Query의 useQuery나 useMutation보다 나을 게 없어보이고, 그냥 React Query로 GraphQL 요청을 보내고 캐시하면 되는 거 아닐까 하는 생각이 들 수도 있다. 하지만 Apollo Client의 가장 강력한 기능은, 앞서 언급했던 것과 같이 "서버가 가지고 있는 스키마 및 리졸버 함수"를 사용하여 각종 타입 및 useQuery, useMutation를 만들어낼 수 있다는 것이다.
graphql-codegen 라이브러리 및 각종 플러그인을 설치한 다음 프로젝트 최상단에 codegen.yml 문서를 아래와 같이 만들어준다. npx graphql-codegen을 실행하면 알아서 코드를 생성한다. codegen.yml 문서의 내용을 살펴보자면 백엔드의 api에 schema를 요청하고, src/schema 폴더 내부에 선언한 여러 쿼리 리터럴을 가져다가 쓰겠다는 소리다. 이 중 타입 관련 코드는 types/graphql.ts 위치에 생성되고, 각종 훅은 hooks/graphql.ts 위치에 생성된다.
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"
types/graphql.ts를 살펴보면 아래와 같이 자동으로 생성된 타입들을 확인할 수 있다.
아래는 hooks/graphql.ts에 자동 생성된 커스텀 훅의 일부이다. 하나의 쿼리 리터럴로부터 세 개의 커스텀 훅이 생성된 것을 알 수 있다. useGetBookQuery는 일반적인 쿼리를 실행하는 데 사용되며, 컴포넌트가 렌더링될 때 자동으로 쿼리를 실행한 뒤 쿼리의 결과를 반환한다. useGetBookLazyQuery는 execute 함수를 반환하는데, 이를 통해 컴포넌트를 수동으로 실행할 수 있다는 점 및 두 개 짜리 튜플을 리턴한다는 점에서 useMutation과 유사하다고 할 수 있다. useGetBookSuspenseQuery는 React Suspense와 함께 사용하기 위한 커스텀훅이다.
useQuery를 사용할 때는 이렇게 data의 타입과 variables의 타입을 명시하고, 어떤 쿼리 리터럴을 사용할지 정해주어야 한다. 하지만 자동 생성된 커스텀 훅의 경우 내부적으로 이런 과정(타입 연결 및 쿼리 리터럴 지정)을 자동으로 처리해놓은 덕분에 훨씬 간단하게 데이터를 가져다 사용할 수 있다.
const { data } = useQuery<GetBookQuery, GetBookQueryVariables>(
GET_BOOK_QUERY,
{ variables: { isbn13: "9788950993283" } }
)
const { data } = useGetBookQuery({ variables: { isbn13: "9788950993283" } })
+ 코드에 투자해야 할 초기 비용이 REST API에 비해 많은 거 같기는 한데, 일단 한 번 돌아가기 시작하면 REST API에 비해 신경쓸 게 덜하다고 느껴진다. 특히 타입 관련해서 내가 따로 하나씩 만들어줄 필요 없이 자동화 시킬 수 있다는 점이 굉장히 매력적이다.
블로그의 정보
Ayden's journal
Beard Weard Ayden