들어가기에 앞서

현재 저의 블로그에선 REST API가 아닌 GraphQL을 통해서 데이터를 요청하고 있습니다.
이를 통한 데이터 요청, 응답, 관리의 용이성을 위해 Apollo Client를 사용하고 있습니다.
이번 포스팅에서는 Apollo Client에 대해 간단히 알아보고, Next.js에서 Apollo Client를 적용하는 방법을 알아보겠습니다. (추후 Next.js에 대해 정리하겠다는 떡밥👊🏻)

Apollo Client

Apollo Client는 GraphQL을 사용하여 데이터를 관리할 수 있는 조합 상태관리 라이브러리입니다.
Apollo Client를 통해 데이터를 요청하고, 캐싱하며, 상태를 관리할 수 있습니다.

Apollo Client의 캐싱

Query 요청시 초기 Flow는 다음과 같습니다.
post_image
1. 데이터 요청
2. InMemotyCache에 저장된 Cache가 없다면 서버에 요청
3. 서버에서 데이터를 받아옴
4. InMemotyCache에 데이터를 저장
5. 데이터 반환
같은 Query를 다시 요청하면 다음과 같은 Flow를 거칩니다.
post_image
1. 데이터 요청
2. InMemotyCache에 저장된 Cache 반환
다름과 같이 같은 데이터를 요청할 때마다 서버에 요청하지 않고 캐싱된 데이터를 사용할 수 있습니다.

Next.js와 Apollo Client

현재 저의 블로그에 저장된 Apollo Client + Next.js 데이터 흐름은 다음과 같습니다.
1. getServerSideProps에서 Apollo Client를 사용하여 A Query 요청
2. 응답한 데이터를 Cache에 저장
3. Component에서 A Query 요청
4. InMemotyCache에 저장된 Cache 데이터 반환
이를 통해 서버와 통신을 줄이면서 저장된 데이터를 Props를 통해 전달하지 않고 Component 내부에서 사용할 수 있습니다.
하지만 다음을 실행하기 위해 해결해야 하는 문제점이 있습니다.
getServerSideProps는 Server 환경에서 Apollo Client 객체를 생성하고 데이터를 요청, 캐싱합니다.
이에 따라 Client에서 선언된 Apollo Client 객체에 Server Apollo Client 객체에 저장된 Cache 데이터를 통합하는 과정이 필요합니다.

Next.js, Apollo Client 설치

1$ npx create-next-app@latest --typescript
2$ npm i @apollo/client deepmerge lodash
Next.js와 Apollo Client 외 deepmerge와 lodash 라이브러리를 설치합니다.

apollo.ts

먼저 원하는 경로에 Apollo Client의 코드를 작성할 apollo.ts 파일을 생성합니다. (저는 src/utils 경로에 생성했습니다.)
1import { useMemo } from 'react';
2import { AppProps } from 'next/app';
3
4import merge from 'deepmerge';
5import isEqual from 'lodash/isEqual';
6
7import { ApolloClient, HttpLink, InMemoryCache, NormalizedCacheObject } from '@apollo/client';
8
9let apolloClient: ApolloClient<NormalizedCacheObject>;
10export const APOLLO_STATE_PROP_NAME = '__APOLLO_STATE__';
11
12const createApolloClient = () => {
13    return new ApolloClient({
14        ssrMode: typeof window === 'undefined',
15        link: new HttpLink({
16            uri: 'http://localhost:3065/graphql',
17            credentials: 'include',
18        }),
19        cache: new InMemoryCache(),
20    });
21};
22
23export const initializeApollo = (initialState: NormalizedCacheObject | null = null) => {
24    const _apolloClient = apolloClient ?? createApolloClient();
25
26    if (initialState) {
27        const existingCache = _apolloClient.extract();
28
29        const data = merge(initialState, existingCache, {
30            arrayMerge: (destinationArray, sourceArray) => [
31                ...sourceArray,
32                ...destinationArray.filter((d) => sourceArray.every((s) => !isEqual(d, s))),
33            ],
34        });
35
36        _apolloClient.cache.restore(data);
37    }
38    if (typeof window === 'undefined') return _apolloClient;
39    if (!apolloClient) apolloClient = _apolloClient;
40
41    return _apolloClient;
42};
43
44export const useApollo = (pageProps: AppProps['pageProps']) => {
45    const initialState = pageProps?.[APOLLO_STATE_PROP_NAME];
46    const store = useMemo(() => initializeApollo(initialState), [initialState]);
47    return store;
48};
49
50export const addApolloState = (client: ApolloClient<NormalizedCacheObject>, pageProps: AppProps['pageProps']) => {
51    if (pageProps?.props) pageProps.props[APOLLO_STATE_PROP_NAME] = client.cache.extract();
52
53    return pageProps;
54};

createApolloClient

새로운 Apollo Client 객체를 생성합니다. 내부 옵션은 다음과 같습니다.
ssrMode: boolean
  • typeof window === 'undefined'가 true이면 server 환경입니다.
link: 서버와 통신 간 내부에 선언된 Link를 순차적으로 실행
  • server와 통신을 위한 HttpLink를 선언합니다.
  • uri: GraphQL 서버의 주소
  • credentials: 'include'를 통해 쿠키를 포함한 요청을 보낼 수 있습니다.
cache: 데이터를 저장하기 위한 Cache 저장소
  • Apollo Client의 캐싱을 위해 InMemoryCache를 사용합니다.

initializeApollo

