GraphQL 클라이언트 프레임워크를 설계할 때 고려해야 할 점을 살펴보고, Relay가 단순한 GraphQL 클라이언트가 아니라 선언적 데이터 패칭을 위한 프레임워크인 이유를 설명합니다.
GraphQL은 제품 개발자와 클라이언트 애플리케이션의 요구에 초점을 맞춤으로써, 클라이언트가 데이터를 가져오는 새로운 방식을 제시합니다. GraphQL은 개발자가 어떤 뷰에 필요한 정확한 데이터를 지정할 수 있게 해주며, 클라이언트가 그 데이터를 단 한 번의 네트워크 요청으로 가져오도록 해줍니다. REST 같은 전통적인 접근과 비교하면, GraphQL은 (리소스 중심 REST 접근에 비해) 애플리케이션이 데이터를 더 효율적으로 가져오도록 돕고, (커스텀 엔드포인트에서 발생할 수 있는) 서버 로직의 중복을 피하게 해줍니다. 더 나아가 GraphQL은 제품 코드와 서버 로직을 디커플링하도록 돕습니다. 예를 들어, 제품은 관련된 모든 서버 엔드포인트를 변경하지 않아도 더 많거나 더 적은 정보를 가져올 수 있습니다. 데이터를 가져오는 훌륭한 방법입니다.
이 글에서는 GraphQL 클라이언트 프레임워크를 만든다는 것이 무엇을 의미하는지, 그리고 이것이 더 전통적인 REST 시스템의 클라이언트와 어떻게 다른지 살펴보겠습니다. 그 과정에서 Relay의 설계 결정들을 들여다보며, Relay가 단지 GraphQL 클라이언트가 아니라 선언적 데이터 패칭(declarative data-fetching) 을 위한 프레임워크이기도 하다는 점을 보게 될 것입니다. 처음부터 시작해서 데이터를 가져와 봅시다!
간단한 애플리케이션이 있다고 가정해봅시다. 이 앱은 스토리 목록을 가져오고, 각 스토리의 일부 세부 정보를 가져옵니다. 리소스 중심 REST에서는 다음과 같이 구현할 수 있습니다:
// Fetch the list of story IDs but not their details:rest.get('/stories').then(stories => // This resolves to a list of items with linked resources: // [ { href: "http://.../story/1" }, ... ] Promise.all(stories.map(story => rest.get(story.href) // Follow the links ))).then(stories => { // This resolves to a list of story items: //[ { id: "...", text: "..." } ] console.log(stories);});
이 접근은 서버에 n+1 번 요청해야 한다는 점에 주목하세요. 목록을 가져오기 위한 1번, 그리고 각 아이템을 가져오기 위한 n 번입니다. GraphQL을 사용하면 (그 후 유지보수해야 하는 커스텀 엔드포인트를 만들지 않고도) 동일한 데이터를 단 한 번의 네트워크 요청으로 서버에서 가져올 수 있습니다:
graphql.get(query { stories { id, text } }).then( stories => { // A list of story items: // [ { id: "...", text: "..." } ] console.log(stories); });
지금까지는 GraphQL을 전형적인 REST 접근의 더 효율적인 버전으로만 사용한 셈입니다. GraphQL 버전에서의 두 가지 중요한 장점에 주목하세요:
간단한 애플리케이션이라도 이미 좋은 개선입니다.
서버에서 정보를 반복적으로 다시 가져오는 것은 상당히 느려질 수 있습니다. 예를 들어 스토리 목록에서 목록 항목으로 이동했다가 다시 스토리 목록으로 돌아오면, 전체 목록을 다시 가져와야 합니다. 표준적인 해결책인 캐싱(caching) 으로 이를 해결해봅시다.
리소스 중심 REST 시스템에서는 URI 기반의 응답 캐시(response cache) 를 유지할 수 있습니다:
var _cache = new Map();rest.get = uri => { if (!_cache.has(uri)) { _cache.set(uri, fetch(uri)); } return _cache.get(uri);};
응답 캐싱은 GraphQL에도 적용할 수 있습니다. 기본적인 접근은 REST 버전과 유사하게 동작합니다. 쿼리 텍스트 자체를 캐시 키로 사용할 수 있습니다:
var _cache = new Map();graphql.get = queryText => { if (!_cache.has(queryText)) { _cache.set(queryText, fetchGraphQL(queryText)); } return _cache.get(queryText);};
이제 이전에 캐싱된 데이터에 대한 요청은 네트워크 요청 없이 즉시 응답할 수 있습니다. 이는 애플리케이션의 체감 성능을 개선하는 실용적인 접근입니다. 하지만 이런 캐싱 방식은 데이터 일관성 측면에서 문제를 일으킬 수 있습니다.
GraphQL에서는 여러 쿼리의 결과가 서로 겹치는(overlap) 경우가 매우 흔합니다. 하지만 앞 절의 응답 캐시는 이 겹침을 고려하지 않습니다 — 서로 다른 쿼리를 기준으로 캐싱하기 때문입니다. 예를 들어 스토리를 가져오는 쿼리를 실행했다고 합시다:
query { stories { id, text, likeCount } }
그리고 나중에 likeCount가 증가한 뒤 그 스토리 중 하나를 다시 가져온다면:
query { story(id: "123") { id, text, likeCount } }
이제 스토리에 접근하는 방식에 따라 서로 다른 likeCount를 보게 됩니다. 첫 번째 쿼리를 사용하는 뷰는 오래된 카운트를 보게 되고, 두 번째 쿼리를 사용하는 뷰는 업데이트된 카운트를 보게 됩니다.
GraphQL을 캐싱하는 해법은 계층형 응답을 평평한(flat) 레코드(records) 컬렉션으로 정규화(normalize)하는 것입니다. Relay는 이 캐시를 ID에서 레코드로의 맵으로 구현합니다. 각 레코드는 필드 이름에서 필드 값으로의 맵입니다. 레코드는 다른 레코드를 링크할 수도 있는데(이를 통해 순환 그래프를 표현 가능), 이런 링크는 최상위 맵을 다시 참조하는 특수한 값 타입으로 저장됩니다. 이 접근을 사용하면 서버 레코드는 어떻게 가져오든 상관없이 한 번만 저장됩니다.
아래는 스토리의 텍스트와 작성자 이름을 가져오는 예시 쿼리입니다:
query { story(id: "1") { text, author { name } }}
가능한 응답은 다음과 같습니다:
{ "query": { "story": { "text": "Relay is open-source!", "author": { "name": "Jan" } } }}
응답은 계층적이지만, 모든 레코드를 평탄화하여 캐시합니다. Relay가 이 쿼리 응답을 캐시하는 방식의 예시는 다음과 같습니다:
Map { // story(id: "1") 1: Map { text: 'Relay is open-source!', author: Link(2), }, //story.author 2: Map { name: 'Jan', },};
이는 단순한 예시일 뿐입니다. 실제로는 캐시가 일대다(one-to-many) 연관관계와 페이지네이션(등)을 처리해야 합니다.
그렇다면 이 캐시를 어떻게 사용할까요? 두 가지 연산을 살펴봅시다: 응답을 받았을 때 캐시에 쓰기(write), 그리고 쿼리를 로컬에서 충족할 수 있는지 판단하기 위해 캐시에서 읽기(read)입니다(위의 _cache.has(key)와 동등하지만, 그래프에 대해 수행합니다).
캐시를 채우는 과정은 계층형 GraphQL 응답을 순회하며 정규화된 캐시 레코드를 생성하거나 업데이트하는 것입니다. 처음에는 응답만으로도 충분히 처리할 수 있을 것처럼 보이지만, 실제로는 아주 단순한 쿼리에서만 그렇습니다. user(id: "456") { photo(size: 32) { uri } }를 생각해보세요 — photo를 어떻게 저장해야 할까요? 캐시에서 필드 이름으로 photo를 쓰면, 다른 쿼리가 동일한 필드를 다른 인자 값으로 가져올 수도 있기 때문에 동작하지 않습니다(예: photo(size: 64) {...}). 페이지네이션에서도 유사한 문제가 발생합니다. stories(first: 10, offset: 10)로 11번째부터 20번째 스토리를 가져온다면, 이 새 결과는 기존 리스트에 추가(append) 되어야 합니다.
따라서 GraphQL을 위한 정규화된 응답 캐시는 페이로드와 쿼리를 병렬로 처리해야 합니다. 예를 들어 위의 photo 필드는 인자 값과 함께 필드를 유일하게 식별할 수 있도록 photo_size(32) 같은 생성된 필드 이름으로 캐시될 수 있습니다.
캐시에서 읽기 위해서는 쿼리를 순회하면서 각 필드를 해석(resolve)하면 됩니다. 그런데 잠깐만요: 이건 GraphQL 서버가 쿼리를 처리할 때 하는 일과 정확히 같지 않나요? 맞습니다! 캐시에서 읽기는 실행기(executor)의 특수한 경우입니다. a) 결과가 고정된 데이터 구조에서 오기 때문에 사용자 정의 필드 함수가 필요 없고, b) 결과가 항상 동기적입니다 — 데이터가 캐시되어 있거나 아니거나 둘 중 하나입니다.
Relay는 쿼리 순회(query traversal) 의 여러 변형을 구현합니다. 이는 쿼리를 캐시나 응답 페이로드 같은 다른 데이터와 나란히 두고 걷는(walk) 연산입니다. 예를 들어 쿼리를 가져올 때 Relay는 어떤 필드가 누락됐는지 판단하기 위해 “diff” 순회를 수행합니다(React가 가상 DOM 트리를 diff하는 것과 유사). 이는 많은 일반적인 경우에서 가져오는 데이터 양을 줄일 수 있으며, 쿼리가 완전히 캐시되어 있을 때는 네트워크 요청 자체를 피하도록도 해줍니다.
이 정규화된 캐시 구조는 겹치는 결과를 중복 없이 캐시할 수 있게 해준다는 점에 주목하세요. 각 레코드는 어떻게 가져오든 관계없이 한 번만 저장됩니다. 앞서의 데이터 불일치 예시로 돌아가서, 이 캐시가 그 상황에서 어떻게 도움이 되는지 봅시다.
첫 번째 쿼리는 스토리 목록에 대한 것이었습니다:
query { stories { id, text, likeCount } }
정규화된 응답 캐시에서는 목록의 각 스토리에 대해 레코드가 생성됩니다. stories 필드는 각 레코드로의 링크를 저장합니다.
두 번째 쿼리는 그 스토리들 중 하나의 정보를 다시 가져왔습니다:
query { story(id: "123") { id, text, likeCount } }
이 응답이 정규화될 때, Relay는 id를 기반으로 이 결과가 기존 데이터와 겹친다는 것을 감지할 수 있습니다. 새 레코드를 만드는 대신, Relay는 기존의 123 레코드를 업데이트합니다. 따라서 새로운 likeCount는 두 쿼리 모두 에서 사용할 수 있고, 이 스토리를 참조할 수 있는 어떤 다른 쿼리에서도 사용할 수 있습니다.
정규화된 캐시는 캐시 의 일관성을 보장합니다. 하지만 뷰는 어떨까요? 이상적으로는 React 뷰가 항상 캐시의 최신 정보를 반영해야 합니다.
스토리의 텍스트와 댓글을, 해당 작성자의 이름과 사진과 함께 렌더링하는 상황을 생각해봅시다. GraphQL 쿼리는 다음과 같습니다:
query { story(id: "1") { text, author { name, photo }, comments { text, author { name, photo } } }}
이 스토리를 처음 가져온 뒤 캐시는 다음과 같을 수 있습니다. 스토리와 댓글이 모두 author로서 동일한 레코드를 링크한다는 점에 주목하세요:
// Note: This is pseudo-code for Mapinitialization to make the structure// more obvious.Map { //story(id: "1") 1: Map { text: 'got GraphQL?', author: Link(2), comments: [Link(3)], }, //story.author 2: Map { name: 'Yuzhi', photo: 'http://.../photo1.jpg', }, //story.comments[0] 3: Map { text: 'Here\'s how to get one!', author: Link(2), },}
이 스토리의 작성자가 댓글도 달았군요 — 꽤 흔한 일입니다. 이제 어떤 다른 뷰가 작성자에 대한 새로운 정보를 가져왔고, 프로필 사진이 새로운 URI로 변경되었다고 상상해봅시다. 캐시에서 바뀌는 것은 다음의 부분 뿐입니다:
Map { ... 2: Map { ... photo: 'http://.../photo2.jpg', },}
photo 필드 값이 바뀌었고, 따라서 레코드 2도 바뀌었습니다. 그게 전부입니다. 캐시 에서 다른 어떤 것도 영향을 받지 않습니다. 하지만 분명히 뷰 는 이 업데이트를 반영해야 합니다. UI에서 작성자가 등장하는 두 곳(스토리 작성자, 댓글 작성자) 모두가 새 사진을 보여줘야 합니다.
흔한 대답은 “그냥 불변(immutable) 데이터 구조를 쓰면 된다”인데, 실제로 그렇게 하면 어떤 일이 일어나는지 봅시다:
ImmutableMap { 1: ImmutableMap // same as before 2: ImmutableMap { ... // other fields unchanged photo: 'http://.../photo2.jpg', }, 3: ImmutableMap // same as before}
2를 새로운 불변 레코드로 교체하면, 캐시 객체의 새로운 불변 인스턴스도 얻게 됩니다. 하지만 레코드 1과 3은 건드리지 않습니다. 데이터가 정규화되어 있기 때문에, story 레코드만 보고는 story의 내용이 바뀌었는지 알 수 없습니다.
평탄화된 캐시와 뷰를 최신 상태로 유지하기 위한 해법은 다양합니다. Relay가 취하는 접근은 각 UI 뷰를 그 뷰가 참조하는 ID 집합에 매핑하는 것입니다. 이 경우 스토리 뷰는 스토리(1), 작성자(2), 댓글(3 및 다른 댓글들)에 대한 업데이트를 구독(subscribe)합니다. 캐시에 데이터를 쓸 때 Relay는 어떤 ID가 영향을 받는지 추적하고, 그 ID를 구독하는 뷰에만 알립니다. 영향을 받은 뷰는 다시 렌더링하고, 영향을 받지 않은 뷰는 성능을 위해 렌더링을 생략(opt-out)합니다(Relay는 안전하면서도 효과적인 기본 shouldComponentUpdate를 제공합니다). 이 전략이 없다면 아주 작은 변경에도 모든 뷰가 다시 렌더링될 것입니다.
이 해법은 쓰기(writes) 에도 동일하게 적용된다는 점에 주목하세요: 캐시에 대한 어떤 업데이트든 영향을 받은 뷰에 알리며, 쓰기란 캐시를 업데이트하는 또 하나의 방식일 뿐입니다.
지금까지는 데이터를 조회하고 뷰를 최신 상태로 유지하는 과정을 살펴봤지만, 쓰기(write)에 대해서는 보지 않았습니다. GraphQL에서 쓰기는 뮤테이션(mutations) 이라고 부릅니다. 이를 부수 효과(side effect)가 있는 쿼리로 생각할 수 있습니다. 예를 들어 현재 사용자가 특정 스토리를 ‘좋아요’ 표시한 것으로 만들 수 있는 뮤테이션을 호출하는 예시는 다음과 같습니다:
// Give a human-readable name and define the types of the inputs,// in this case the id of the story to mark as liked.mutation StoryLike($storyID: String) { // Call the mutation field and trigger its side effects storyLike(storyID: $storyID) { // Define fields to re-fetch after the mutation completes likeCount }}
뮤테이션의 결과로 변경되었을 수도 있는 데이터를 쿼리하고 있다는 점에 주목하세요. 자연스러운 질문은 이것입니다: 서버가 그냥 무엇이 바뀌었는지 알려주면 안 되나요? 답은: 복잡하기 때문입니다. GraphQL은 어떤 데이터 저장 계층(또는 여러 소스의 집계) 위에서도 추상화되며, 어떤 프로그래밍 언어와도 함께 동작합니다. 또한 GraphQL의 목표는 뷰를 만드는 제품 개발자에게 유용한 형태로 데이터를 제공하는 것입니다.
우리는 GraphQL 스키마가 디스크에 저장되는 데이터의 형태와 약간 다르거나, 심지어 크게 다른 경우가 흔하다는 것을 발견했습니다. 간단히 말해: 기반 데이터 저장소 (디스크)에서의 데이터 변화와, 제품에 보이는 스키마 (GraphQL)에서의 데이터 변화가 항상 1:1로 대응하지는 않습니다. 이에 대한 완벽한 예가 프라이버시입니다. 사용자에게 보이는 age 같은 필드를 반환하려면, 활성 사용자가 그 age를 볼 수 있는지 판단하기 위해 데이터 저장 계층의 수많은 레코드에 접근해야 할 수 있습니다(우리는 친구인가? 내 나이는 공유되었나? 내가 당신을 차단했나? 등).
이런 현실적인 제약 때문에, GraphQL에서는 뮤테이션 이후에 바뀔 수 있는 것들을 클라이언트가 다시 쿼리하는 접근을 취합니다. 그렇다면 그 쿼리에는 정확히 무엇을 넣어야 할까요? Relay를 개발하는 동안 여러 아이디어를 탐색했습니다 — Relay가 사용하는 접근을 이해하기 위해, 이를 간단히 살펴보겠습니다:
옵션 1: 앱이 지금까지 쿼리한 모든 것을 다시 가져옵니다. 실제로 변경되는 데이터는 이 중 아주 작은 부분일 뿐이지만, 서버가 전체 쿼리를 실행할 때까지 기다리고, 결과를 다운로드할 때까지 기다리고, 다시 처리할 때까지 기다려야 합니다. 매우 비효율적입니다.
옵션 2: 현재 렌더링되고 있는 뷰에 필요한 쿼리만 다시 가져옵니다. 옵션 1보다 약간 개선됩니다. 하지만 현재 보고 있지 않은 캐시 데이터는 업데이트되지 않습니다. 이 데이터가 오래되었다고 표시되거나 캐시에서 제거되지 않는 한, 이후 쿼리는 오래된 정보를 읽게 됩니다.
옵션 3: 뮤테이션 후에 바뀔 수도 있는 필드의 고정된 목록을 다시 가져옵니다. 이 목록을 fat query라고 부릅시다. 우리는 이 방식도 비효율적이라는 것을 발견했습니다. 일반적인 애플리케이션은 fat query의 일부만 렌더링하지만, 이 접근은 그 필드 전부를 가져오도록 요구하기 때문입니다.
옵션 4(Relay): 바뀔 수도 있는 것(fat query)과 캐시에 있는 데이터의 교집합을 다시 가져옵니다. Relay는 데이터 캐시뿐 아니라 각 아이템을 가져오는 데 사용된 쿼리도 기억합니다. 이를 tracked queries라고 합니다. tracked query와 fat query를 교차시키면, Relay는 애플리케이션이 업데이트에 필요한 정확한 정보 집합만 쿼리하고 그 이상은 하지 않을 수 있습니다.
지금까지는 데이터 패칭의 더 낮은 수준의 측면을 살펴보며, 익숙한 개념들이 GraphQL로 어떻게 옮겨지는지 보았습니다. 이제 한 발 물러서서, 제품 개발자들이 데이터 패칭과 관련해 자주 마주치는 더 높은 수준의 관심사들을 봅시다:
우리는 전형적인 데이터 패칭 접근 — 명령형(imperative) API — 이 개발자에게 이런 본질적이지 않은 복잡성을 너무 많이 떠넘긴다는 것을 발견했습니다. 예를 들어 낙관적 UI 업데이트(optimistic UI updates) 를 생각해봅시다. 이는 서버 응답을 기다리는 동안 사용자에게 피드백을 주는 방법입니다. 무엇을 해야 하는지는 꽤 명확할 수 있습니다: 사용자가 “좋아요”를 클릭하면, 스토리를 좋아요한 것으로 표시하고 서버로 요청을 보냅니다. 하지만 구현은 종종 훨씬 복잡합니다. 명령형 접근은 그 모든 단계를 직접 구현하게 만듭니다: UI를 파고들어 버튼을 토글하고, 네트워크 요청을 시작하고, 필요하면 재시도하고, 실패하면 에러를 보여주고(그리고 버튼 토글을 되돌리고), 등등. 데이터 패칭도 마찬가지입니다: 우리가 어떤 데이터가 필요한지를 지정하는 것이 종종 그 데이터가 어떻게 언제 가져와지는지를 좌우합니다. 다음으로, Relay로 이러한 관심사들을 해결하기 위한 우리의 접근을 살펴보겠습니다.