07 · Server-side Response Cache — 서버가 자기 응답을 캐시하다
질문: 클라이언트 캐시·persisted query 다 했는데, 같은 인기 글의 동일 쿼리가 수천 명에게서 들어온다. resolver를 매번 실행해야 하나? 한 줄 답: 서버에서 응답 JSON 자체를
(query, variables, auth)키로 캐시하면, 같은 요청은 DB도 resolver도 거치지 않고 즉시 반환된다 —@cacheControl(maxAge, scope)힌트가 필드별 TTL과 scope을 합성한다.
Pyramid Top
이전 6개 문서가 클라이언트와 네트워크 쪽 캐시를 다뤘다면, 이번 문서는 서버 자기 자신의 캐시다. 백엔드의 마지막 방어선 — DataLoader(03-n-plus-1)가 요청 1개 안의 fan-out을 막았다면, response cache는 요청 N개 사이의 동일 응답을 막는다. 핵심은 필드 단위 cache hint를 응답 단위로 합성하는 알고리즘 — Apollo가 그 합성 규칙을 spec처럼 정해두었고, Hasura는 DB가 그래프인 모델에 맞춰 다른 전략을 쓴다.
사고 흐름
5개 레이어가 직렬로 동작하고, response cache는 클라이언트와 DB 사이의 마지막 방어선이다.
Why — DataLoader로 다 푼다 생각하기 쉽지만
DataLoader는 한 요청 안의 N+1을 batch + 같은 tick cache로 흡수한다. 그런데:
- 서로 다른 두 사용자가 같은 query를 동시에 보내면 → DataLoader가 각자 1번씩 실행.
- 5초 간격으로 같은 인기 게시글을 수천 명이 본다 → DB는 매번 같은 SQL을 받는다.
이게 DataLoader가 못 푸는 자리다. 요청 자체를 캐시해야 한다.
How — Apollo Response Cache Plugin
Apollo Server는 공식 플러그인으로 제공:
import { ApolloServer } from "@apollo/server";
import { ApolloServerPluginCacheControl } from "@apollo/server/plugin/cacheControl";
import responseCachePlugin from "@apollo/server-plugin-response-cache";
import Keyv from "keyv";
import KeyvRedis from "@keyv/redis";
const cache = new Keyv(new KeyvRedis("redis://localhost"));
const server = new ApolloServer({
typeDefs,
resolvers,
plugins: [
ApolloServerPluginCacheControl({ defaultMaxAge: 0 }),
responseCachePlugin({
cache,
// 어떻게 key를 만들까?
sessionId: (req) => req.contextValue.user?.id ?? null,
// sessionId === null → public cache
// sessionId !== null → private cache (그 user만)
}),
],
});@cacheControl(maxAge, scope) 디렉티브
스키마에 필드별 힌트:
type Query {
popularPosts: [Post]! @cacheControl(maxAge: 60)
me: User! @cacheControl(maxAge: 10, scope: PRIVATE)
}
type Post @cacheControl(maxAge: 240) {
id: ID!
title: String!
author: User! # ← author 필드는 자체 maxAge로 다시 계산
}
type User @cacheControl(maxAge: 30, scope: PRIVATE) {
id: ID!
name: String!
}또는 코드에서:
const resolvers = {
Query: {
popularPosts(_, __, ctx, info) {
info.cacheControl.setCacheHint({ maxAge: 60 });
return db.popularPosts();
},
},
};합성 규칙 — 가장 보수적인 힌트가 이긴다
응답 한 개에 여러 필드가 각자 힌트를 갖고 있을 때, 응답 단위 maxAge는:
min(모든 필드의 maxAge)
scope는:
모든 필드가 PUBLIC이면 PUBLIC, 하나라도 PRIVATE이면 PRIVATE
예시:
query {
popularPosts { # maxAge: 60
title
author { # maxAge: 30, PRIVATE
name
}
}
}→ 응답 maxAge = min(60, 240, 30) = 30, scope = PRIVATE.
이 합성 규칙이 핵심이다. 한 필드의 낮은 힌트가 전체 응답의 캐시 가능성을 결정한다. 그래서 PRIVATE 한 필드를 잘못 추가하면 모든 사용자 공유 가능했던 응답이 갑자기 user별 cache로 분리된다.
What — Full vs Scoped
| 차원 | Full response cache | Scoped (필드 단위) |
|---|---|---|
| 키 | (query, variables, sessionId) | 필드 + 인자 |
| 저장 위치 | Redis·Memcached·LRU | 같음 |
| 갱신 | TTL 만료 또는 invalidate | TTL 또는 entity invalidate |
| 어떻게 사용 | 응답 전체를 그대로 반환 | resolver가 부분 hit + 부분 miss를 합성 |
| 구현 난이도 | 낮음 | 높음 (resolver-level 정합성) |
Apollo의 공식 plugin은 full response cache다. 필드 단위 캐시는 직접 짜야 한다 (또는 GraphQL Mesh·Stellate 같은 edge 캐시).
scope의 세 가지 의미
scope: PUBLIC # 모든 사용자 공유 가능
scope: PRIVATE # sessionId 별로 분리
# (생략 시 default — defaultMaxAge로 결정)PRIVATE의 결과:
- key에
sessionId가 포함됨. - 같은 query라도 user 별로 다른 cache entry.
- 메모리 사용량이 user 수만큼 곱해진다.
scope을 잘못 설정하면 데이터 누설 사고로 직결. user A의 캐시된 응답이 user B에게 반환될 수 있다. 인증·인가가 들어간 모든 필드는
PRIVATE의무.
Invalidate — TTL만으로 충분한가
TTL은 시간 기반 만료고, 실시간성과는 양립이 어렵다.
- maxAge=60 → 갱신 후 최대 60초는 stale.
- 실시간성이 중요하면? → 명시적 invalidate가 필요.
// mutation 후
await cache.delete(/* 영향받는 cache key들 */);문제: 어떤 key를 invalidate해야 하나? 응답 한 개는 수십 개 필드에서 왔다. 어느 mutation이 어느 응답을 무효화하는지 손으로 매핑하는 건 비현실적.
그래서 동적 invalidate가 어려운 곳은 짧은 TTL로 타협한다. Hasura·Stellate는 DB write event로 자동 invalidate하는 방식을 쓴다 — 다음 절 참고.
Hasura의 query caching — DB가 그래프인 모델
Hasura·PostGraphile은 DB schema가 곧 GraphQL schema다. 그래서 일반 GraphQL 서버와 다른 캐시 전략을 쓴다.
query @cached(ttl: 60) {
authors { id name posts { id title } }
}@cached directive 하나로:
- 응답 JSON을 Redis에 저장.
- TTL 만료까지 DB 안 거침.
Hasura의 진짜 강점은 invalidate:
- DB write event(
INSERT/UPDATE/DELETE)를 감지. - 그 테이블에 의존하는 cache key를 자동 무효화.
- → TTL이 길어도 stale 데이터가 거의 없다.
이건 DB 중심 그래프 모델이라 가능한 트릭이다. resolver가 임의 SQL이나 외부 API를 호출하는 일반 GraphQL 서버는 어느 데이터에 의존하는지를 자동 추적할 수 없다.
What-if — response cache 없이 운영하면
| 사례 | 결과 |
|---|---|
| 인기 게시글에 매일 100만 hit | DB가 매번 같은 SQL → connection pool exhaust |
| feed query 1초당 1만 호출 | resolver fan-out 폭발 → CPU saturation |
| 같은 admin dashboard 5명 동시 보기 | 5명 모두 DB까지 가는데, 응답은 같음 |
| Spike 트래픽 (Slashdot 효과) | scale-out으로만 대응 → 비용 폭증 |
response cache가 없으면 GraphQL은 DataLoader의 batch 효과도 요청 단위로만 갇힌다. 캐시 hit이 DB load 감소에 기여하는 폭이 가장 크다.
다른 도구들
| 도구 | 어떻게 캐시하나 |
|---|---|
| Apollo Server response cache plugin | full response, Redis/LRU, @cacheControl 합성 |
| GraphQL Mesh | edge에서 cache (CDN 친화), invalidate via mutation hooks |
| Stellate (formerly GraphCDN) | edge CDN으로 GET URL 캐시 + write-through invalidate |
| Hasura query caching | @cached(ttl:), DB event 기반 자동 invalidate |
| PostGraphile | 비슷, query cache + persisted |
| Mercurius (Fastify) | JIT + per-query response cache |
흥미로운 이야기
**Stellate는 “GraphCDN”이라는 이름으로 출발해 서드파티 CDN이 되었다
2020년 The Guild이 *“GraphQL을 CDN처럼 캐시할 수 있다”*는 발견으로 GraphCDN (현 Stellate)을 만들었다. 핵심은 Apollo의
@cacheControl을 edge node에서 해석하는 것 — 클라이언트와 origin 사이에 GraphQL-aware CDN을 끼우는 발상이다. 보통 CDN은 URL을 모르고 GraphQL은 URL이 같다. Stellate는 body를 파싱해서 cache key를 만든다 —parse(query) + variables + auth scope. 그리고 mutation을 cache invalidator로 자동 등록한다. DataLoader가 server-side 그래프 fan-out을 흡수했다면, Stellate는 edge-side 그래프 fan-out을 흡수한 것. 새 추상은 늘 기존 추상의 한 층 위에 자란다 — DataLoader → response cache → edge cache.
Insight — 5개 레이어의 응답 시간 구성
캐시는 맨 위 레이어로 갈수록 빠르다. 최적화는 위에서부터 시도한다 — 클라이언트 → CDN → 서버 → DB 순. 이 챕터가 그 순서대로 7개 문서를 배열한 이유다.
한 단락 요약
Server-side response cache는 (query, variables, auth) 키로 응답 JSON 전체를 저장해, 같은 요청이 DB와 resolver를 거치지 않게 한다. Apollo의 response cache plugin이
@cacheControl(maxAge, scope)힌트를 필드 단위로 받아서 응답 단위로 합성한다 — 합성 규칙은 가장 보수적인 힌트가 이긴다(min maxAge, any PRIVATE wins). Hasura·PostGraphile은 DB가 그래프인 모델 위에서 DB write event로 자동 invalidate하는 우월한 전략을 갖는다. Stellate 같은 GraphQL-aware CDN은 edge에서 같은 일을 한다. 이 레이어가 빠지면 DataLoader의 batch 효과도 요청 단위에 갇혀 인기 글의 트래픽 급증이 그대로 DB를 친다.다음 챕터(
06-federation)는 한 그래프를 여러 팀이 나눠 가질 때 — 그 위에 캐시·실행·스키마가 어떻게 다시 짜이는가를 다룬다.