목차
- 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'라는 키워드가 자주 등장한다.
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 팀의 발표 내용에서 해답을 발견할 수 있었는데,
궁금했던 것이 맞았고, 이분 말에 따르면 이 문제를 해결하기 위해서 각 요청마다 새로운 Apollo Client 인스턴스를 생성한다고 한다. 이를 통해 각 요청은 독립적인 캐시를 가지게 된다.
3. Server, Client의 캐싱 동기화 메커니즘
Apollo Client는 서버와 클라이언트 양쪽에서 in-memory cache를 사용하는데, 이 두 공간은 물리적으로 다른 공간이다. 서버의 캐시는 서버 메모리에 저장되고, 클라이언트 캐시는 브라우저 메모리에 저장된다. 서로 다른 공간에 존재하는 캐시가 어떻게 동일한 데이터를 상태를 유지하는지 궁금해졌다.
Next.js 레포에서 next+apollo를 사용한 예시 코드를 발견했는데, 여기서 힌트를 찾을 수 있었다.
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할 때만 사용하라고 한다.
experimental 패키지에서도 RSC와 SSR의 use cases는 독립적이다고 명시되어 있다. 조심히 사용해야 할 것 같다..
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되는지 알 수가 없었다.
그런데..
하하.. Next 15부터는 디폴트가 no-store이라고 한다. 공식문서는 주기적으로 봐줘야하나보다..
fetchOption에 cache: 'force-cache'를 추가해주니 이제 잘된다!
'Projects,Activity > Spotify' 카테고리의 다른 글
GraphQL 적응기 - RESTful 마인드셋에서 벗어나기 위한 몸부림 (4) | 2024.12.07 |
---|---|
Optimization(1): Throttle & Debounce (4) | 2024.12.05 |
쿠키가 도착하지 않는 이유- Express, Next.js, Browser 간 쿠키 전달 (1) | 2024.11.19 |
Spotify API와 자체 DB를 통합한 인증 시스템 - JWT+JWT vs Session+JWT (0) | 2024.11.06 |