본문 바로가기
Projects,Activity/Spotify

Apollo client의 캐시가 Next.js를 만나면: SSR, RSC + Next.js 15의 바뀐점

by 그냥하는거지뭐~ 2024. 12. 4.
목차
- Apollo client의 Normalization cache
- SSR에서의 in-memory cache 사용이 data leak를 초래하진 않을까?
- Server, Client의 캐싱 동기화 메커니즘
- RSC에서의 캐싱
- 마치면서
- 번외) Next.js 15에서 바뀐점

 

Apollo client를 next.js 프로젝트에 적용하면서, client side에서의 캐싱뿐 아니라 SSR, RSC에서 어떻게 캐싱 시스템이 동작하는지, 그리고 next에는 이미 훌륭한 캐싱 메커니즘이 built-in 되어 있는데, apollo client와 어떻게 시너지가 나는지 공부할 필요성을 느꼈다. 이것저것 알아보니 생각보다 복잡했고, 이해하는 데에 시간이 오래 걸렸다. 아직 완벽하게 이해한 건 아니지만, 지금까지 이해한 것을 정리해보고자 한다.


1. Apollo client의 Normalization cache

1.1. 컨셉 이해하기

Apollo client cache에 관한 공식 문서를 보면, 'Normalized'라는 키워드가 자주 등장한다. 

https://www.apollographql.com/docs/react/caching/overview

 

Apollo Client는 GraphQL 응답을 Normalization해서, 데이터 단위(entity-level)로 캐싱한다. 이를 통해 하나의 쿼리로 가져온 데이터가 다른 쿼리에서도 재사용될 수 있기 때문에 중복적인 API call을 줄일 수 있다. 

 

다음과 같은 두 개의 쿼리가 있다고 가정해 보자.

query GetBook1 {
  book(id: "book1") {
    id
    title
    author {
      id
      name
    }
  }
}

query GetBook2 {
  book(id: "book2") {
    id
    title
    author {
      id
      name
    }
  }
}

 

Normalization 하지 않으면 다음과 같이 저장될 것이다. 

{
    "book(id:\"book1\")": {
        "id": "book1",
        "title": "GraphQL part 1",
        "author": {
            "id": "author1",
            "name": "Alice"
        }
    },
    "book(id:\"book2\")": {
        "id": "book2",
        "title": "GraphQL part 2",
        "author": {
            "id": "author1",
            "name": "Alice"
    	}
    }
}

즉, 동일한 저자 이름(Alice)이 두 번 저장됐다. 

 

하지만 normalization을 거친다면, 다음과 같이 중복 없이 저장된다. 

{
  "Book:book1": {
    "__typename": "Book",
    "id": "book1",
    "title": "GraphQL part 1",
    "author": { "__ref": "Author:author1" }
  },
  "Book:book2": {
    "__typename": "Book",
    "id": "book2",
    "title": "GraphQL part 2",
    "author": { "__ref": "Author:author1" }
  },
  "Author:author1": {
    "__typename": "Author",
    "id": "author1",
    "name": "Alice"
  }
}

 

 

1.2. normalization 과정

Apollo client는 자바스크립트 객체를 사용하여 key-value pair로 데이터를 캐싱한다. 예를 들어, 다음과 같은 쿼리가 있을 때,

// graphql query
query {
  book(id: "book1") {
    id
    title
    author {
      id
      name
    }
  }
}

// graphql response
{
  "data": {
    "book": {
      "id": "book1",
      "title": "GraphQL Basics",
      "author": {
        "id": "author1",
        "name": "Alice"
      }
    }
  }
}

 

STEP 1: 쿼리 결과를 개별 객체(Entity)로 분할

  • book 객체: {id, title, author}
  • author 객체: {id, name}

STEP 2: 각 객체에 고유한 식별자를 할당

기본적으로 __typename id 필드를 조합하여 식별자를 만든다. 이 식별자가 cache 객체의 key로 사용되고, 이를 이용해서 데이터를 빠르게 검색할 수 있다. 

  • book 객체의 식별자: Book:book1
  • author 객체의 식별자: Author:author1

 

