🔷 GraphQL5. 캐시 & 성능07 · Server-side Response Cache

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 cacheScoped (필드 단위)
(query, variables, sessionId)필드 + 인자
저장 위치Redis·Memcached·LRU같음
갱신TTL 만료 또는 invalidateTTL 또는 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만 hitDB가 매번 같은 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 pluginfull response, Redis/LRU, @cacheControl 합성
GraphQL Meshedge에서 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)는 한 그래프를 여러 팀이 나눠 가질 때 — 그 위에 캐시·실행·스키마가 어떻게 다시 짜이는가를 다룬다.