Next) Next에서 Apollo Client 사용하기

2ast·2022년 11월 11일
2

Next에서 apollo client를 사용할 때 설정이 필요한 이유

apollo client의 강력한 기능 중 하나는 캐싱을 지원한다는 점이다. 한번 서버에 쿼리를 날려서 응답을 받으면 apollo client는 자동으로 이 결과값을 메모리에 캐싱해둔다. 그리고 그 이후에 동일한 요청이 들어오면 서버에 쿼리를 날리는 대신 캐싱해둔 데이터를 응답으로 내보내는 것이다.

캐시가 있을 때 응답 플로우 (출처: APOLLO DOCS)

문제는 SSR 환경에서 발생한다. CSR이라면 어차피 쿼리를 날리는 주체가 클라이언트이므로, 클라이언트에서 응답 결과를 메모리에 캐싱하고, 이후 동일한 요청이 들어왔을 때 캐시 데이터를 응답해줄 수 있다. 하지만 SSR에서는 쿼리를 날리는 주체가 서버가 된다. 따라서 쿼리 결과에 따른 캐시를 클라이언트로 전달해줄 필요성이 생긴다. 이번에 해볼 작업은 서버의 캐시를 클라이언트로 전달하여 이를 클라이언트 캐시에 통합할 수 있도록 설정하는 과정이 될 예정이다. 참고로 Next js에서 권장하는 방법과 Apollo에서 권장하는 방법이 각각 다르다. 이번에 해볼 방법은 Next에서 권장하는 방법을 기반으로 Kellen Mace라는 분이 제안한 코드를 재현한 것이다.

기본 컨셉

먼저 구현에 들어가기 전에 컨셉에 대해서 간략하게 정리해보자면 다음과 같다.

  1. getStaticProps(또는 getServerSideProps) 함수 내부에서 새로운 apollo client를 초기화하고 이 client를 이용해 graphql 쿼리를 날린다. (이 요청은 서버에서 실행된다.)
  2. 응답을 성공적으로 받았다면 apollo client는 응답 받은 값을 캐싱한다.
  3. 서버 단의 apollo client에서 cache를 추출하여 props로 반환한다.
  4. pages/_app.js 파일에서 pageProps 형태로 서버에서 넘겨준 cache를 받고, 이를 기존에 갖고 있던 클라이언트 단의 apollo client 캐시와 병합한다.

구현에 필요한 함수 정의하기

Requirement

먼저 이 과정을 구현하기 위해 필요한 함수들을 구현할 것이다. next js와 apollo client 외에 deepmerge와 lodash 라이브러리가 필요하니, 미리 설치해두면 좋다. 그리고 테스트 api로는 각 국가의 정보를 리턴해주는 "https://countries.trevorblades.com" 을 사용할 예정이다.

yarn add deepmerge lodash

구현하기

먼저 원하는 경로에 함수들을 구현할 파일을 하나 생성한다. 나는 타입스크립트를 사용하므로 프로젝트 루트에 apolloClient.tsx 파일을 생성해주었다. 그리고 파일에 정의할 함수들의 최종 모습은 다음과 같다.

import { useMemo } from "react";
import {
  ApolloClient,
  InMemoryCache,
  NormalizedCacheObject,
} from "@apollo/client";
import merge from "deepmerge";
import isEqual from "lodash/isEqual";

export const APOLLO_STATE_PROP_NAME = "__APOLLO_STATE__";

let apolloClient: ApolloClient<NormalizedCacheObject> | undefined;

function createApolloClient() {
  return new ApolloClient({
    ssrMode: typeof window === "undefined",
    uri: "https://countries.trevorblades.com",
    cache: new InMemoryCache()
  });
}

export function initializeApollo(
  initialState: NormalizedCacheObject | null = null
) {
  const _apolloClient = apolloClient ?? createApolloClient();

  if (initialState) {
    const existingCache = _apolloClient.extract();
    const data = merge(initialState, existingCache, {
      arrayMerge: (destinationArray, sourceArray) => [
        ...sourceArray,
        ...destinationArray.filter((d) =>
          sourceArray.every((s) => !isEqual(d, s))
        ),
      ],
    });

    _apolloClient.cache.restore(data);
  }

  if (typeof window === "undefined") return _apolloClient;

  if (!apolloClient) apolloClient = _apolloClient;

  return _apolloClient;
}