STEP 3: 객체들을 flat한 데이터 구조로 저장

모든 데이터를 key-value 구조로 저장해서, 중첩된 구조를 평면화한다.

{
  "Book:book1": {
    "id": "book1",
    "title": "GraphQL Basics",
    "author": { "__ref": "Author:author1" }
  },
  "Author:author1": {
    "id": "author1",
    "name": "Alice"
  }
}
  • author 필드는 Author:author1을 참조(__ref)함 

이를 통해 Author 데이터가 여러 곳에서 사용되더라도 Author:author1을 참조하므로 중복 데이터가 없다. Apollo는 이 캐시를 기반으로 다음과 같이 동작한다.

  • book 데이터를 요청: Book:book1에서 바로 검색
  • author 데이터를 요청: Book:book1의 author를 통해 Author:author1 참조

 

1.3. 데이터 업데이트 시 동작

// request
mutation {
  updateAuthor(id: "author1", name: "Alice Smith") {
    id
    name
  }
}

// response
{
  "data": {
    "updateAuthor": {
      "id": "author1",
      "name": "Alice Smith"
    }
  }
}

// cache update
{
  "Author:author1": {
    "id": "author1",
    "name": "Alice Smith"
  }
}

이렇게 변경된 데이터는 Book:book1의 author를 참조하는 모든 UI 컴포넌트에 즉시 반영된다. 즉, 추가적인 네트워크 요청 없이 최신 데이터를 표시할 수 있다. 

 

1.4. Normalization의 장점 정리

① 중복 제거: 동일한 데이터가 여러 번 저장되지 않는다. 따라서 메모리를 효율적으로 사용하고, 데이터 불일치를 방지할 수 있다. 

② 빠른 검색: key-value 구조로 flat하게 저장되어 있으므로, key 기반으로 빠르게 검색할 수 있다. 

③ UI의 자동 업데이트: 캐시된 데이터가 업데이트되면, Apollo는 자동으로 관련된 UI를 다시 렌더링한다. mutation 결과가 캐시에 반영되면, 연관된 쿼리가 추가적인 요청 없이 UI가 갱신된다. 

④ 네트워크 요청 감소: 변경된 데이터만 갱신하므로, 성능 최적화 면에서도 좋다. 

 

이제 이 말이 이해가 된다. 


2. SSR에서의 in-memory cache 사용이 data leak를 초래하진 않을까?

Apollo client는 캐시를 in-memory cache에 저장한다. SSR에서 in-memory cache를 사용한다면, 유저 간 캐시가 공유돼서 data leak가 발생할 수 있지 않을까? 하는 궁금증이 생겼다.

 

Apollo 팀의 발표 내용에서 해답을 발견할 수 있었는데, 

https://www.youtube.com/watch?v=buhHZksGM84&t=1123s

궁금했던 것이 맞았고, 이분 말에 따르면 이 문제를 해결하기 위해서 각 요청마다 새로운 Apollo Client 인스턴스를 생성한다고 한다. 이를 통해 각 요청은 독립적인 캐시를 가지게 된다. 

 


3. Server, Client의 캐싱 동기화 메커니즘

Apollo Client는 서버와 클라이언트 양쪽에서 in-memory cache를 사용하는데, 이 두 공간은 물리적으로 다른 공간이다. 서버의 캐시는 서버 메모리에 저장되고, 클라이언트 캐시는 브라우저 메모리에 저장된다. 서로 다른 공간에 존재하는 캐시가 어떻게 동일한 데이터를 상태를 유지하는지 궁금해졌다. 

Next.js 레포에서 next+apollo를 사용한 예시 코드를 발견했는데, 여기서 힌트를 찾을 수 있었다. 

https://github.com/vercel/next.js/blob/main/examples/api-routes-apollo-server-and-client/pages/index.tsx

 

