@Urql/svelte 사용법 및 relay cursor pagination 구현 그리고 cache handling(feat. typescript)

Dev-Yuns
Svelte Seoul
Published in
10 min readDec 24, 2021

--

Urql logo from github

Intro

UrqlApollo client, relay와 같이 GraphQL client 라이브러리입니다. 공식 문서에서 svelte 전용 패키지를 지원하고 있고 사용 방법이 쉽고 간단해서 svelte에서 graphql을 사용할 때 좋은 선택지가 될 것 같습니다. 이번 글에서는 Urql을 사용해서 relay style cursor pagination을 구현하는 방법과 cache를 어떻게 핸들링하는지에 대해 살펴보겠습니다. 덤으로 저는 프로젝트에서 typescript를 주로 사용하기 때문에 Urql에서 Type safe하게 코딩하는 부분도 살펴보겠습니다.

프로젝트 세팅

공식 문서를 살펴보시면, 먼저 client를 세팅하는 부분이 있습니다. 보통 app.svelte 파일에 세팅을 하게 되는데, 저는 urql 폴더를 따로 만들어서 query문과 client 옵션들을 함께 모아두었습니다.

urql 및 graphql 관련 로직을 모아둔 폴더

Urql에서는 Redux의 미들웨어들처럼 exchange 라는 것을 통해 graphql opertion들을 제어할 수 있습니다. 따라서 앱이 점점 커질수록 client 코드가 복잡해지기 때문에 미리 분리해두었습니다. queries 폴더에서 schema 모델 별로 query string을 분류해두었고, updaters 폴더에는 모델 별 cache update 함수들을 모아두었습니다. 이제부터client 코드에서 시작해서 하나씩 살펴보겠습니다.

Client

index.svelte 에 있는 client 코드는 아래와 같습니다. initClientcreateClient 함수와 setClient 함수를 합쳐놓은 함수입니다. 여기에 여러 옵션들을 넣어주게 됩니다. 필수로 사용되는 옵션들을 살펴보겠습니다.

  • url : 요청을 보낼 end point 주소입니다.
  • exchanges : Urql가 다른 라이브러리와 차별점을 가질 수 있는 가장 중요한 부분입니다. 원하는 동작에 대해 exchange들을 exchanges 배열에 추가해서 동작을 핸들링할 수 있습니다. 인자를 추가하고 싶다면 기본적으로 반드시 dedupExchange , cacheExchange , fetchExchange 를 순서대로 추가해준 후에 errorExchange 와 같은 다른 exchange들을 추가하면 됩니다. dedupExchange 는 중복 요청이 이루어지지 않도록 확인하는 미들웨어입니다. cacheExchange 는 말그대로 cache를 핸들링할 수 있는 미들웨어이고, fetchExchange 는 fetch를 수행하는 미들웨어입니다. 상황에 따라 유사한 다른 fetch 종류의 exchange를 추가할 수도 있습니다. exchanges는 optional 하기 때문에, 만약 추가하지 않는다면 기본값으로 defaultExchange 가 알아서 주입되고 위 3개의 exchange를 포함하고 있습니다.
  • requestPolicy : 요청 정책을 정의하는 것으로 default는 cache-first 입니다. 해당 옵션은 여기서 확인할 수 있습니다.
  • fetchOption : 기본적으로 들어가야할 값으로, url 로 보낼 요청을 조작할 수 있습니다. 위 코드에서는 기본적으로 headers 에 token을 실어보내고 있습니다.

cacheExchange

클라이언트에서 graphql 요청을 통해 만들어진 operation은 exchanges 요소들을 차례로 순회하게 됩니다. 이 글의 주제는 cache이므로 cacheExchange 를 좀 더 살펴보겠습니다. cacheExchange 에서는 local resolver 의 개념이 사용됩니다. 상단의 client 코드에서 cacheExchange 의 첫번째 인자로 resolvers 라는 키가 있습니다. 클라이언트에서 요청을 보내기 전에 operation필드의 값을 핸들링할 수 있습니다. 형태는 서버의 리졸버와 동일하다고 생각하시면 됩니다. 예를 들어 아래와 같이 todo 타입의 특정 필드의 값을 수정해 줄 수 있습니다. 보다 자세한 내용은 문서에서 확인할 수 있습니다.

cacheExchange({
resolvers: {
Todo: {
updatedAt: parent => new Date(parent.updatedAt),
},
},
});

만약 cache를 수정해야한다면 cacheExchange 내의 updates 키에 원하는 동작을 수행할 수 있습니다.

여기까지 전체적인 client 세팅 및 주요 개념들을 살펴 봤고 이제 pagination 구조를 살펴보겠습니다.

Relay cursor Pagination

서버에 relay style의 cursor pagination이 이미 구현되어 있다면 사실 client에서 할 것은 많지 않습니다. @urql/exchange-graphcache 패키지를 설치한 후, @urql/exchange-graphcache/extras 경로에서 relayPagination 을 가져올 수 있습니다. 이 함수를 원하는 pagination query에 할당해주면 됩니다.

cacheExchange<GraphCacheConfig>({
resolvers: {
Query: {
todos: relayPagination(),
},
},
}),

이제 todos 요청을 보내면 relay pagination의 명세에 맞게 값을 리턴 받을 수 있습니다. 쿼리 내용 예시는 아래와 같습니다. gql 은 string으로 이루어진 쿼리문을 documentNode 타입으로 변환 해줍니다. cache 핸들링에서 사용되는 타입입니다.

