이 글은 React + Apollo Client 환경에서 Pagination된 데이터를 캐싱하기 위한 코드를 기록하기 위해서 작성되었다.
클라이언트에서 Cache 를 하려고 하는 이유.
1. API콜을 줄임으로써, 서버부하 감소. 트래픽 감소. UI반응 지연 감소.
대부분의 Cache이유와 동일한 이유이다. 서버로부터 가져온 데이터가 실시간으로 변화하는 데이터가 아니라면, 굳이 불러왔던 정보를 두고 새로 불러 올 필요가 없다. 새로 불러오게 된다면 사용자는 매번 로딩을 기다려야 할 뿐만 아니라, 서버에 부하도 생기게 되고, 이는 서버의 처리 지연과 인프라 비용증가로 이어지게 된다. 이부분을 해결하기 위해 cache를 사용한다.
2. Cache를 구조에 맞게 잘 작성한다면, 데이터 변경시에도 캐시를 이용할 수 있다.
사실 이게 가장 큰 이유이다. 이번에 마주한 문제는 프로필 화면에서 유저가 작성한 게시글을 cache하고, 새로운 게시글이 생겼을때 cache에만 반영하기 위해서다. apollo가 기본적으로 cache를 해준다. 하지만 요청에 따른 데이터를 cache 하기 때문에, 일종의 데이터베이스 형태를 기대할 수 없다.
예를 들면 1번 페이지 데이터, 2번 페이지 데이터 이렇게 있다고 해보자. 각각 5개의 데이터가 들어있다. 만약 새로운 데이터를 1번 페이지의 맨 앞에 추가하려고 하면 1번 페이지는 6개다 된다. 이런 문제를 pagination cache를 활용해서 해결하려고 한다.
Apollo Client Cache를 먼저 알아보자.
Apollo Client의 캐시가 어떻게 동작하는지 프로세스를 살펴보자.
가장 기본인 프로세스다. Apollo Client에서 쿼리를 날리면, 먼저 InMemoryCache를 거친다. 만약 InMemoryCache 에 찾는 내용이 없다면 서버로 요청을 보낸다. 응답이 오면 응답온 데이터를 InMemoryCache 에 저장한다.
그렇게 저장한 데이터는 다음의 query의 응답으로 사용된다. InMemoryCache에서 데이터를 가져온것 이기때문에, 서버는 아무런 응답을 하지 않아도 된다. 이런 과정을 통해 서버의 부하를 덜어낼 수 있는 것이다.
Apollo Client에서 실제로 Cache된 내용을 확인하면 이렇다.
왼쪽에는 캐시된 데이터들이 백엔드에서 설정한 Entity별로 모여있다. 데이터가 배열을 통해서 담겨왔다면, 하나하나 분리해서 저렇게 참조의 형태를 만든다.
오른쪽은 쿼리의 Field에 담긴 내용들이다. 요청을 한 query나 mutation의 이름으로 key값이 설정되고, 데이터는 __ref라는 key의 값을 통해 왼쪽의 데이터를 참조한다.
내가 요청한 모든 query와 mutation은 자동으로 cache된다.
물론 이 기능을 사용하기 위해선 아래의 코드를 추가해줘야 한다.
import { InMemoryCache, ApolloClient } from '@apollo/client';
const client = new ApolloClient({
// ...other arguments...
cache: new InMemoryCache(options)
});
이제 Cache를 Pagination 해보자.
https://www.apollographql.com/docs/react/pagination/overview/
Pagination in Apollo Client
Overview
www.apollographql.com
Pagintion은 모두 잘 알다시피 데이터배열을 일정한 단위로 쪼개서 페이지를 만드는것이다.
Apollo Client에서도 동일하다. offset, limit으로 서버에 원하는 데이터의 양을 조절해서 query를 보낸다.
Pagination도 자료를 참고해보자.
pagination에는 offset-based 방식과 cursor-based방식이 있다. 이부분은 사실 Apollo Client에서 설정하는게 아니라 사용자가 구현하기에 따라 다르다. 그러므로 문서를 보고 상황에 맞게 각자 구현하면 된다.
커서 기반 페이지네이션 (Cursor-based Pagination) 구현하기
커서 기반 페이지네이션 (Cursor-based Pagination) 구현하기
사실 처음에는 이 주제로 포스트를 쓰려고 했던건 아니고 Apollo GraphQL 에서 커서 기반 페이지네이션 구현 을 주제로 글을 쓰려고 했습니다. 그런데 막상 찾아보니 백엔드-프론트엔드를 함께 고려
velog.io
(참고자료 - 페이지네이션에 대한 내용이 잘 정리되어있다.)
자료를 보고 클라이언트에서는 offset-based방식을 사용하기로 했다. 백엔드는 지식이 많이 없어서 TypeORM에서 제공하는 skip-take 기능을 사용했다.(현재는 백엔드를 cursor-based방식으로 수정 했다. 서버로 보내는 인자값은 달라졌으니 참고만 하자.)
그럼 예제를 살펴보자.
아폴로 클라이언트에서 제공하는 offsetLimtPagination이라는 헬퍼 함수를 사용하면 쉽게 구현할 수 있다고 하는데, 나는 이방법으로 해도 하나도 cache가 안되더라.... 이유는 잘 모르겠다. 그래서 직접 구현하는 방법을 사용했다.
이 코드가 직접 데이터를 cache하고, read하는 코드이다. 하나하나 살펴보자.
read()
read함수를 통해서 cache에 존재하는 값을 불러올 수 있다. 첫번째 인자인 existing이라는 첫번째 인자를 통해서 cache에 존재하는 데이터를 가져온다. 두번째 인자로 query에 담긴 arguments를 받아온다.
cache에 담긴 내용을 가져오고, arguments를 통해 원하는 데이터를 return할 수 있다.
위에서 설명했던것과 같이, Apollo client에서는 먼저 cache를 뒤져본 후, 데이터가 없다면 서버로 요청을 보낸다. read함수에서 undefined를 return한다면, 서버로 요청이 가는것이다. (나중에 나올 내용이지만, Apollo client는 cache로의 요청도 cache한다.)
merge()
merge 함수를 통해 cache되는 데이터를 제어할 수 있다. 첫번째 인자인 existing을 통해서 cache에 존재하는 데이터를 가져온다. 두번째 인자로 새로 추가될 데이터를 받아온다. 세번째 인자로 query의 argument를 받아온다.
새로 추가될 데이터를 merge함수를 통해 argument의 내용으로 병합을 시키고 cache할 수 있다.
병합한 데이터를 return 해주면 cache에 저장되게 된다.
keyArgs
이건 그냥 default값인 false로 놓자. 적어도 지금은 사용할 일이 없다.
그래서 내가 작성한 코드는?
먼저 cache를 사용하기 위해 설정부터 하자.
export const cache = new InMemoryCache({
typePolicies: {
Query: {
fields: {
getMyPosts: {
// @ts-ignore
read(existing, {args:{args:{offset, limit}}}) {
if(!existing){
return undefined
}
return existing && {__typename: existing.__typename, data: existing.data.slice(offset, limit)};
},
keyArgs: false,
merge(existing = {data:[]} , incomming:QGetMyPosts_getMyPosts) {
return {__typename : incomming.__typename, data:[...existing.data, ...incomming.data]};
},
}
}
}
}
});
export const client = new ApolloClient({
cache,
link: from([authMiddleware, errorLink.concat(httpLink)]),
});
Apollo Client에서 cache를 커스텀 하기 위해서는, 인스턴스를 만들어서 Apollo Client를 생성할때 넣어줘야 한다. 인스턴스를 만들때 cache를 위한 field를 정의해야 한다. 이 작업은 반드시 선행되어야 한다. 여기에 read 함수의 merge함수를 담아주면 된다.
read()
read(existing, {args:{args:{offset, limit}}}) {
if(!existing){
return undefined
}
return {__typename: existing.__typename,
data: existing.data.slice(offset, limit)};
},
slice함수를 통해서 offset과 limit의 범위의 데이터를 보내주면 된다. 만약 데이터(existing)가 없다면 undefined를 return해서, 서버로 요청을 보내게 한다.(아래에서 다시 언급될테지만, 이 함수는 원래의 사용법과 조금은 다르게 동작한다)
merge()
merge(existing = {data:[]} , incomming:QGetMyPosts_getMyPosts) {
return {__typename : incomming.__typename,
data:[...existing.data, ...incomming.data]};
},
spread문법으로 기존의 데이터에 추가로 들어온 데이터를 병합해준다. return된 데이터는 cache에 저장 된다.
실제 데이터를 요청하는 코드
//한 페이에 들어갈 요소는 3개
const [postsLimit, setPostsLimit] = useState<number>(3);
const {
data: postsData,
loading: postsLoading,
fetchMore: fetchPostsMore,
} = useQuery(GET_MYPOSTS, {
variables: { args: { offset: 0, limit: postsLimit } },
});
const posts = postsData?.getMyPosts.data;
useEffect(() => {
console.log(posts?.length, postsLimit);
if (posts && postsLimit > 3) {
if (posts.length + 3 === postsLimit) {
fetchPostsMore({ variables: { args: { offset: posts?.length, limit: 3 } } });
}
}
}, [postsData]);
const toNextPage = async () => {
setPostsLimit((prev) => prev + 3);
};
나는 한 페이지당 3개씩 pagination하기로 했다. 코드의 플로우는 아래와 같다.
setPostLimit -> 리렌더링 -> userQuery재실행(0부터 지금페이지) -> postsData-useEffect ->
-> 다음페이지 요청이 필요할 시 fetchPostMore 내장함수 실행. -> 서버로 다음페이지 요청 ->
-> 데이터 반영.
내가봐도 한눈에 안들어고 너무 복잡해 보인다. 리팩토링을 할 수 있다면 반드시 하고싶다. 다음에 이 기능을 사용 할 때 더 개선 할 수있는지 알아봐야겠다. 이렇게 밖에 할 수 없었던 이유를 살펴보자.
1. fetchPostsMore는 cache를 조회하지 않는다?
해당 함수는 read를 호출하지 않는다. cache에 어떤 데이터가 있는지 조회하지 않는것같다. read함수에 console.log 함수를 넣어봐도 merge가 먼저 동작하고, 그 다음에 read가 동작한다. 이것 때문에 아무리 cache해도 데이터를 계속 서버로 fetch요청을 날린다. 이부분을 이해를 못해서 함참을 해멨다. 당연하게 read먼저 해서, 캐시에 있는지 내용을 확인한 다음, 없다면 서버로 요청을 날릴줄 알았는데, 그렇지 않고 cache에 내용이 있음에도, 자꾸 서버로 요청을 보내고 있었다. read가 문제인지, merge가 문제인지 삽질을 한참 했는데, 내가 내린결론은, fetchPostsMore는 cache를 확인하지 않고, 요청을 보낸다는걸로 결론을 냈다. 그래서 조건부로 요청을 하게 코드를 작성했다.
2. 나름대로 메모리와 코드량를 아껴보려고 노력했다.
useQuery(GET_MYPOSTS, {
variables: { args: { offset: 0, limit: postsLimit } },
});
이 함수는 포스트를 0번부터 지정한번호까지 전부를 들고오게 되어있다. 그러면 여기서 의문이 생길수도 있다. 페이지네이션이면 특정 페이지의 내용을 쿼리해야 하는게 아닌가? 이다. 나는 페이지네이션은 요청에만 사용하고, 실제로 사용할 때는 원하는만큼의 분량을 처음부터 가져오는 방식을 선택했다. 이 방식대로 하게되면 기존내용에 데이터를 추가하는 작업을 안해도된다. 코드량도 줄일수 있고, 컴포넌트에서 추가적으로 state를 관리하지 않아도 query만 요청하면 데이터를 render할 수 있다.
이렇게 하게되면 또 다른 의문이 생기는데, 만약 cache에 없는 페이지까지 불러오려고하면, 서버에 전체를 요청하는 문제가 생기지 않나? 이다. 이부분은 read()함수가 담당한다. read함수는 existing이 없다면, undefined를 반환하고, 데이터가 있다면 slice함수를 통해서 필요한 부분만 반환한다. slice함수는 기존배열보다 더 큰 인덱스까지의 데이터를 요구해도 기존배열의 데이터까지만 반환한다. 이렇게 되면 cache에 있던 없던, 일단 반환이 이루어 지게 된다.
3. 조건문을 통해서 데이터를 조절해서 서버에 요청한다.
cache가 없는 경우는 서버로 바로 요청을 보내지만, cache가 없는 부분까지 요청한 데이터는 서버에 요청을 보내지 않는다. 왜냐하면 read()함수의 return값이 undefined가 아니기 때문이다. 이때 데이터가 부족한지 여부를 조건문을 통해 판단해서, fetchMore함수를 실행시킨다. fetchMore함수를 통해 마지막 페이지의 데이터를 불러와서 merge를 시켜주면, 캐시에 반영이 되고, query함수가 실행되어 데이터가 rendering될수 있게 된다.
결과.
차곡차곡 캐싱이 잘 된것을 확인할 수 있다.
느낀점.
아무리 공식문서를 훑어봐도 해결이 안되서 너무 짜증이 난 상태였다. 거의 7시간을 쏟아부은 것 같았다. 짜증이 나니 이성적으로 생각을 못하게 되고, 문제해결에서 점점 멀어지는 기분이었다. 다음부터는 더 차분하게 에러 디버깅을 해야겠다.
직전의 프로젝트에서 페이지네이션을 구현한 적이 있었다. 그때는 cache도 안썼고, 1차원적으로 코드를 짰었다. 그래서 코드량도 쓸데없이 길었고, 디버깅 하기도 힘들었다. 그때에 비하면더 성장한것 같다는 생각이 들었다. 앞으로 디버깅만 더 차분하게 하면 될것같다.
React + Apollo Client Pagination Cache
이 글은 React + Apollo Client 환경에서 Pagination된 데이터를 캐싱하기 위한 코드를 기록하기 위해서 작성되었다.
클라이언트에서 Cache 를 하려고 하는 이유.
1. API콜을 줄임으로써, 서버부하 감소. 트래픽 감소. UI반응 지연 감소.
대부분의 Cache이유와 동일한 이유이다. 서버로부터 가져온 데이터가 실시간으로 변화하는 데이터가 아니라면, 굳이 불러왔던 정보를 두고 새로 불러 올 필요가 없다. 새로 불러오게 된다면 사용자는 매번 로딩을 기다려야 할 뿐만 아니라, 서버에 부하도 생기게 되고, 이는 서버의 처리 지연과 인프라 비용증가로 이어지게 된다. 이부분을 해결하기 위해 cache를 사용한다.
2. Cache를 구조에 맞게 잘 작성한다면, 데이터 변경시에도 캐시를 이용할 수 있다.
사실 이게 가장 큰 이유이다. 이번에 마주한 문제는 프로필 화면에서 유저가 작성한 게시글을 cache하고, 새로운 게시글이 생겼을때 cache에만 반영하기 위해서다. apollo가 기본적으로 cache를 해준다. 하지만 요청에 따른 데이터를 cache 하기 때문에, 일종의 데이터베이스 형태를 기대할 수 없다.
예를 들면 1번 페이지 데이터, 2번 페이지 데이터 이렇게 있다고 해보자. 각각 5개의 데이터가 들어있다. 만약 새로운 데이터를 1번 페이지의 맨 앞에 추가하려고 하면 1번 페이지는 6개다 된다. 이런 문제를 pagination cache를 활용해서 해결하려고 한다.
Apollo Client Cache를 먼저 알아보자.
Apollo Client의 캐시가 어떻게 동작하는지 프로세스를 살펴보자.
가장 기본인 프로세스다. Apollo Client에서 쿼리를 날리면, 먼저 InMemoryCache를 거친다. 만약 InMemoryCache 에 찾는 내용이 없다면 서버로 요청을 보낸다. 응답이 오면 응답온 데이터를 InMemoryCache 에 저장한다.
그렇게 저장한 데이터는 다음의 query의 응답으로 사용된다. InMemoryCache에서 데이터를 가져온것 이기때문에, 서버는 아무런 응답을 하지 않아도 된다. 이런 과정을 통해 서버의 부하를 덜어낼 수 있는 것이다.
Apollo Client에서 실제로 Cache된 내용을 확인하면 이렇다.
왼쪽에는 캐시된 데이터들이 백엔드에서 설정한 Entity별로 모여있다. 데이터가 배열을 통해서 담겨왔다면, 하나하나 분리해서 저렇게 참조의 형태를 만든다.
오른쪽은 쿼리의 Field에 담긴 내용들이다. 요청을 한 query나 mutation의 이름으로 key값이 설정되고, 데이터는 __ref라는 key의 값을 통해 왼쪽의 데이터를 참조한다.
내가 요청한 모든 query와 mutation은 자동으로 cache된다.
물론 이 기능을 사용하기 위해선 아래의 코드를 추가해줘야 한다.
import { InMemoryCache, ApolloClient } from '@apollo/client';
const client = new ApolloClient({
// ...other arguments...
cache: new InMemoryCache(options)
});
이제 Cache를 Pagination 해보자.
Pagintion은 모두 잘 알다시피 데이터배열을 일정한 단위로 쪼개서 페이지를 만드는것이다.
Apollo Client에서도 동일하다. offset, limit으로 서버에 원하는 데이터의 양을 조절해서 query를 보낸다.
Pagination도 자료를 참고해보자.
pagination에는 offset-based 방식과 cursor-based방식이 있다. 이부분은 사실 Apollo Client에서 설정하는게 아니라 사용자가 구현하기에 따라 다르다. 그러므로 문서를 보고 상황에 맞게 각자 구현하면 된다.
커서 기반 페이지네이션 (Cursor-based Pagination) 구현하기
(참고자료 - 페이지네이션에 대한 내용이 잘 정리되어있다.)
자료를 보고 클라이언트에서는 offset-based방식을 사용하기로 했다. 백엔드는 지식이 많이 없어서 TypeORM에서 제공하는 skip-take 기능을 사용했다.(현재는 백엔드를 cursor-based방식으로 수정 했다. 서버로 보내는 인자값은 참고만 하자.)
그럼 예제를 살펴보자.
아폴로 클라이언트에서 제공하는 offsetLimtPagination이라는 헬퍼 함수를 사용하면 쉽게 구현할 수 있다고 하는데, 나는 이방법으로 해도 하나도 cache가 안되더라.... 이유는 잘 모르겠다. 그래서 직접 구현하는 방법을 사용했다.
이 코드가 직접 데이터를 cache하고, read하는 코드이다. 하나하나 살펴보자.
read()
read함수를 통해서 cache에 존재하는 값을 불러올 수 있다. 첫번째 인자인 existing이라는 첫번째 인자를 통해서 cache에 존재하는 데이터를 가져온다. 두번째 인자로 query에 담긴 arguments를 받아온다.
cache에 담긴 내용을 가져오고, arguments를 통해 원하는 데이터를 return할 수 있다.
위에서 설명했던것과 같이, Apollo client에서는 먼저 cache를 뒤져본 후, 데이터가 없다면 서버로 요청을 보낸다. read함수에서 undefined를 return한다면, 서버로 요청이 가는것이다. (나중에 나올 내용이지만, Apollo client는 cache로의 요청도 cache한다.)
merge()
merge 함수를 통해 cache되는 데이터를 제어할 수 있다. 첫번째 인자인 existing을 통해서 cache에 존재하는 데이터를 가져온다. 두번째 인자로 새로 추가될 데이터를 받아온다. 세번째 인자로 query의 argument를 받아온다.
새로 추가될 데이터를 merge함수를 통해 argument의 내용으로 병합을 시키고 cache할 수 있다.
병합한 데이터를 return 해주면 cache에 저장되게 된다.
keyArgs
이건 그냥 default값인 false로 놓자. 적어도 지금은 사용할 일이 없다.
그래서 내가 작성한 코드는?
먼저 cache를 사용하기 위해 설정부터 하자.
export const cache = new InMemoryCache({
typePolicies: {
Query: {
fields: {
getMyPosts: {
// @ts-ignore
read(existing, {args:{args:{offset, limit}}}) {
if(!existing){
return undefined
}
return existing && {__typename: existing.__typename, data: existing.data.slice(offset, limit)};
},
keyArgs: false,
merge(existing = {data:[]} , incomming:QGetMyPosts_getMyPosts) {
return {__typename : incomming.__typename, data:[...existing.data, ...incomming.data]};
},
}
}
}
}
});
export const client = new ApolloClient({
cache,
link: from([authMiddleware, errorLink.concat(httpLink)]),
});
Apollo Client에서 cache를 커스텀 하기 위해서는, 인스턴스를 만들어서 Apollo Client를 생성할때 넣어줘야 한다. 인스턴스를 만들때 cache를 위한 field를 정의해야 한다. 이 작업은 반드시 선행되어야 한다. 여기에 read 함수의 merge함수를 담아주면 된다.
read()
read(existing, {args:{args:{offset, limit}}}) {
if(!existing){
return undefined
}
return {__typename: existing.__typename,
data: existing.data.slice(offset, limit)};
},
slice함수를 통해서 offset과 limit의 범위의 데이터를 보내주면 된다. 만약 데이터(existing)가 없다면 undefined를 return해서, 서버로 요청을 보내게 한다.(아래에서 다시 언급될테지만, 이 함수는 원래의 사용법과 조금은 다르게 동작한다)
merge()
merge(existing = {data:[]} , incomming:QGetMyPosts_getMyPosts) {
return {__typename : incomming.__typename,
data:[...existing.data, ...incomming.data]};
},
spread문법으로 기존의 데이터에 추가로 들어온 데이터를 병합해준다. return된 데이터는 cache에 저장 된다.
실제 데이터를 요청하는 코드
//한 페이에 들어갈 요소는 3개
const [postsLimit, setPostsLimit] = useState<number>(3);
const {
data: postsData,
loading: postsLoading,
fetchMore: fetchPostsMore,
} = useQuery(GET_MYPOSTS, {
variables: { args: { offset: 0, limit: postsLimit } },
});
const posts = postsData?.getMyPosts.data;
useEffect(() => {
console.log(posts?.length, postsLimit);
if (posts && postsLimit > 3) {
if (posts.length + 3 === postsLimit) {
fetchPostsMore({ variables: { args: { offset: posts?.length, limit: 3 } } });
}
}
}, [postsData]);
const toNextPage = async () => {
setPostsLimit((prev) => prev + 3);
};
나는 한 페이지당 3개씩 pagination하기로 했다. 코드의 플로우는 아래와 같다.
setPostLimit -> 리렌더링 -> userQuery재실행(0부터 지금페이지) -> postsData-useEffect ->
-> 다음페이지 요청이 필요할 시 fetchPostMore 내장함수 실행. -> 서버로 다음페이지 요청 ->
-> 데이터 반영.
내가봐도 한눈에 안들어고 너무 복잡해 보인다. 리팩토링을 할 수 있다면 반드시 하고싶다. 다음에 이 기능을 사용 할 때 더 개선 할 수있는지 알아봐야겠다. 이렇게 밖에 할 수 없었던 이유를 살펴보자.
1. fetchPostsMore는 cache를 조회하지 않는다?
해당 함수는 read를 호출하지 않는다. cache에 어떤 데이터가 있는지 조회하지 않는것같다. read함수에 console.log 함수를 넣어봐도 merge가 먼저 동작하고, 그 다음에 read가 동작한다. 이것 때문에 아무리 cache해도 데이터를 계속 서버로 fetch요청을 날린다. 이부분을 이해를 못해서 함참을 해멨다. 당연하게 read먼저 해서, 캐시에 있는지 내용을 확인한 다음, 없다면 서버로 요청을 날릴줄 알았는데, 그렇지 않고 cache에 내용이 있음에도, 자꾸 서버로 요청을 보내고 있었다. read가 문제인지, merge가 문제인지 삽질을 한참 했는데, 내가 내린결론은, fetchPostsMore는 cache를 확인하지 않고, 요청을 보낸다는걸로 결론을 냈다. 그래서 조건부로 요청을 하게 코드를 작성했다.
2. 나름대로 메모리와 코드량를 아껴보려고 노력했다.
useQuery(GET_MYPOSTS, {
variables: { args: { offset: 0, limit: postsLimit } },
});
이 함수는 포스트를 0번부터 지정한번호까지 전부를 들고오게 되어있다. 그러면 여기서 의문이 생길수도 있다. 페이지네이션이면 특정 페이지의 내용을 쿼리해야 하는게 아닌가? 이다. 나는 페이지네이션은 요청에만 사용하고, 실제로 사용할 때는 원하는만큼의 분량을 처음부터 가져오는 방식을 선택했다. 이 방식대로 하게되면 기존내용에 데이터를 추가하는 작업을 안해도된다. 코드량도 줄일수 있고, 컴포넌트에서 추가적으로 state를 관리하지 않아도 query만 요청하면 데이터를 render할 수 있다.
이렇게 하게되면 또 다른 의문이 생기는데, 만약 cache에 없는 페이지까지 불러오려고하면, 서버에 전체를 요청하는 문제가 생기지 않나? 이다. 이부분은 read()함수가 담당한다. read함수는 existing이 없다면, undefined를 반환하고, 데이터가 있다면 slice함수를 통해서 필요한 부분만 반환한다. slice함수는 기존배열보다 더 큰 인덱스까지의 데이터를 요구해도 기존배열의 데이터까지만 반환한다. 이렇게 되면 cache에 있던 없던, 일단 반환이 이루어 지게 된다.
3. 조건문을 통해서 데이터를 조절해서 서버에 요청한다.
cache가 없는 경우는 서버로 바로 요청을 보내지만, cache가 없는 부분까지 요청한 데이터는 서버에 요청을 보내지 않는다. 왜냐하면 read()함수의 return값이 undefined가 아니기 때문이다. 이때 데이터가 부족한지 여부를 조건문을 통해 판단해서, fetchMore함수를 실행시킨다. fetchMore함수를 통해 마지막 페이지의 데이터를 불러와서 merge를 시켜주면, 캐시에 반영이 되고, query함수가 실행되어 데이터가 rendering될수 있게 된다.
결과.
차곡차곡 캐싱이 잘 된것을 확인할 수 있다.
느낀점.
아무리 공식문서를 훑어봐도 해결이 안되서 너무 짜증이 난 상태였다. 거의 7시간을 쏟아부은 것 같았다. 짜증이 나니 이성적으로 생각을 못하게 되고, 문제해결에서 점점 멀어지는 기분이었다. 다음부터는 더 차분하게 에러 디버깅을 해야겠다.
직전의 프로젝트에서 페이지네이션을 구현한 적이 있었다. 그때는 cache도 안썼고, 1차원적으로 코드를 짰었다. 그래서 코드량도 쓸데없이 길었고, 디버깅 하기도 힘들었다. 그때에 비하면더 성장한것 같다는 생각이 들었다. 앞으로 디버깅만 더 차분하게 하면 될것같다.
React + Apollo Client Pagination Cache
이 글은 React + Apollo Client 환경에서 Pagination된 데이터를 캐싱하기 위한 코드를 기록하기 위해서 작성되었다.
클라이언트에서 Cache 를 하려고 하는 이유.
1. API콜을 줄임으로써, 서버부하 감소. 트래픽 감소. UI반응 지연 감소.
대부분의 Cache이유와 동일한 이유이다. 서버로부터 가져온 데이터가 실시간으로 변화하는 데이터가 아니라면, 굳이 불러왔던 정보를 두고 새로 불러 올 필요가 없다. 새로 불러오게 된다면 사용자는 매번 로딩을 기다려야 할 뿐만 아니라, 서버에 부하도 생기게 되고, 이는 서버의 처리 지연과 인프라 비용증가로 이어지게 된다. 이부분을 해결하기 위해 cache를 사용한다.
2. Cache를 구조에 맞게 잘 작성한다면, 데이터 변경시에도 캐시를 이용할 수 있다.
사실 이게 가장 큰 이유이다. 이번에 마주한 문제는 프로필 화면에서 유저가 작성한 게시글을 cache하고, 새로운 게시글이 생겼을때 cache에만 반영하기 위해서다. apollo가 기본적으로 cache를 해준다. 하지만 요청에 따른 데이터를 cache 하기 때문에, 일종의 데이터베이스 형태를 기대할 수 없다.
예를 들면 1번 페이지 데이터, 2번 페이지 데이터 이렇게 있다고 해보자. 각각 5개의 데이터가 들어있다. 만약 새로운 데이터를 1번 페이지의 맨 앞에 추가하려고 하면 1번 페이지는 6개다 된다. 이런 문제를 pagination cache를 활용해서 해결하려고 한다.
Apollo Client Cache를 먼저 알아보자.
Apollo Client의 캐시가 어떻게 동작하는지 프로세스를 살펴보자.
가장 기본인 프로세스다. Apollo Client에서 쿼리를 날리면, 먼저 InMemoryCache를 거친다. 만약 InMemoryCache 에 찾는 내용이 없다면 서버로 요청을 보낸다. 응답이 오면 응답온 데이터를 InMemoryCache 에 저장한다.
그렇게 저장한 데이터는 다음의 query의 응답으로 사용된다. InMemoryCache에서 데이터를 가져온것 이기때문에, 서버는 아무런 응답을 하지 않아도 된다. 이런 과정을 통해 서버의 부하를 덜어낼 수 있는 것이다.
Apollo Client에서 실제로 Cache된 내용을 확인하면 이렇다.
왼쪽에는 캐시된 데이터들이 백엔드에서 설정한 Entity별로 모여있다. 데이터가 배열을 통해서 담겨왔다면, 하나하나 분리해서 저렇게 참조의 형태를 만든다.
오른쪽은 쿼리의 Field에 담긴 내용들이다. 요청을 한 query나 mutation의 이름으로 key값이 설정되고, 데이터는 __ref라는 key의 값을 통해 왼쪽의 데이터를 참조한다.
내가 요청한 모든 query와 mutation은 자동으로 cache된다.
물론 이 기능을 사용하기 위해선 아래의 코드를 추가해줘야 한다.
import { InMemoryCache, ApolloClient } from '@apollo/client';
const client = new ApolloClient({
// ...other arguments...
cache: new InMemoryCache(options)
});
이제 Cache를 Pagination 해보자.
Pagintion은 모두 잘 알다시피 데이터배열을 일정한 단위로 쪼개서 페이지를 만드는것이다.
Apollo Client에서도 동일하다. offset, limit으로 서버에 원하는 데이터의 양을 조절해서 query를 보낸다.
Pagination도 자료를 참고해보자.
pagination에는 offset-based 방식과 cursor-based방식이 있다. 이부분은 사실 Apollo Client에서 설정하는게 아니라 사용자가 구현하기에 따라 다르다. 그러므로 문서를 보고 상황에 맞게 각자 구현하면 된다.
커서 기반 페이지네이션 (Cursor-based Pagination) 구현하기
(참고자료 - 페이지네이션에 대한 내용이 잘 정리되어있다.)
자료를 보고 클라이언트에서는 offset-based방식을 사용하기로 했다. 백엔드는 지식이 많이 없어서 TypeORM에서 제공하는 skip-take 기능을 사용했다.(현재는 백엔드를 cursor-based방식으로 수정 했다. 서버로 보내는 인자값은 참고만 하자.)
그럼 예제를 살펴보자.
아폴로 클라이언트에서 제공하는 offsetLimtPagination이라는 헬퍼 함수를 사용하면 쉽게 구현할 수 있다고 하는데, 나는 이방법으로 해도 하나도 cache가 안되더라.... 이유는 잘 모르겠다. 그래서 직접 구현하는 방법을 사용했다.
이 코드가 직접 데이터를 cache하고, read하는 코드이다. 하나하나 살펴보자.
read()
read함수를 통해서 cache에 존재하는 값을 불러올 수 있다. 첫번째 인자인 existing이라는 첫번째 인자를 통해서 cache에 존재하는 데이터를 가져온다. 두번째 인자로 query에 담긴 arguments를 받아온다.
cache에 담긴 내용을 가져오고, arguments를 통해 원하는 데이터를 return할 수 있다.
위에서 설명했던것과 같이, Apollo client에서는 먼저 cache를 뒤져본 후, 데이터가 없다면 서버로 요청을 보낸다. read함수에서 undefined를 return한다면, 서버로 요청이 가는것이다. (나중에 나올 내용이지만, Apollo client는 cache로의 요청도 cache한다.)
merge()
merge 함수를 통해 cache되는 데이터를 제어할 수 있다. 첫번째 인자인 existing을 통해서 cache에 존재하는 데이터를 가져온다. 두번째 인자로 새로 추가될 데이터를 받아온다. 세번째 인자로 query의 argument를 받아온다.
새로 추가될 데이터를 merge함수를 통해 argument의 내용으로 병합을 시키고 cache할 수 있다.
병합한 데이터를 return 해주면 cache에 저장되게 된다.
keyArgs
이건 그냥 default값인 false로 놓자. 적어도 지금은 사용할 일이 없다.
그래서 내가 작성한 코드는?
먼저 cache를 사용하기 위해 설정부터 하자.
export const cache = new InMemoryCache({
typePolicies: {
Query: {
fields: {
getMyPosts: {
// @ts-ignore
read(existing, {args:{args:{offset, limit}}}) {
if(!existing){
return undefined
}
return existing && {__typename: existing.__typename, data: existing.data.slice(offset, limit)};
},
keyArgs: false,
merge(existing = {data:[]} , incomming:QGetMyPosts_getMyPosts) {
return {__typename : incomming.__typename, data:[...existing.data, ...incomming.data]};
},
}
}
}
}
});
export const client = new ApolloClient({
cache,
link: from([authMiddleware, errorLink.concat(httpLink)]),
});
Apollo Client에서 cache를 커스텀 하기 위해서는, 인스턴스를 만들어서 Apollo Client를 생성할때 넣어줘야 한다. 인스턴스를 만들때 cache를 위한 field를 정의해야 한다. 이 작업은 반드시 선행되어야 한다. 여기에 read 함수의 merge함수를 담아주면 된다.
read()
read(existing, {args:{args:{offset, limit}}}) {
if(!existing){
return undefined
}
return {__typename: existing.__typename,
data: existing.data.slice(offset, limit)};
},
slice함수를 통해서 offset과 limit의 범위의 데이터를 보내주면 된다. 만약 데이터(existing)가 없다면 undefined를 return해서, 서버로 요청을 보내게 한다.(아래에서 다시 언급될테지만, 이 함수는 원래의 사용법과 조금은 다르게 동작한다)
merge()
merge(existing = {data:[]} , incomming:QGetMyPosts_getMyPosts) {
return {__typename : incomming.__typename,
data:[...existing.data, ...incomming.data]};
},
spread문법으로 기존의 데이터에 추가로 들어온 데이터를 병합해준다. return된 데이터는 cache에 저장 된다.
실제 데이터를 요청하는 코드
//한 페이에 들어갈 요소는 3개
const [postsLimit, setPostsLimit] = useState<number>(3);
const {
data: postsData,
loading: postsLoading,
fetchMore: fetchPostsMore,
} = useQuery(GET_MYPOSTS, {
variables: { args: { offset: 0, limit: postsLimit } },
});
const posts = postsData?.getMyPosts.data;
useEffect(() => {
console.log(posts?.length, postsLimit);
if (posts && postsLimit > 3) {
if (posts.length + 3 === postsLimit) {
fetchPostsMore({ variables: { args: { offset: posts?.length, limit: 3 } } });
}
}
}, [postsData]);
const toNextPage = async () => {
setPostsLimit((prev) => prev + 3);
};
나는 한 페이지당 3개씩 pagination하기로 했다. 코드의 플로우는 아래와 같다.
setPostLimit -> 리렌더링 -> userQuery재실행(0부터 지금페이지) -> postsData-useEffect ->
-> 다음페이지 요청이 필요할 시 fetchPostMore 내장함수 실행. -> 서버로 다음페이지 요청 ->
-> 데이터 반영.
내가봐도 한눈에 안들어고 너무 복잡해 보인다. 리팩토링을 할 수 있다면 반드시 하고싶다. 다음에 이 기능을 사용 할 때 더 개선 할 수있는지 알아봐야겠다. 이렇게 밖에 할 수 없었던 이유를 살펴보자.
1. fetchPostsMore는 cache를 조회하지 않는다?
해당 함수는 read를 호출하지 않는다. cache에 어떤 데이터가 있는지 조회하지 않는것같다. read함수에 console.log 함수를 넣어봐도 merge가 먼저 동작하고, 그 다음에 read가 동작한다. 이것 때문에 아무리 cache해도 데이터를 계속 서버로 fetch요청을 날린다. 이부분을 이해를 못해서 함참을 해멨다. 당연하게 read먼저 해서, 캐시에 있는지 내용을 확인한 다음, 없다면 서버로 요청을 날릴줄 알았는데, 그렇지 않고 cache에 내용이 있음에도, 자꾸 서버로 요청을 보내고 있었다. read가 문제인지, merge가 문제인지 삽질을 한참 했는데, 내가 내린결론은, fetchPostsMore는 cache를 확인하지 않고, 요청을 보낸다는걸로 결론을 냈다. 그래서 조건부로 요청을 하게 코드를 작성했다.
2. 나름대로 메모리와 코드량를 아껴보려고 노력했다.
useQuery(GET_MYPOSTS, {
variables: { args: { offset: 0, limit: postsLimit } },
});
이 함수는 포스트를 0번부터 지정한번호까지 전부를 들고오게 되어있다. 그러면 여기서 의문이 생길수도 있다. 페이지네이션이면 특정 페이지의 내용을 쿼리해야 하는게 아닌가? 이다. 나는 페이지네이션은 요청에만 사용하고, 실제로 사용할 때는 원하는만큼의 분량을 처음부터 가져오는 방식을 선택했다. 이 방식대로 하게되면 기존내용에 데이터를 추가하는 작업을 안해도된다. 코드량도 줄일수 있고, 컴포넌트에서 추가적으로 state를 관리하지 않아도 query만 요청하면 데이터를 render할 수 있다.
이렇게 하게되면 또 다른 의문이 생기는데, 만약 cache에 없는 페이지까지 불러오려고하면, 서버에 전체를 요청하는 문제가 생기지 않나? 이다. 이부분은 read()함수가 담당한다. read함수는 existing이 없다면, undefined를 반환하고, 데이터가 있다면 slice함수를 통해서 필요한 부분만 반환한다. slice함수는 기존배열보다 더 큰 인덱스까지의 데이터를 요구해도 기존배열의 데이터까지만 반환한다. 이렇게 되면 cache에 있던 없던, 일단 반환이 이루어 지게 된다.
3. 조건문을 통해서 데이터를 조절해서 서버에 요청한다.
cache가 없는 경우는 서버로 바로 요청을 보내지만, cache가 없는 부분까지 요청한 데이터는 서버에 요청을 보내지 않는다. 왜냐하면 read()함수의 return값이 undefined가 아니기 때문이다. 이때 데이터가 부족한지 여부를 조건문을 통해 판단해서, fetchMore함수를 실행시킨다. fetchMore함수를 통해 마지막 페이지의 데이터를 불러와서 merge를 시켜주면, 캐시에 반영이 되고, query함수가 실행되어 데이터가 rendering될수 있게 된다.
결과.
차곡차곡 캐싱이 잘 된것을 확인할 수 있다.
느낀점.
아무리 공식문서를 훑어봐도 해결이 안되서 너무 짜증이 난 상태였다. 거의 7시간을 쏟아부은 것 같았다. 짜증이 나니 이성적으로 생각을 못하게 되고, 문제해결에서 점점 멀어지는 기분이었다. 다음부터는 더 차분하게 에러 디버깅을 해야겠다.
직전의 프로젝트에서 페이지네이션을 구현한 적이 있었다. 그때는 cache도 안썼고, 1차원적으로 코드를 짰었다. 그래서 코드량도 쓸데없이 길었고, 디버깅 하기도 힘들었다. 그때에 비하면더 성장한것 같다는 생각이 들었다. 앞으로 디버깅만 더 차분하게 하면 될것같다.
React + Apollo Client Pagination Cache
이 글은 React + Apollo Client 환경에서 Pagination된 데이터를 캐싱하기 위한 코드를 기록하기 위해서 작성되었다.
클라이언트에서 Cache 를 하려고 하는 이유.
1. API콜을 줄임으로써, 서버부하 감소. 트래픽 감소. UI반응 지연 감소.
대부분의 Cache이유와 동일한 이유이다. 서버로부터 가져온 데이터가 실시간으로 변화하는 데이터가 아니라면, 굳이 불러왔던 정보를 두고 새로 불러 올 필요가 없다. 새로 불러오게 된다면 사용자는 매번 로딩을 기다려야 할 뿐만 아니라, 서버에 부하도 생기게 되고, 이는 서버의 처리 지연과 인프라 비용증가로 이어지게 된다. 이부분을 해결하기 위해 cache를 사용한다.
2. Cache를 구조에 맞게 잘 작성한다면, 데이터 변경시에도 캐시를 이용할 수 있다.
사실 이게 가장 큰 이유이다. 이번에 마주한 문제는 프로필 화면에서 유저가 작성한 게시글을 cache하고, 새로운 게시글이 생겼을때 cache에만 반영하기 위해서다. apollo가 기본적으로 cache를 해준다. 하지만 요청에 따른 데이터를 cache 하기 때문에, 일종의 데이터베이스 형태를 기대할 수 없다.
예를 들면 1번 페이지 데이터, 2번 페이지 데이터 이렇게 있다고 해보자. 각각 5개의 데이터가 들어있다. 만약 새로운 데이터를 1번 페이지의 맨 앞에 추가하려고 하면 1번 페이지는 6개다 된다. 이런 문제를 pagination cache를 활용해서 해결하려고 한다.
Apollo Client Cache를 먼저 알아보자.
Apollo Client의 캐시가 어떻게 동작하는지 프로세스를 살펴보자.
가장 기본인 프로세스다. Apollo Client에서 쿼리를 날리면, 먼저 InMemoryCache를 거친다. 만약 InMemoryCache 에 찾는 내용이 없다면 서버로 요청을 보낸다. 응답이 오면 응답온 데이터를 InMemoryCache 에 저장한다.
그렇게 저장한 데이터는 다음의 query의 응답으로 사용된다. InMemoryCache에서 데이터를 가져온것 이기때문에, 서버는 아무런 응답을 하지 않아도 된다. 이런 과정을 통해 서버의 부하를 덜어낼 수 있는 것이다.
Apollo Client에서 실제로 Cache된 내용을 확인하면 이렇다.
왼쪽에는 캐시된 데이터들이 백엔드에서 설정한 Entity별로 모여있다. 데이터가 배열을 통해서 담겨왔다면, 하나하나 분리해서 저렇게 참조의 형태를 만든다.
오른쪽은 쿼리의 Field에 담긴 내용들이다. 요청을 한 query나 mutation의 이름으로 key값이 설정되고, 데이터는 __ref라는 key의 값을 통해 왼쪽의 데이터를 참조한다.
내가 요청한 모든 query와 mutation은 자동으로 cache된다.
물론 이 기능을 사용하기 위해선 아래의 코드를 추가해줘야 한다.
import { InMemoryCache, ApolloClient } from '@apollo/client';
const client = new ApolloClient({
// ...other arguments...
cache: new InMemoryCache(options)
});
이제 Cache를 Pagination 해보자.
Pagintion은 모두 잘 알다시피 데이터배열을 일정한 단위로 쪼개서 페이지를 만드는것이다.
Apollo Client에서도 동일하다. offset, limit으로 서버에 원하는 데이터의 양을 조절해서 query를 보낸다.
Pagination도 자료를 참고해보자.
pagination에는 offset-based 방식과 cursor-based방식이 있다. 이부분은 사실 Apollo Client에서 설정하는게 아니라 사용자가 구현하기에 따라 다르다. 그러므로 문서를 보고 상황에 맞게 각자 구현하면 된다.
커서 기반 페이지네이션 (Cursor-based Pagination) 구현하기
(참고자료 - 페이지네이션에 대한 내용이 잘 정리되어있다.)
자료를 보고 클라이언트에서는 offset-based방식을 사용하기로 했다. 백엔드는 지식이 많이 없어서 TypeORM에서 제공하는 skip-take 기능을 사용했다.(현재는 백엔드를 cursor-based방식으로 수정 했다. 서버로 보내는 인자값은 참고만 하자.)
그럼 예제를 살펴보자.
아폴로 클라이언트에서 제공하는 offsetLimtPagination이라는 헬퍼 함수를 사용하면 쉽게 구현할 수 있다고 하는데, 나는 이방법으로 해도 하나도 cache가 안되더라.... 이유는 잘 모르겠다. 그래서 직접 구현하는 방법을 사용했다.
이 코드가 직접 데이터를 cache하고, read하는 코드이다. 하나하나 살펴보자.
read()
read함수를 통해서 cache에 존재하는 값을 불러올 수 있다. 첫번째 인자인 existing이라는 첫번째 인자를 통해서 cache에 존재하는 데이터를 가져온다. 두번째 인자로 query에 담긴 arguments를 받아온다.
cache에 담긴 내용을 가져오고, arguments를 통해 원하는 데이터를 return할 수 있다.
위에서 설명했던것과 같이, Apollo client에서는 먼저 cache를 뒤져본 후, 데이터가 없다면 서버로 요청을 보낸다. read함수에서 undefined를 return한다면, 서버로 요청이 가는것이다. (나중에 나올 내용이지만, Apollo client는 cache로의 요청도 cache한다.)
merge()
merge 함수를 통해 cache되는 데이터를 제어할 수 있다. 첫번째 인자인 existing을 통해서 cache에 존재하는 데이터를 가져온다. 두번째 인자로 새로 추가될 데이터를 받아온다. 세번째 인자로 query의 argument를 받아온다.
새로 추가될 데이터를 merge함수를 통해 argument의 내용으로 병합을 시키고 cache할 수 있다.
병합한 데이터를 return 해주면 cache에 저장되게 된다.
keyArgs
이건 그냥 default값인 false로 놓자. 적어도 지금은 사용할 일이 없다.
그래서 내가 작성한 코드는?
먼저 cache를 사용하기 위해 설정부터 하자.
export const cache = new InMemoryCache({
typePolicies: {
Query: {
fields: {
getMyPosts: {
// @ts-ignore
read(existing, {args:{args:{offset, limit}}}) {
if(!existing){
return undefined
}
return existing && {__typename: existing.__typename, data: existing.data.slice(offset, limit)};
},
keyArgs: false,
merge(existing = {data:[]} , incomming:QGetMyPosts_getMyPosts) {
return {__typename : incomming.__typename, data:[...existing.data, ...incomming.data]};
},
}
}
}
}
});
export const client = new ApolloClient({
cache,
link: from([authMiddleware, errorLink.concat(httpLink)]),
});
Apollo Client에서 cache를 커스텀 하기 위해서는, 인스턴스를 만들어서 Apollo Client를 생성할때 넣어줘야 한다. 인스턴스를 만들때 cache를 위한 field를 정의해야 한다. 이 작업은 반드시 선행되어야 한다. 여기에 read 함수의 merge함수를 담아주면 된다.
read()
read(existing, {args:{args:{offset, limit}}}) {
if(!existing){
return undefined
}
return {__typename: existing.__typename, data: existing.data.slice(offset, limit)};
},
slice함수를 통해서 offset과 limit의 범위의 데이터를 보내주면 된다. 만약 데이터(existing)가 없다면 undefined를 return해서, 서버로 요청을 보내게 한다.(아래에서 다시 언급될테지만, 이 함수는 원래의 사용법과 조금은 다르게 동작한다)
merge()
merge(existing = {data:[]} , incomming:QGetMyPosts_getMyPosts) {
return {__typename : incomming.__typename, data:[...existing.data, ...incomming.data]};
},
spread문법으로 기존의 데이터에 추가로 들어온 데이터를 병합해준다. return된 데이터는 cache에 저장 된다.
실제 데이터를 요청하는 코드
//한 페이지에 들어갈 요소는 3개
const pageItemCount = 3;
const [postsLimit, setPostsLimit] = useState<number>(pageItemCount);
const {
data: postsData,
loading: postsLoading,
fetchMore: fetchPostsMore,
} = useQuery(GET_MYPOSTS, {
variables: { args: { offset: 0, limit: postsLimit } },
});
const posts = postsData?.getMyPosts.data;
useEffect(() => {
// console.log(posts?.length, postsLimit);
// 첫페이지는 fetchMore로직을 동작시키지 않음.
if (posts && postsLimit > pageItemCount) {
// 현재 페이지에서 + 3된 숫자가 postsLimit와 같다면 뒤에 더 페이지가 있기 때문에 추가 요청
if (posts.length + pageItemCount === postsLimit) {
fetchPostsMore({ variables: { args: { offset: posts?.length, limit: pageItemCount } } });
}
}
}, [postsData]);
const toNextPage = async () => {
setPostsLimit((prev) => prev + pageItemCount);
};
나는 한 페이지당 3개씩 pagination하기로 했다. 코드의 플로우는 아래와 같다.
setPostLimit -> 리렌더링 -> userQuery재실행(0부터 지금페이지) -> postsData-useEffect ->
-> 다음페이지 요청이 필요할 시 fetchPostMore 실행. -> 서버로 다음페이지 요청 ->
-> 데이터 반영.
내가봐도 한눈에 안들어고 너무 복잡해 보인다. 리팩토링을 할 수 있다면 반드시 하고싶다. 다음에 이 기능을 사용 할 때 더 개선 할 수있는지 알아봐야겠다. 이렇게 밖에 할 수 없었던 이유를 살펴보자.
1. fetchMore는 cache를 조회하지 않는다?
해당 함수는 read를 호출하지 않는다. 바로 백엔드 서버로 요청을 보낸다. cache에 어떤 데이터가 있는지 조회하지 않는것같다. read함수에 console.log 함수를 넣어봐도 merge가 먼저 동작하고, 그 다음에 read가 동작한다. 이것 때문에 아무리 cache해도 데이터를 계속 서버로 fetch요청을 날린다. 이부분을 이해를 못해서 함참을 해멨다. 당연하게 read먼저 해서, 캐시에 있는지 내용을 확인한 다음, 없다면 서버로 요청을 날릴줄 알았는데, 그렇지 않고 cache에 내용이 있음에도, 자꾸 서버로 요청을 보내고 있었다. read가 문제인지, merge가 문제인지 삽질을 한참 했는데, 내가 내린결론은, fetchMore는 cache를 확인하지 않고, 요청을 보낸다는걸로 결론을 냈다. 그래서 조건부로 요청을 하게 코드를 작성했다.
2. 나름대로 메모리와 코드량를 아껴보려고 노력했다.
useQuery(GET_MYPOSTS, {
variables: { args: { offset: 0, limit: postsLimit } },
});
이 함수는 포스트를 0번부터 지정한번호까지 전부를 들고오게 되어있다. 그러면 여기서 의문이 생길수도 있다. 페이지네이션이면 특정 페이지의 내용을 쿼리해야 하는게 아닌가? 이다. 나는 페이지네이션 args는 백엔드 요청에만 사용하고, 실제로 렌더링 할 때는 처음부터 전부 가져오는 방식으로 코드를 작성했다. 이 방식대로 하게되면 모든 데이터가 새로 할당되며, 데이터를 추가하는 작업을 안해도된다. 코드량도 줄일수 있고, 컴포넌트에서 추가적으로 state를 관리하지 않아도 query만 요청하면 데이터를 render할 수 있다.
이렇게 하게되면 또 다른 의문이 생기는데, 만약 cache에 없는 페이지까지 불러오려고하면, 서버에 전체를 요청하는 문제가 생기지 않나? 이다. 이부분은 read()함수가 담당한다. read함수는 existing이 없다면, undefined를 반환하고, 데이터가 있다면 slice함수를 통해서 필요한 부분만 반환한다. slice함수는 기존배열보다 더 큰 인덱스까지의 데이터를 요구해도 기존배열의 데이터까지만 반환한다. 이렇게 되면 cache에 있던 없던, 일단 반환이 이루어 지게 된다. cache에 원하는 데이터가 있는지 없는지는 조건문으로 확인한다.
3. 조건문을 통해서 데이터를 조절해서 서버에 요청한다.
cache가 없는 경우는 서버로 바로 요청을 보내지만, cache가 없는 부분까지 요청한 데이터는 서버에 요청을 보내지 않는다. 왜냐하면 read()함수의 return값이 undefined가 아니기 때문이다. 이때 데이터가 부족한지 여부를 조건문을 통해 판단해서, fetchMore함수를 실행시킨다. fetchMore함수를 통해 다음 페이지의 데이터를 불러와서 기존 데이터에 merge를 시켜주면, 캐시에 반영이 되고, query함수가 실행되어 데이터가 rendering될수 있게 된다.
결과.
차곡차곡 캐싱이 잘 된것을 확인할 수 있다. data에 배열의 형태로 저장된 모습이다.
'소셜독' 카테고리의 다른 글
ApolloClient Authentication Error Handling (1) | 2022.06.13 |
---|---|
bcrypt로 비밀번호 암호화 (0) | 2022.06.12 |
Server-side Pagination (0) | 2022.06.12 |
Apollo Cache - Client State 반영 (0) | 2022.06.12 |
AWS file upload - presigned url (0) | 2022.06.12 |