export function addApolloState(
  client: ApolloClient<NormalizedCacheObject>,
  pageProps: any
) {
  if (pageProps?.props) {
    pageProps.props[APOLLO_STATE_PROP_NAME] = client.cache.extract();
  }

  return pageProps;
}

export function useApollo(pageProps: any) {
  const state = pageProps[APOLLO_STATE_PROP_NAME];
  const store = useMemo(() => initializeApollo(state), [state]);
  return store;
}

createApolloClient

새로운 apollo client 객체를 만들어 리턴해주는 역할을 수행한다.

initializeApollo

apollo client를 초기화해주는 함수이다. 이 함수내부에서 사용하는 apollo client는 파일 루트에서 let으로 선언한 apolloClient가 undefined라면(initializeApollo가 한번도 호출된 적 없다면) createApolloClient 함수를 실행해서 새로운 client 객체를 사용한 뒤, 파일 루트에 선언한 apolloClient에 그 값을 할당한다. 만약 undefined가 아니라면 파일 루트에 선언한 apolloClient를 사용한다.

또한 initializeApollo는 옵셔널로 cache를 parameter로 받는데 만약 arg로 cache를 넘겨받았다면 기존 클라이언트 단의 apollo client cache와 새롭게 넘겨받은 cache를 병합해주는 역할도 추가로 수행한다.

addApolloState

apollo client와 object를 paramter로 받아 새로운 object를 반환한다. apollo client에서 cache를 추출한 뒤, object의 props 속성에 추가해주는 역할을 수행한다. getStaticProps(또는 getServerSideProps)에서 return에 해당하는 부분에서 호출되므로, object의 형태도 getStaticProps의 return type을 따른다.

useApollo

parameter로 받은 pageProps를 인수로 하여 initializeApollo를 호출한다. 이때 initialzeApollo에 argument를 주어 호출했으니, 기존 cache와 새로운 cache가 병합된 apollo client가 반환된다.

동작하는 코드로 작성하기

getStaticProps(또는 getServerSideProps) 작성하기

이제 필요한 함수들은 모두 구현이 끝났으니 실제 사용하는 과정만 남았다. 가장 먼저 pages/index.tsx 파일에서 정의하는 getStaticProps를 보자. 이곳에서 해줄 일은 딱 두가지다.

  1. 페이지 렌더링에 필요한 데이터 요청하기
  2. apollo client의 cache를 추출해서 클라이언트로 보내기

실제로 구현된 코드는 다음과 같다.

const COUNTRIS_QUERY = gql`
  query Countries {
    countries {
      code
      name
      emoji
    }
  }
`;

export async function getStaticProps() {
  const apolloClient = initializeApollo();

  const {
    data: { countries },
  } = await apolloClient.query({
    query: COUNTRIS_QUERY,
  });


  return addApolloState(apolloClient, {
    props: {},
  });
}

쿼리를 요청하기 위해 initailizeApollo를 호출해서 새로운 apollo client를 생성한다. 그 이후 쿼리를 날리고 addApolloState의 결과값을 return하는 구조로 되어있다. addApolloState는 첫 번째 argument로 받은 apollo client의 cache를 추출해서 props에 추가해주는 역할을 수행하므로, _app.tsx파일의 pageProps에서 이 cache값을 받아볼 수 있게 된다.

_app.tsx 파일 작성

export default function MyApp({ Component, pageProps }: AppProps) {
  const apolloClient = useApollo(pageProps);

  return (
    <ApolloProvider client={apolloClient}>
      <Component {...pageProps} />
    </ApolloProvider>
  );
}

코드에서 알 수 있듯이 _app.tsx는 가장먼저 useApollo를 호출한다. 이때 일어나는 일을 복습해보자면 useApollo는 내부에서 initializeApollo함수를 호출하고, 그 결과로 getStaticProps의 return값으로 넘겨받은 서버단의 cache를 기존 클라이언트단의 cache와 통합한 뒤 apollo client를 반환하는 역할을 수행하고 있다. 이렇게 함으로써 우리가 바라보는 component에서는 항상 최신화된 cache에 접근할 수 있게 되는 것이다.