export const todosQuery = gql`
query todos($first: Int!, $after: String) {
todos(first: $first, after: $after) {
edges {
cursor
node {
id
title
content
}
}
pageInfo {
startCursor
endCursor
hasNextPage
}
}
}
`;

따로 저장된 쿼리문은 사용하는 곳에서 아래와 같이 사용할 수 있습니다. operationStore 는 svelte의 스토어를 리턴해주기 때문에 $todos 의 형태로 값을 구독할 수 있습니다. 따라서 쿼리의 값이 바뀌면 $todos 에 의존하고 있는 관련 로직들도 모두 리프레시됩니다. 타입 주입을 위해서 첫번째 인자에는 리턴되는 데이터 타입을, 두 번째 인자에는 쿼리에 사용될 인자들의 타입을 넣어주었습니다. 타입을 만드는 방법은 하단의 Bonus 섹션에서 확인할 수 있습니다.

const todos = operationStore<ArtistConnection, QueryTodosArgs> (todosQuery, {first: 20});

데이터를 계속 로드하기 위해서는 loadNext 와 같이 다음 데이터를 불러오는 함수를 구현해야합니다. 할당을 통해 반응성이 동작하는 svelte의 특징을 이용해서 $todos.variables 에 새로운 값을 할당해주면 데이터 로딩이 일어납니다.

const loadNext = (): void => {
if (pageInfo.hasNextPage) {
if ($todos.variables) {
$todos.variables = {
first: LOAD_CNT,
after: pageInfo.endCursor || '',
};
}
}
};

이제 쿼리에서 리턴받은 값을 통해 todo list를 화면에 그려낼 수 있습니다. 그런데 만약 create 화면에서 새로운 todo 를 만들었다면 변경 사항은 새로고침을 하기 전까지는 화면에 반영되지 않을 것입니다. 이때 cache logic이 들어가게 됩니다.

만약 relay style cursor pagination에 대해 더 궁금하시다면 자세한 명세는 여기에서 확인할 수 있습니다.서버 측 pagination 구현이 궁금하시다면 여기에서 예시를 확인할 수 있습니다.

Cache handling

cacheExchange 에 할당되는 객체의 키 중에서는 updates 라는 키가 있습니다. 여기에서 캐시 관련 로직을 구현할 수 있습니다.

updates: {
Mutation: {
createTodo: createTodoUpdater,
},
},

createTodo 뮤테이션을 통해 새로운 todo가 만들어졌지만, 새로고침이 일어나지 않는다면 변경사항이 바로 반영되지 않습니다. 따라서 직접 저장된 캐시 데이터를 불러 와서 생성된 데이터를 캐시 저장소에 넣어 주어야 합니다. 먼저 코드를 살펴보겠습니다.

createTodoUpdater updater 함수는 result, args, cache, info등 4개의 인자를 받습니다. result 는 뮤테이션의 결과이고, args는 뮤테이션에 넘긴 인자, cache는 현재의 캐시 저장소 객체, info 는 현재 graphql document에 대한 메타 데이터입니다.

먼저 inspectFields('Query') 를 통해 캐시 저장소 가장 상단에 있는 객체를 불러옵니다. 이후 filter 를 통해 우리가 원하는 todos 라는 fieldName을 가진 필드를 가져옵니다.

이제 캐시를 업데이트할 차례인데, cache의updateQuery 라는 메소드를 사용합니다. 이때 첫번째 인자로 들어가는 객체에는 정확히 같은 쿼리와 변수를 넣어주어야 합니다. 여타 다른 라이브러리들처럼 Urql 역시 쿼리문과 변수를 통해 캐시 데이터를 식별하기 때문입니다. 두 번째 콜백에서 첫 번째 인자로 들어간 객체에 매칭되는 데이터를 받아올 수 있습니다.result 를 사용해서 새로운 edge 를 만들어줍니다. cursor 는 임시로 사용할 값이기 때문에 random string을 사용해도 무방합니다. 새롭게 만들어진 edge 는 기존 데이터에 넣어준 후 리턴 해줍니다.

Conclusion

Urql과 Svelte 모두 최근에 처음 사용해본 기술이지만 API가 직관적이고 사용하기 편리해서 생각보다 둘의 궁합이 잘맞다는 생각이 들었습니다. 앞으로 점점 svelte의 인기가 많아지고 있는 추세인데 이와 더불어 Urql을 통해 사이드 프로젝트를 만들어 보는 것도 좋을 것 같다는 생각이 듭니다.

Bonus — For Typescript user

타입스크립트를 사용하고 있다면 당연히 타입 주입을 통해서 타입 세이프한 코딩을 하고 싶을 것입니다. 서버에 schema.graphql 이 준비되어 있다면 get-graphql-schema라는 패키지를 통해 서버에서 schema 파일을 가져올 수 있습니다. 이후 graphql-code-generator패키지를 통해서 타입 파일을 생성할 수 있습니다.

code generator를 위해서는 codegen.yml 을 만들어주어야 합니다. yml파일에서는plugins 옵션을 통해 원하는 플러그인들을 추가할 수 있는데, typescript-urql-graphcache 플러그인을 넣어주면 GraphCacheConfig 타입을 만들어줍니다. 이 타입을 cacheExchange 에 주입해주면 캐시 로직을 작성할 때 타입의 도움을 받을 수 있습니다. 글 상단의 client 코드에서도 해당 타입이 주입되어 있는 것을 확인하실 수 있습니다. :)

urql 사용시 codegen.yml 예시

--

--