Apollo Client 객체를 생성하고, 초기 상태를 설정합니다.
1const _apolloClient = apolloClient ?? createApolloClient();
전역 변수에 저장된 apolloClient가 존재하지 않는다면 createApolloClient 함수를 통해 새로운 Apollo Client 객체를 생성합니다.
getServerSideProps에선 apolloClient는 undefined이기 때문에 createApolloClient 함수를 통해 새로운 Apollo Client 객체를 생성합니다.
1if (initialState) {
2    const existingCache = _apolloClient.extract();
3
4    const data = merge(initialState, existingCache, {
5        arrayMerge: (destinationArray, sourceArray) => [
6            ...sourceArray,
7            ...destinationArray.filter((d) => sourceArray.every((s) => !isEqual(d, s))),
8        ],
9    });
10
11    _apolloClient.cache.restore(data);
12}
initialState가 존재한다면 기존에 저장된 Cache 데이터와 새로 요청한 데이터를 병합해야합니다.
이를 위해 기존에 저장된 Cache 데이터를 따라가면서 새로 요청한 데이터를 병합합니다.
deepmergelodash.isEqual을 사용하여 내부 데이터가 일치하지 않는 경우에만 데이터를 병합합니다.

useApollo

useMemo를 통해 State가 변경될 때 마다 initializeApollo를 실행하여 Apollo Çlient 객체를 저장합니다.

addApolloState

앞서 useApollo함수는 pageProps에서 저장된 State를 가져와 Apollo Client 객체를 생성하는 역할을 했습니다.
addApolloStategetServerSideProps에서 생성된 Apollo Client 객체의 캐시값을 pageProps에 State를 저장하는 역할을 합니다.

동작 코드 구현

셍성한 파일은 _app.tsx, index.tsx, test.tsx파일입니다.
queries/getPosts.query.tsGET_POSTS는 GraphQL 쿼리문입니다.

_app.tsx

1import { AppProps } from 'next/app';
2import { ApolloProvider } from '@apollo/client';
3import { useApollo } from '../utils/apollo';
4
5const MyApp = ({ Component, pageProps }: AppProps) => {
6    const client = useApollo(pageProps);
7
8    return (
9        <ApolloProvider client={client}>
10            <Component {...pageProps} />
11        </ApolloProvider>
12    );
13};
14
15export default MyApp;
정의한 useApollo를 통해 Apollo Client 객체를 생성하고, ApolloProvider를 통해 Apollo Client 객체에 생성한 Client를 주입합니다.
또한 페이지가 변경시 함께 변경되는 pageProps를 useApollo에 전달하여 저장된 state 변경시 Apollo Client 객체를 재생성합니다.

index.tsx

중요한 부분은 getServerSideProps이기 때문에 우선 해당 부분만 보겠습니다.
1...
2
3export const getServerSideProps: GetServerSideProps = async (ctx) => {
4    const apolloClient = initializeApollo();
5
6    await apolloClient.query({ query: GET_POSTS });
7
8    return addApolloState(apolloClient, { props: {} });
9};
10
11...
getServerSideProps함수 내부에서 initializeApollo함수를 통해 Apollo Client 객체를 생성합니다.
이후 생성한 Apollo Client 객체를 통해 GET_POSTS 쿼리를 실행하여 데이터를 받아와 캐시에 저장합니다. (캐시 저장은 내부 로직에서 자동으로 이루어집니다.)
이후 addApolloState함수를 통해 Apollo Client 객체의 캐시값을 pageProps에 저장합니다.

실행

npm run dev를 통해 서버를 실행하고 index 페이지와 test 페이지에 대해서 코드가 잘 작동하는지 확인합니다.

index 페이지

index 페이지에 코드는 다음과 같습니다.
1const Home = () => {
2    const { data, fetchMore } = useMyQuery<IGetPosts>(GET_POSTS);
3
4    return (
5        <div>
6            <Link href="/tt">테스트 페이지</Link>
7            <div>
8                {data.getPosts.posts.map((post, i) => (
9                    <div key={post._id} style={{ marginBottom: '20px', backgroundColor: '#ccc', maxWidth: '300px' }}>
10                        <div>번호: {i + 1}</div>
11                        <div>아이디: {post._id}</div>
12                        <div>제목: {post.title}</div>
13                        <div>카테고리: {post.category}</div>
14                    </div>
15                ))}
16            </div>
17        </div>
18    );
19};
20
21export const getServerSideProps: GetServerSideProps = async (ctx) => {
22    const apolloClient = initializeApollo();
23
24    await apolloClient.query({ query: GET_POSTS });
25
26    return addApolloState(apolloClient, { props: {} });
27};
post_image
사전에 예상한 flow대로 Client 측에서 캐시를 사용하여 데이터를 가져오는 것을 확인할 수 있습니다.

test 페이지

test 페이지에 코드는 다음과 같습니다.
1const Test = () => {
2    return (
3        <div>
4            <Link href="/"></Link>
5        </div>
6    );
7};
8
9export const getServerSideProps = async () => {
10    const apolloClient = initializeApollo();
11
12    const { data } = await apolloClient.query({
13        query: GET_POSTS,
14    });
15
16    return addApolloState(apolloClient, { props: { posts: data.getPosts.posts } });
17};
post_image
test 페이지 또한 Client에서 요청을 보내지 않고 cache에 등록된 data를 가져오는 것을 확인할 수 있습니다.

마치며

이번 포스팅에서는 Next.js에서 Apollo Client를 사용하는 방법에 대해 알아보았습니다. 해당 기술은 제 블로그에 적용되어 있습니다.
깃허브 레포지토리에 해당 코드가 있으니 자세한 코드가 궁금하신 분들은 확인해보시기 바랍니다.
처음 제 블로그에 글을 작성하다 보니 정리가 안된 느낌이 드네요. 다음 포스팅부터는 더 깔끔하게 정리해서 올리도록 하겠습니다☺️
오타 및 오류 지적은 환영입니다