실제로 데이터를 렌더링해보기

실제 데이터가 렌더링 될 pages/index.tsx 파일 내부 코드는 다음과 같다.

import { gql, useApolloClient, useQuery } from "@apollo/client";

import React, { useState } from "react";
import { addApolloState, initializeApollo } from "../apolloClient";
import Link from "next/link";

const COUNTRIS_QUERY = gql`
  query Countries {
    countries {
      code
      name
      emoji
    }
  }
`;

export default function Home(props: any) {
  const { data } = useQuery(COUNTRIS_QUERY);
  const countries = data.countries.slice(0, 4);

  return (
    <div>
      {countries.map((country: any) => (
        <div key={country.name}>{country.name}</div>
      ))}
    </div>
  );
}

export async function getStaticProps() {
  ...
}

getStaticProp의 결과로 직접적인 데이터를 받아오지는 않았지만, cache를 넘겨받아서 클라이언트 단의 cache와 통합했기 때문에, useQuery를 실행했을 때 실제로 서버에 요청을 보내는게 아니라 캐시에서 데이터를 가져올 수 있게 되었다.

이렇게 chrome 개발자 도구의 network 탭을 봐도 api 요청 없이 캐싱된 데이터를 잘 사용하고 있음을 확인할 수 있다. 더 정확한 확인을 위해 getStaticProps에 단순히 {props:{}}만을 리턴하고 network 탭을 보면

서버로 api 요청이 날아가고 있는 것도 확인할 수 있다.

typePolicies 수정하기

메인 주제에서 벗어난 얘기일지도 모르지만 사실 https://countries.trevorblades.com 에서 제공받는 데이터들은 이대로 사용하기에는 무리가 있는 형태로 제공되고 있다. 그 이유는 데이터들의 id가 없기 때문이다. 정확하게 말하자면 id 필드 대신 동일한 역할을 수행하는 code 필드가 있다.

고유한 식별자의 역할을 하는 code 필드가 있는데 뭐가 문제냐고 한다면 apollo client의 cache 정책때문에 그렇다. 실제로 방금 구현한 페이지의 cache를 살펴보면 아래와 같은 형태를 보이고 있다.

ROOT_QUERY에 모든 데이터들이 직접 입력된 형태로 존재하고 있다. 이런 모습은 이상적이지 않다. apollo client cache의 큰 장점 중 하나는 응답받은 데이터들을 model에따라 별도로 저장하고 ROOT_QUERY에서는 각 값을 참조하는 형태로 관리되기 때문에 캐시 데이터 관리에 이점이 있다는 점이기 때문이다. 이런 현상이 일어난 이유는 자동으로 데이터들을 캐싱할 때 id 필드의 여부로 분류해서 캐싱하기 때문이다. 따라서 우리는 typePolicies를 수정해서 code 필드를 기준으로 데이터를 인식하도록 설정해줄 것이다.

// apolloClient.tsx

function createApolloClient() {
  return new ApolloClient({
    ssrMode: typeof window === "undefined",
    uri: "https://countries.trevorblades.com",
    cache: new InMemoryCache({
      typePolicies: {
        Country: {
          keyFields: ["code"],  // <= here
        }
      },
    }),
  });
}

설정하는 건 간단하다. 아까 정의한 createApolloClient 함수 내부에서 InMemoryCache의 인수로 typePolicies를 정의해주면 된다. 위 코드의 내용은 Country 모델을 캐싱할 때 code 필드를 key로 설정하겠다는 뜻이다.

이렇게하고 다시 cache를 살펴보면 아래와 같이 원하던 형태로 캐싱되고 있음을 볼 수 있다.

후기

이로써 next js에서 apollo client를 사용하기 위한 설정이 완료되었다. 사실 맨 처음에도 언급했듯이 next에서 apollo client를 사용하는 방법은 이 방법만 있는 것이 아니다. 대표적으로 getDataFromTree라고 하는 apollo client에서 제공하는 메서드를 사용하는 방법이 있다. 다만 그 방법에 대해서는 아직까지 충분한 자료를 얻지 못했기 때문에 추가적으로 공부한 뒤에 기회가 된다면 추후에 다뤄볼 생각이다.

profile
React-Native 개발블로그

0개의 댓글