STEP 1: 서버에서 캐시 초기화

서버에서 GraphQL 쿼리를 실행하고, 결과 데이터를 Apollo Client의 캐시에 저장한다. 이 데이터를 클라이언트로 전달함으로써 캐시를 초기화한다.

import { ApolloClient, InMemoryCache, gql } from "@apollo/client";

const client = new ApolloClient({
  uri: "https://example.com/graphql",
  cache: new InMemoryCache(),
});

const data = await client.query({
  query: gql`
    query GetViewer {
      viewer {
        id
        name
        status
      }
    }
  `,
});

const initialCache = client.extract(); // 캐시 데이터를 JSON 형태로 추출

 

STEP 2: Client로 캐시 전달

서버에서 추출한 캐시 데이터를 HTML 렌더링 결과에 포함하여 클라이언트로 전달한다. 

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

  await apolloClient.query({
    query: ViewerQuery,
  });

  return {
    props: {
      initialApolloState: apolloClient.cache.extract(),
    },
  };
}

 

STEP 3: Client에서 캐시 복원

클라이언트는 전달받은 initialApolloState를 기반으로 Apollo Client를 초기화한다. 이를 통해 서버에서 가져온 데이터를 그대로 사용할 수 있다. 

import { ApolloClient, InMemoryCache } from "@apollo/client";

const client = new ApolloClient({
  uri: "https://example.com/graphql",
  cache: new InMemoryCache().restore(window.__APOLLO_STATE__),
});

 


4. RSC에서의 캐싱 

RSC의 주요한 목적은 서버에서 컴포넌트를 렌더링하고 그 결과를 클라이언트로 전송하는 것이다(HTML streaming). 그래서 stateless하고, apollo client의 in-memory cache는 애초에 브라우저 환경에서 상태를 유지하면서 동작하도록 설계되었기 때문에 RSC에서는 사용할 수 없다. 대신 Next.js의 캐싱 시스템을 사용한다. 

그렇다면, 두 캐시가 애초에 다른 방식으로 저장되기 때문에 서로 공유될 수는 없는 것은 당연해 보인다. apollo 팀에서도 이건 trade-off고, RSC에서의 사용은 make sense할 때만 사용하라고 한다. 

https://www.youtube.com/watch?v=buhHZksGM84&t=1123s

experimental 패키지에서도 RSC와 SSR의 use cases는 독립적이다고 명시되어 있다. 조심히 사용해야 할 것 같다..

https://www.npmjs.com/package/@apollo/experimental-nextjs-app-support


5. 마치면서

React의 렌더링 패러다임이 CSR > SSR > Suspense in SSR > RSC로 발전해 온 것에, Apollo Client가 발맞추어 나가는 과정을 지켜보는 느낌이었다. @apollo/experimental-nextjs-app-support 패키지가 서버 컴포넌트에서의 data fetching, client side hydration, 그리고 streaming SSR 지원 등 여러 과제를 해결하고 있지만, 아직 완벽하다고는 볼 수 없을 것 같다. RSC에서 in-memory cache를 직접 활용하기 어려운 점, 서버와 클라이언트 간의 상태 동기화 문제, 그리고 Suspense와의 통합 과정에서 발생할 수 있는 레이스 컨디션 등 여러 과제가 남아있다. 이게 프레임워크 수준에서의 변화가 필요할 수도 있어서 next step이 어떻게 될지 궁금해진다. 


번외) Next.js 15에서 바뀐점

다른 프로젝트에서는 cache hit가 잘 찍혔던 것 같은데 이번 프로젝트에서는 왜 자꾸 cache skip되는지 알 수가 없었다. 

그런데.. 

https://nextjs.org/blog/next-15-rc#fetch-requests-are-no-longer-cached-by-default

하하.. Next 15부터는 디폴트가 no-store이라고 한다. 공식문서는 주기적으로 봐줘야하나보다.. 

fetchOption에 cache: 'force-cache'를 추가해주니 이제 잘된다!