Persisted queries를 통한 캐시 제어
GraphQL은 일반적으로 POST를 통해 동작하며, 모든 쿼리를 단일 엔드포인트에 대해 실행하고 요청 본문에 파라미터를 전달합니다. 해당 단일 엔드포인트의 URL은 서로 다른 응답을 반환하므로 캐시할 수 없습니다(적어도 URL을 식별자로 사용하는 경우에는).
따라서 GraphQL에서 캐싱을 지원하는 표준적인 방법은 Apollo 클라이언트 및 이와 유사한 라이브러리를 통한 클라이언트 레이어의 캐싱입니다. 이러한 라이브러리는 반환된 객체를 각각 독립적으로 캐시하고 전역 고유 ID로 식별합니다.
(반면, 서버에서 캐싱할 때는 일반적으로 URL을 식별자로 사용하며, 응답의 모든 엔티티 데이터를 함께 캐시합니다.)
하지만 이 해결책에는 몇 가지 단점이 있습니다:
- 클라이언트 측에서 실행해야 할 JavaScript가 늘어납니다. 저사양 스마트폰으로 웹사이트에 접속하면 성능이 저하됩니다
- 캐싱 레이어 구현도 고려해야 하므로 애플리케이션이 더 복잡해지고 관리해야 할 부분이 늘어납니다
- JavaScript를 모든 사람이 이해하는 것은 아닙니다(예: 웹사이트가 PHP로 개발된 경우). 하지만 JS 처리도 책임이 되어 버립니다
훨씬 더 나은 해결책은 HTTP 캐싱을 사용하는 것입니다. 이것이 작동하기 위해 필요한 전제 조건을 살펴보겠습니다.
GET을 통한 GraphQL 접근
HTTP 캐싱을 사용한다는 것은 URL을 식별자로 사용하여 GraphQL 응답을 캐시한다는 의미입니다. 이에는 두 가지 함의가 있습니다:
- GraphQL의 단일 엔드포인트에
GET으로 접근해야 합니다 - 쿼리와 변수를 URL 파라미터로 전달해야 합니다
따라서 단일 엔드포인트가 /graphql인 경우, GET 작업은 URL /graphql?query=...&variables=...에 대해 실행할 수 있습니다.
이는 서버에서 데이터를 조회하는 경우(query 작업)에 적용됩니다. 데이터를 변경하는 경우(mutation 작업)에는 여전히 POST를 사용해야 합니다. 여기서는 문제가 없습니다. mutation은 항상 새롭게 실행되기 때문에 mutation 결과를 캐시할 수 없으며, 어차피 HTTP 캐싱을 사용하지 않을 것이기 때문입니다.
이 접근 방식은 작동하며(공식 사이트에서도 권장합니다), 주의해야 할 몇 가지 고려 사항이 있습니다.
URL 파라미터를 통한 GraphQL 쿼리 코딩
GraphQL 쿼리는 일반적으로 여러 줄에 걸쳐 있습니다. 예를 들어:
{
posts {
id
title
}
}하지만 이 여러 줄 문자열을 URL 파라미터에 직접 입력할 수는 없습니다.
해결책은 인코딩하는 것입니다. 예를 들어, GraphiQL 클라이언트는 위의 쿼리를 다음과 같이 인코딩합니다:
%7B%0A%20%20posts%20%7B%0A%20%20%20%20id%0A%20%20%20%20title%0A%20%20%7D%0A%7D
네, 이것은 작동합니다. 하지만 보기가 별로 좋지 않죠? 이 쿼리의 내용을 이해할 수 있는 사람이 있을까요?
GraphQL의 장점 중 하나는 쿼리가 매우 이해하기 쉽다는 것입니다. 조금만 연습하면 쿼리를 보자마자 바로 이해할 수 있습니다. 하지만 일단 인코딩되면 그 장점은 모두 사라지고 기계만 해독할 수 있게 됩니다. 사람은 방정식 밖으로 밀려납니다.
또 다른 해결책은 쿼리의 모든 줄 바꿈을 공백으로 교체하는 것입니다. 줄 바꿈은 쿼리에 의미론적 의미를 추가하지 않기 때문에 이것은 작동합니다. 그러면 위의 쿼리는 다음과 같이 표현할 수 있습니다:
?query={ posts { id title } }
이것은 간단한 쿼리에는 잘 작동합니다. 하지만 { }를 여러 번 열고 닫고, 필드 인수와 디렉티브를 추가하는 매우 긴 쿼리가 있다면 점점 더 이해하기 어려워집니다.
예를 들어, 이 쿼리는:
{
posts(limit:5) {
id
title @titleCase
excerpt @default(
value:"No title",
condition:IS_EMPTY
)
author {
name
}
tags {
id
name
}
comments(
limit:3,
order:"date|DESC"
) {
id
date(format:"d/m/Y")
author {
name
}
content
}
}
}다음과 같은 한 줄 쿼리가 됩니다:
{ posts(limit:5) { id title @titleCase excerpt @default(value:"No title", condition:IS_EMPTY) author { name } tags { id name } comments(limit:3, order:"date|DESC") { id date(format:"d/m/Y") author { name } content } } }
다시 한번 말씀드리지만, 쿼리 실행은 작동하지만 무엇을 실행하고 있는지 알 수 없습니다.
쿼리에 프래그먼트도 포함되어 있다면, 그때는 완전히 포기할 수밖에 없으며 이해할 방법이 없습니다.
Persisted queries의 등장
쿼리를 URL에 전달하는 것이 만족스럽지 않다면, 다른 어떤 선택지가 있을까요? 바로 쿼리를 URL에 전달하지 않는 것입니다!
이것이 「persisted query」라고 불리는 접근 방식입니다: 쿼리를 서버에 저장하고, 식별자(숫자 ID 또는 쿼리를 입력으로 해시 알고리즘을 적용하여 생성된 고유 문자열 등)를 사용하여 가져옵니다. 마지막으로, 쿼리 대신 이 식별자를 URL 파라미터로 전달합니다.
예를 들어, 쿼리를 ID 2908(또는 "50ac3e81"과 같은 해시)로 식별하고, URL /graphql?id=2908에 대해 GET 작업을 실행합니다. GraphQL 서버는 이 ID에 해당하는 쿼리를 가져와 실행하고 결과를 반환합니다.
Gato GraphQL은 이를 더욱 간편하게 만들어 줍니다: persisted query는 커스텀 게시물 유형으로 구현되므로, 일반 게시물과 마찬가지로 생성하여 게시할 수 있으며, 선택한 슬러그(기본적으로 입력한 제목을 기반으로 함)가 식별자가 됩니다. Persisted queries를 사용하면 HTTP 캐싱 구현이 매우 간단해집니다.
max-age 값 계산
HTTP 캐싱은 응답에 Cache-Control 헤더를 보내는 방식으로 작동합니다. 이 헤더에는 응답을 캐시해야 하는 시간을 나타내는 max-age 값, 또는 캐시하지 않음을 나타내는 no-store가 포함됩니다.
서로 다른 필드가 각각 다른 max-age 값을 가질 수 있을 때, GraphQL 서버는 쿼리의 max-age 값을 어떻게 계산할까요?
답은 다음과 같습니다: 쿼리에서 요청된 모든 필드의 max-age 값을 가져와 그 중 가장 낮은 값을 찾습니다. 그것이 응답의 max-age가 됩니다.
예를 들어, User 유형의 엔티티가 있다고 가정해 봅시다. 이 엔티티에 할당된 동작에 따라 해당 필드를 얼마나 오래 캐시할 수 있는지 지정할 수 있습니다:
🛠 ID는 절대 변하지 않습니다 ⇒ 필드 id에 max-age로 1년을 설정합니다
🛠 URL은 거의(또는 전혀) 업데이트되지 않습니다 ⇒ 필드 url에 max-age로 1일을 설정합니다
🛠 사람의 이름은 가끔 변경될 수 있습니다(예: 상태를 추가하거나 "Milton (마스크 착용)"으로 표시하는 경우) ⇒ 필드 name에 max-age로 1시간을 설정합니다
🛠 사이트에서 사용자의 카르마는 언제든지 변경될 수 있습니다(예: 누군가 댓글에 찬성표를 준 후) ⇒ 필드 karma에 max-age로 1분을 설정합니다
🛠 로그인한 사용자의 데이터를 조회하는 경우, 어떤 필드를 가져오든 응답을 전혀 캐시할 수 없습니다 ⇒ max-age는 반드시 no-store여야 합니다
결과적으로, 다음 GraphQL 쿼리에 대한 응답은 다음과 같은 max-age 값을 가집니다(이 예에서는 Root.users 필드의 max-age를 무시하지만, 실제로는 고려됩니다):
| 쿼리 | max-age 값 |
|---|---|
| 1년 |
| 1일 |
| 1시간 |
| 1분 |
| no-store(캐시하지 않음) |
Cache Control List 생성
각 필드의 max-age를 확인했으면 Cache Control List를 통해 이 정보를 입력합니다:

그러면 Gato GraphQL이 자동으로 응답의 max-age 값을 계산하여 Cache-Control HTTP 헤더로 반환합니다.