Apollo Client란
나는 회사에서 React + GQL 기반의 웹 프로덕트를 많이 만들고 있고, 그 때 데이터를 관리 도구로 Apollo Client
를 사용한다. 많은 라이브러리 중에서 Apollo Client를 사용하는 이유는 캐싱이 잘 되어있고, 데이터를 선언적으로 접근하기 때문에 적은 코드로 생산성 높은 개발을 할 수 있다는 장점을 가지고 있다.
Apollo Client에서 캐싱은 정규화가 되어 있어서 여러 컴포넌트에서 데이터의 일관성을 유지 시켜준다. Apollo Client의 주요 특징 중 하나는 로컬 인메모리 정규화된 캐시를 사용한다는 것이다.
import { ApolloClient, InMemoryCache } from '@apollo/client';
const client = new ApolloClient({
cache: new InMemoryCache(),
});
캐시를 Apollo Client에 보내면, Apollo Client가 쿼리 응답을 받을 때 마다, 자체 캐시 안에서 분리된 별개의 엔트리에 자동적으로 데이터를 저장한다.
처음으로 쿼리할 때 데이터를 가져오는 흐름은 다음과 같다.
같은 쿼리를 두 번째 이후부터 부를 때는 흐름이 다음과 같이 바뀐다.
Apollo Client 캐시는 아주 변경이 가능한 구조여서 각각의 타입과 필드를 커스터마이즈 할 수 있다. 또한 캐시를 아직 GQL 서버로부터 패치되지 않은 로컬 데이터를 저장하고 상호작용 하는데 사용할 수도 있다.
데이터의 저장과 정규화
Apollo Client의 InMemoryCache는 데이터를 flat lookup table
형태로 저장한다. 따라서 객체 안에서 각각을 서로서로 참조할 수 있다. 이 객체는 GQL 쿼리에서 리턴받는 객체와 일치한가. 여러 쿼리를 하더라도 하나의 객체로 묶여져서 캐싱된다.
캐시는 플랫하지만, GQL 쿼리로 응답받은 객체는 때때로 플랫하지 않다. 사실, 깊게 중첩이 될 수 있는 구조이다. 예를 들면 다음과 같다.
{
"data": {
"person": {
"__typename": "Person",
"id": "cGVvcGxlOjE=",
"name": "Luke Skywalker",
"homeworld": {
"__typename": "Planet",
"id": "cGxhbmV0czox",
"name": "Tatooine"
}
}
}
}
이 응답에는 Person 객체가 포함되어 있고, 차례대로 Person 객체의 homeworld 필드에서 Planet 객체를 포함한다.
따라서 이렇게 중첩된 데이터를 평평한 데이터로 바꿔서 캐시에 저장을 해 주어야 하는데, 이 과정에서 사용하는 방법이 정규화(normalization)다.
Apollo Client 캐시가 쿼리 데이터를 받으면 다음과 같은 과정들을 수행한다.
- 객체를 확인한다. (Identify objects)
- 캐시 ID를 만든다. (Generate cache IDs)
- 객체의 필드를 레퍼런스로 대체한다. (Replace object field with references)
- 정규화된 객체를 저장한다.
캐시에 데이터를 읽고 쓰기
우리는 GQL 서버와 통신하지 않고, 직접 Apollo Client 캐시에 데이터를 읽고 쓸 수 있다. 당신은 이전에 서버에서 패치된 데이터와 상호작용할 수도 있으며, 오직 로컬에서만 가능한 데이터와 상호작용할 수도 있다.
Apollo Client에서는 캐시된 데이터와 상호작용 할 수 있는 여러가지 전략을 제공한다.
- GQL 쿼리를 사용하는 방법 : 원격 그리고 로컬 데이터를 둘 다 관리하기 위해 표준 GQL 쿼리를 사용한다.
- readQuery
- writeQuery
- updateQuery
- GQL Fragment를 사용하는 방법 : 어떤 객체에 도달하기 위한 전체 쿼리 혼합 없이 어떠한 캐시된 객체의 필드에 접근할 수 있다.
- readFragment
- writeFragment
- updateFragment
- 직접 캐시된 필드를 변경하는 경우 : 캐시된 데이터를 GQL을 전혀 사용하지 않고 조작한다.
- cache.modify
readQuery
는 GQL 쿼리를 직접 당신의 캐시에 실행한다.
const READ_TODO = gql`
query ReadTodo($id: ID!) {
todo(id: $id) {
id
text
completed
}
}
`;
// Fetch the cached to-do item with ID 5
const { todo } = client.readQuery({
query: READ_TODO,
variables: { // Provide any required variables here. Variables of mismatched types will return `null`.
id: 5,
},
});
당신의 캐시가 모든 쿼리 필드값들을 포함하고 있다면, readQuery는 쿼리의 모양에 맞춰서 객체를 리턴한다.
{
todo: {
__typename: 'Todo', // __typename is automatically included
id: 5,
text: 'Buy oranges 🍊',
completed: true
}
}
이 때 주의해야 할 점은, 반환된 객체를 직접 수정하면 안 된다. 같은 객체가 여러 컴포넌트에 리턴되었을 것이다. 만약 결과를 수정하고 싶다면 readQuery와 writeQuery를 같이 사용해서 데이터를 캐시하고 선택적인 수정을 가해야 한다.
// Query that fetches all existing to-do items
const query = gql`
query MyTodoAppQuery {
todos {
id
text
completed
}
}
`;
// Get the current to-do list
const data = client.readQuery({ query });
// Create a new to-do item
const myNewTodo = {
id: '6',
text: 'Start using Apollo Client.',
completed: false,
__typename: 'Todo',
};
// Write back to the to-do list, appending the new item
client.writeQuery({
query,
data: {
todos: [...data.todos, myNewTodo],
},
});
writeQuery
는 데이터를 캐시에 쓸 수 있게 하는 메서드로 이 때 GQL 쿼리에 맞춘 모양으로 작성한다.
client.writeQuery({
query: gql`
query WriteTodo($id: Int!) {
todo(id: $id) {
id
text
completed
}
}`,
data: { // Contains the data to write
todo: {
__typename: 'Todo',
id: 5,
text: 'Buy grapes 🍇',
completed: false
},
},
variables: {
id: 5
}
});
여기서 알아야 할 점들은 writeQuery를 통해 데이터를 캐싱하면 GQL 서버에 그 수정사항들이 가지는 않는다는 점이다. 만약 당신의 환경을 리로딩하면, 이러한 변화들은 사라진다. 또한 쿼리의 모양은 GQL 서버 스키마에 강제되지 않는다.
readFragment
는 readQuery와 다르게 id 옵션을 요구한다. 이 옵션은 캐시 ID를 특정해서 캐시에서 그 객체를 찾아낸다. 기본적으로 캐시 아이디는<__typename>:<id>
포맷이다.
const todo = client.readFragment({
id: 'Todo:5', // The value of the to-do item's cache ID
fragment: gql`
fragment MyTodo on Todo {
id
text
completed
}
`,
});
writeFragment
를 통해서는 캐시에 데이터를 쓸 수 있다. 단, 명심해야 할 점은 writeFragment로 작성한 데이터는 GQL 서버에 반영되지 않는다. 환경을 리로드 하면 이러한 수정 사항은 사라질 것이다. writeFragment는 readFragment와 비슷하지만, 추가적인 data 변수를 요구한다.
client.writeFragment({
id: 'Todo:5',
fragment: gql`
fragment MyTodo on Todo {
completed
}
`,
data: {
completed: true,
},
});
업데이트가 필요한 경우 간편한 방법은 cache.updateQuery
, cache.updateFragment
를 사용하는 것이다. 이렇게 하면 읽고 쓰는 두 개의 콜을 하나로 합칠 수가 있다.
// Query to fetch all todo items
const query = gql`
query MyTodoAppQuery {
todos {
id
text
completed
}
}
`;
// Set all todos in the cache as completed
cache.updateQuery({ query }, (data) => ({
todos: data.todos.map((todo) => ({ ...todo, completed: true }))
}));
InMemoryCache
의 modify
메서드는 각각의 캐시 필드를 직접 수정하고, 또는 전체 필드를 삭제할 수 있게 한다. 얼핏 보면 수정된 필드들에 대한 모든 활성화 쿼리들을 리프레시 시켜준다는 점에서 writeQuery
, writeFragment
와 비슷하다. 하지만 다른 점은 modify 메서드는 너가 정의한 merge 함수들을 피할 수가 있다. 이는 필드가 항상 너가 정의한 값으로 새롭게 덮여씌워진다는 의미이다. 또한 modify 는 이미 캐시에 존재하지 않는 값에는 쓸 수가 없다.
아래 예제는 name 필드의 값들을 모두 대문자로 바꾸는 예제 코드이다.
cache.modify({
id: cache.identify(myObject),
fields: {
name(cachedName) {
return cachedName.toUpperCase();
},
},
/* broadcast: false // Include this to prevent automatic query refresh */
});
변경 함수(modifier function)는 선택적으로 두 번째 파라미터를 취할 수 있는데, 이는 객체가 유용한 유틸리티들을 포함할 수 있다는 것이다. 예를 들어 리스트에서 한 아이템을 지우는 경우 다음과 같이 변경 함수를 사용할 수 있다. Post
는 Comment
의 배열으로 이루어져 있고, 이 배열에서 특정 Comment
를 지우는 함수이다.
const idToRemove = 'abc123';
cache.modify({
id: cache.identify(myPost),
fields: {
comments(existingCommentRefs, { readField }) {
return existingCommentRefs.filter(
commentRef => idToRemove !== readField('id', commentRef)
);
},
},
});
가비지 콜렉션
Apollo Client 3 에서는 더이상 사용하지 않는 데이터들을 선택적으로 삭제할 수 있다. 기본적인 가비지 콜렉션 방법인 gc
메서드는 대부분의 어플리케이션에서 적합하다. 그러니 evict()
메서드는 더 정제된 컨트롤을 제공한다.
gc 메서드는 정규화된 캐시에서 도달할 수 없는 객체들을 전부 제거한다.
cache.gc();
객체가 도달할 수 있는지 판단하기 위하여, 캐시는 알려진 모든 루트 객체에서 시작해서 탐색 전략을 통해 재귀적으로 모든 자식 레퍼런스들을 방문한다. 이 프로세스 중 방문되지 않은 객체는 제거되낟. cache.gc()
메서드는 제거된 객체들의 ID를 리턴한다.
GQL 데이터를 가지치기 하는 역할 뿐만 아니라, cache.gc
는 또한 기존 캐싱 결과에서 바뀌지 않는 부분을 보존하는데 사용한 메모리를 해방시키는데 사용된다.
cache.gc({ resetResultCache: true })
이렇게 메모리를 비우는 작업은 일시적으로 캐시 읽기 속도를 낮춘다. 왜냐하면 이러한 읽기는 이전 읽기 동작에서 이득을 얻지 못했기 때문이다. 이와 같은 여러가지 유용한 옵션이 gc 메서드에 있는데 자세한 내용은 공식 문서를 살펴보기 바란다.
심화 주제
여기서는 Apollo Client를 사용할 때 발생하는 특별한 상황과 고려사항들에 대해서 설명한다.
때로는 특정 GQL 오퍼레이션에서 캐시를 사용하지 않아야 한다. 예를 들어 쿼리의 응답이 토큰인데 오직 한 번만 쓰인다면, 이 경우는 no-cache
정책을 사용한다.
const { loading, error, data } = useQuery(GET_DOGS, {
fetchPolicy: "no-cache" // highlight-line
});
이 패칭 정책을 사용하는 오퍼레이션은 결과를 캐시에 저장하지 않는다. 또한 서버에 요청을 보낼 때 캐시를 체크하지 않는다.
당신은 또한 InMemoryCache
를 고집하거나 다시 사용해야 할 때가 있다. 예를 들면 저장소 제공자(storage provider) 같은 AsyncStorage
나 localStorage
에서 말이다. 이를 위해서는 apollo3-cache-persist
라이브러리를 사용한다. 이 라이브러리는 다양한 저장소 제공자와 호환이 된다.
시작하기 위해서 캐시를 전달하고 저장소 제공자가 persistCache
하게 하라. 기본적으로 당신의 캐시 컨텐츠는 비동기적으로 바로 복구가 되고, 그 컨텐츠들은 모든 캐시 쓰기의 짧은 디바운스 인터벌 사이에 지속된다. persistCache 메서드는 비동기적이고 Promise를 반환한다.
import { AsyncStorage } from 'react-native';
import { InMemoryCache } from '@apollo/client';
import { persistCache } from 'apollo3-cache-persist';
const cache = new InMemoryCache();
persistCache({
cache,
storage: AsyncStorage,
}).then(() => {
// Continue setting up Apollo Client as usual.
})
때때로, 캐시 전체를 초기화 해야 할 때가 있다. 예를 들면 유저가 로그아웃을 할 때. 이 때는 client.resetStore
를 호출한다. 이 메서드는 비동기적이다. 왜냐하면 여러분의 활성 쿼리들 또한 리패치 하기 때문이다.
export default withApollo(graphql(PROFILE_QUERY, {
props: ({ data: { loading, currentUser }, ownProps: { client }}) => ({
loading,
currentUser,
resetOnLogout: async () => client.resetStore(),
}),
})(Profile));
참고자료
'Web Frontend Developer' 카테고리의 다른 글
[웹 프론트엔드 인터뷰] #4. useCallback과 useMemo는 언제 어떻게 사용하나요? (0) | 2022.07.20 |
---|---|
React Context 올바르게 사용하기 (0) | 2022.07.06 |
Backend For Frontend는 무엇인가? (0) | 2022.05.13 |
[FeBase S3] SVG, Canvas, viewport와 viewbox (1) | 2022.04.17 |
[Troubleshooting] Next.js에서 dynamic routing 새로고침 에러 (0) | 2022.04.15 |