GraphQL API의 다양한 캐싱 전략을 다룹니다. HTTP 캐싱의 한계, Persisted Queries를 통한 CDN 캐싱, @cacheControl 디렉티브, Redis 기반 리졸버 캐싱, 캐시 무효화 패턴을 학습합니다.
@cacheControl 디렉티브로 응답 수준 캐싱을 설정한다REST API에서는 HTTP 캐싱이 자연스럽습니다. 각 엔드포인트가 고유한 URL을 가지고, GET 요청에 Cache-Control 헤더를 설정하면 브라우저와 CDN이 자동으로 캐싱합니다.
GET /api/posts/123
Cache-Control: public, max-age=3600GraphQL은 다릅니다. 모든 요청이 동일한 엔드포인트(/graphql)로 향하고, 대부분 POST 메서드를 사용합니다. POST 요청은 HTTP 사양에 의해 캐시 불가능합니다.
POST /graphql
Content-Type: application/json
{"query": "{ post(id: \"123\") { title content } }"}게다가 같은 엔드포인트로 전혀 다른 데이터를 요청할 수 있으므로, URL만으로는 캐시 키를 결정할 수 없습니다.
이 문제를 해결하기 위한 여러 계층의 캐싱 전략을 살펴보겠습니다.
Persisted Queries는 쿼리 문자열을 사전에 등록하고, 실행 시에는 해시 ID만 전송하는 방식입니다.
// 빌드 타임에 쿼리 목록 생성
const persistedQueries: Record<string, string> = {
'abc123': `query GetPost($id: ID!) {
post(id: $id) { title content author { name } }
}`,
'def456': `query ListPosts($first: Int!) {
posts(first: $first) { edges { node { title summary } } }
}`,
};# 기존 요청 (POST, 캐시 불가)
POST /graphql
{"query": "query GetPost($id: ID!) { post(id: $id) { ... } }", "variables": {"id": "123"}}
# Persisted Query 요청 (GET, 캐시 가능!)
GET /graphql?extensions={"persistedQuery":{"sha256Hash":"abc123"}}&variables={"id":"123"}GET 요청으로 변환되므로 CDN과 브라우저 캐시를 활용할 수 있습니다. 또한 임의의 쿼리 실행을 차단하여 보안도 강화됩니다.
APQ는 사전 등록 없이 자동으로 동작하는 Persisted Queries입니다.
import { ApolloServer } from '@apollo/server';
import { ApolloServerPluginCacheControl } from '@apollo/server/plugin/cacheControl';
const server = new ApolloServer({
typeDefs,
resolvers,
// APQ는 Apollo Server에서 기본 활성화
// 프로덕션에서는 Redis를 APQ 저장소로 사용
cache: new KeyvAdapter(new Keyv('redis://localhost:6379')),
});프로덕션 환경에서는 APQ보다 정적 Persisted Queries를 권장합니다. 빌드 타임에 모든 쿼리를 추출하여 허용 목록(allowlist)을 만들면, 등록되지 않은 쿼리 실행을 완전히 차단할 수 있어 보안이 강화됩니다.
Apollo Server의 @cacheControl 디렉티브를 사용하면 필드/타입 단위로 캐시 정책을 선언할 수 있습니다.
type Query {
post(id: ID!): Post
me: User @cacheControl(maxAge: 0, scope: PRIVATE)
}
type Post @cacheControl(maxAge: 3600) {
id: ID!
title: String!
content: String!
viewCount: Int! @cacheControl(maxAge: 300)
author: User!
comments: [Comment!]! @cacheControl(maxAge: 60)
}
type User @cacheControl(maxAge: 1800) {
id: ID!
name: String!
email: String! @cacheControl(scope: PRIVATE)
}| 속성 | 설명 |
|---|---|
maxAge | 캐시 유효 시간 (초) |
scope | PUBLIC (CDN 캐시 가능) 또는 PRIVATE (브라우저만) |
응답의 전체 Cache-Control 헤더는 쿼리에 포함된 모든 필드 중 가장 제한적인 값으로 결정됩니다.
query {
post(id: "1") { # maxAge: 3600
title # 상속: 3600
viewCount # maxAge: 300
comments { # maxAge: 60
body
}
}
}
# 결과 헤더: Cache-Control: public, max-age=60import { ApolloServer } from '@apollo/server';
import { ApolloServerPluginCacheControl } from '@apollo/server/plugin/cacheControl';
import responseCachePlugin from '@apollo/server-plugin-response-cache';
const server = new ApolloServer({
typeDefs,
resolvers,
plugins: [
ApolloServerPluginCacheControl({
defaultMaxAge: 0, // 기본값: 캐시 안 함
}),
responseCachePlugin({
// 사용자별 캐시 분리
sessionId: async (requestContext) => {
return requestContext.contextValue.currentUser?.id ?? null;
},
}),
],
});응답 전체가 아닌 개별 리졸버의 결과를 Redis에 캐싱하는 방식입니다. 가장 세밀한 제어가 가능합니다.
import Redis from 'ioredis';
const redis = new Redis(process.env.REDIS_URL!);
interface CacheOptions {
ttl: number; // 초 단위
prefix: string;
}
async function cachedResolver<T>(
key: string,
options: CacheOptions,
resolver: () => Promise<T>,
): Promise<T> {
const cacheKey = `${options.prefix}:${key}`;
// 1. 캐시 확인
const cached = await redis.get(cacheKey);
if (cached) {
return JSON.parse(cached) as T;
}
// 2. 캐시 미스 시 리졸버 실행
const result = await resolver();
// 3. 결과 캐싱
await redis.setex(cacheKey, options.ttl, JSON.stringify(result));
return result;
}
// 리졸버에서 활용
const resolvers = {
Query: {
post: async (_parent: unknown, args: { id: string }, context: Context) => {
return cachedResolver(
args.id,
{ ttl: 3600, prefix: 'post' },
() => context.dataSources.posts.findById(args.id),
);
},
popularPosts: async (_parent: unknown, _args: unknown, context: Context) => {
return cachedResolver(
'all',
{ ttl: 300, prefix: 'popular-posts' },
() => context.dataSources.posts.findPopular(),
);
},
},
};캐싱 로직을 DataSource 레이어에 통합하면 리졸버를 깔끔하게 유지할 수 있습니다.
class CachedPostDataSource extends PostDataSource {
constructor(prisma: PrismaClient, private redis: Redis) {
super(prisma);
}
async findById(id: string): Promise<Post | null> {
const cacheKey = `post:${id}`;
const cached = await this.redis.get(cacheKey);
if (cached) {
return JSON.parse(cached) as Post;
}
const post = await super.findById(id);
if (post) {
await this.redis.setex(cacheKey, 3600, JSON.stringify(post));
}
return post;
}
async update(id: string, input: UpdatePostInput): Promise<Post> {
const post = await super.update(id, input);
// 업데이트 시 캐시 무효화
await this.redis.del(`post:${id}`);
return post;
}
}캐싱에서 가장 어려운 부분은 무효화(Invalidation) 입니다.
가장 단순한 방식입니다. 설정된 시간이 지나면 자동으로 캐시가 만료됩니다.
// 인기글 목록: 5분마다 갱신
await redis.setex('popular-posts', 300, JSON.stringify(posts));
// 사용자 프로필: 30분마다 갱신
await redis.setex(`user:${id}`, 1800, JSON.stringify(user));데이터 변경 시 관련 캐시를 즉시 삭제합니다.
class PostService {
async updatePost(id: string, input: UpdatePostInput): Promise<Post> {
const post = await this.prisma.post.update({
where: { id },
data: input,
});
// 관련 캐시 무효화
await Promise.all([
this.redis.del(`post:${id}`),
this.redis.del('popular-posts'),
this.redis.del(`user-posts:${post.authorId}`),
// 태그가 변경되었으면 태그 캐시도 무효화
...(input.tags
? input.tags.map((tag) => this.redis.del(`tag-posts:${tag}`))
: []),
]);
return post;
}
}관련 캐시를 태그로 그룹화하여 한 번에 무효화합니다.
class TaggedCache {
constructor(private redis: Redis) {}
async set(key: string, value: unknown, ttl: number, tags: string[]): Promise<void> {
const pipeline = this.redis.pipeline();
// 값 저장
pipeline.setex(key, ttl, JSON.stringify(value));
// 태그-키 매핑 저장
for (const tag of tags) {
pipeline.sadd(`tag:${tag}`, key);
pipeline.expire(`tag:${tag}`, ttl);
}
await pipeline.exec();
}
async invalidateByTag(tag: string): Promise<void> {
const keys = await this.redis.smembers(`tag:${tag}`);
if (keys.length > 0) {
await this.redis.del(...keys, `tag:${tag}`);
}
}
}
// 사용 예시
const cache = new TaggedCache(redis);
// 포스트 캐싱 시 태그 지정
await cache.set(
`post:${post.id}`,
post,
3600,
['posts', `author:${post.authorId}`, `category:${post.categoryId}`],
);
// 작성자의 모든 관련 캐시 무효화
await cache.invalidateByTag(`author:${authorId}`);캐시 무효화가 복잡해지면 "캐시를 사용하지 않는 것"이 더 나은 선택일 수 있습니다. 데이터 일관성이 중요한 영역에서는 캐싱 대신 DataLoader의 요청 단위 캐시만으로 충분한 경우가 많습니다.
| 계층 | 위치 | 적용 범위 | 장점 | 단점 |
|---|---|---|---|---|
| 클라이언트 정규화 캐시 | Apollo Client | 컴포넌트 | 중복 요청 제거 | 브라우저 한정 |
| CDN 캐시 | CloudFront/Fastly | 전역 | 최고 성능 | GET + Persisted Query 필요 |
| 응답 캐시 | 서버 메모리/Redis | 요청 | 설정 간단 | 세밀한 제어 어려움 |
| 리졸버 캐시 | Redis | 필드 | 세밀한 제어 | 무효화 복잡 |
| DataLoader 캐시 | 메모리 | 요청 내 | 자동, N+1 해결 | 요청 간 공유 불가 |
CDN 엣지에서 GraphQL 응답을 캐싱하는 고급 패턴입니다.
// Cloudflare Workers 예시
export default {
async fetch(request: Request): Promise<Response> {
const url = new URL(request.url);
// GET + Persisted Query인 경우에만 엣지 캐싱
if (request.method === 'GET' && url.searchParams.has('extensions')) {
const cache = caches.default;
const cacheKey = new Request(url.toString(), { method: 'GET' });
const cachedResponse = await cache.match(cacheKey);
if (cachedResponse) {
return cachedResponse;
}
const response = await fetch(request);
const clonedResponse = new Response(response.body, response);
// Cache-Control 헤더가 있으면 엣지에 캐싱
if (response.headers.get('cache-control')?.includes('max-age')) {
await cache.put(cacheKey, clonedResponse.clone());
}
return clonedResponse;
}
// POST 요청은 오리진으로 직접 전달
return fetch(request);
},
};이번 장에서는 GraphQL API의 다양한 캐싱 전략을 학습했습니다. HTTP 캐싱의 한계를 Persisted Queries로 극복하는 방법, @cacheControl 디렉티브를 통한 응답 수준 캐싱, Redis를 활용한 리졸버 수준 캐싱, 그리고 각 전략의 무효화 패턴을 다루었습니다.
캐싱은 성능을 극적으로 향상시키지만 복잡도를 높입니다. "측정 먼저, 캐싱 나중" 원칙을 따르고, 진정으로 병목인 부분에만 적절한 수준의 캐싱을 적용하는 것이 바람직합니다.
9장에서는 코드 생성과 타입 안전성을 다룹니다. GraphQL Code Generator를 활용한 TypeScript 타입 생성, React 훅 자동 생성, Fragment Colocation 패턴, 그리고 코드 퍼스트 빌더와의 비교를 살펴보겠습니다.
이 글이 도움이 되셨나요?
관련 주제 더 보기
GraphQL Code Generator를 활용한 TypeScript 타입 생성, React 훅 자동 생성, Fragment Colocation 패턴을 다룹니다. 코드 퍼스트 스키마 빌더와의 비교, CI 통합 전략도 학습합니다.
GraphQL API에서의 인증과 인가 전략을 다룹니다. JWT 인증 미들웨어, 컨텍스트 기반 사용자 주입, 리졸버 수준 인가, 디렉티브 기반 선언적 인가, RBAC/ABAC 모델을 학습합니다.
GraphQL API의 프로덕션 운영 전략을 다룹니다. 스키마 변경 관리, Breaking Change 감지, 스키마 레지스트리, 쿼리 제한, 관측 가능성, 에러 처리, 성능 모니터링을 학습합니다.