본문으로 건너뛰기
Kreath Archive
TechProjectsBooksAbout
TechProjectsBooksAbout

내비게이션

  • Tech
  • Projects
  • Books
  • About
  • Tags

카테고리

  • AI / ML
  • 웹 개발
  • 프로그래밍
  • 개발 도구

연결

  • GitHub
  • Email
  • RSS
© 2026 Kreath Archive. All rights reserved.Built with Next.js + MDX
홈TechProjectsBooksAbout
//
  1. 홈
  2. 테크
  3. 8장: 캐싱 전략
2026년 2월 20일·웹 개발·

8장: 캐싱 전략

GraphQL API의 다양한 캐싱 전략을 다룹니다. HTTP 캐싱의 한계, Persisted Queries를 통한 CDN 캐싱, @cacheControl 디렉티브, Redis 기반 리졸버 캐싱, 캐시 무효화 패턴을 학습합니다.

13분808자9개 섹션
graphqlapi-designfrontend
공유
graphql-architecture8 / 11
1234567891011
이전7장: 인증과 인가다음9장: 코드 생성과 타입 안전성

학습 목표

  • GraphQL에서 HTTP 캐싱이 어려운 이유를 이해한다
  • Persisted Queries와 APQ를 통한 CDN 캐싱을 구현할 수 있다
  • @cacheControl 디렉티브로 응답 수준 캐싱을 설정한다
  • Redis를 활용한 리졸버 수준 캐싱과 캐시 무효화 전략을 익힌다

GraphQL과 HTTP 캐싱의 난제

REST API에서는 HTTP 캐싱이 자연스럽습니다. 각 엔드포인트가 고유한 URL을 가지고, GET 요청에 Cache-Control 헤더를 설정하면 브라우저와 CDN이 자동으로 캐싱합니다.

text
GET /api/posts/123
Cache-Control: public, max-age=3600

GraphQL은 다릅니다. 모든 요청이 동일한 엔드포인트(/graphql)로 향하고, 대부분 POST 메서드를 사용합니다. POST 요청은 HTTP 사양에 의해 캐시 불가능합니다.

text
POST /graphql
Content-Type: application/json
 
{"query": "{ post(id: \"123\") { title content } }"}

게다가 같은 엔드포인트로 전혀 다른 데이터를 요청할 수 있으므로, URL만으로는 캐시 키를 결정할 수 없습니다.

이 문제를 해결하기 위한 여러 계층의 캐싱 전략을 살펴보겠습니다.


Persisted Queries와 CDN 캐싱

Persisted Queries

Persisted Queries는 쿼리 문자열을 사전에 등록하고, 실행 시에는 해시 ID만 전송하는 방식입니다.

persisted-queries-register.ts
typescript
// 빌드 타임에 쿼리 목록 생성
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 } } }
  }`,
};
text
# 기존 요청 (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과 브라우저 캐시를 활용할 수 있습니다. 또한 임의의 쿼리 실행을 차단하여 보안도 강화됩니다.

Automatic Persisted Queries (APQ)

APQ는 사전 등록 없이 자동으로 동작하는 Persisted Queries입니다.

apq-server.ts
typescript
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')),
});
Tip

프로덕션 환경에서는 APQ보다 정적 Persisted Queries를 권장합니다. 빌드 타임에 모든 쿼리를 추출하여 허용 목록(allowlist)을 만들면, 등록되지 않은 쿼리 실행을 완전히 차단할 수 있어 보안이 강화됩니다.


응답 캐싱: @cacheControl

Apollo Server의 @cacheControl 디렉티브를 사용하면 필드/타입 단위로 캐시 정책을 선언할 수 있습니다.

스키마에 캐시 힌트 추가

cache-control.graphql
graphql
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캐시 유효 시간 (초)
scopePUBLIC (CDN 캐시 가능) 또는 PRIVATE (브라우저만)

캐시 정책 계산

응답의 전체 Cache-Control 헤더는 쿼리에 포함된 모든 필드 중 가장 제한적인 값으로 결정됩니다.

cache-calculation.graphql
graphql
query {
  post(id: "1") {          # maxAge: 3600
    title                   # 상속: 3600
    viewCount               # maxAge: 300
    comments {              # maxAge: 60
      body
    }
  }
}
# 결과 헤더: Cache-Control: public, max-age=60
cache-plugin.ts
typescript
import { 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

응답 전체가 아닌 개별 리졸버의 결과를 Redis에 캐싱하는 방식입니다. 가장 세밀한 제어가 가능합니다.

redis-resolver-cache.ts
typescript
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에 캐싱 통합

캐싱 로직을 DataSource 레이어에 통합하면 리졸버를 깔끔하게 유지할 수 있습니다.

cached-data-source.ts
typescript
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) 입니다.

시간 기반 무효화 (TTL)

가장 단순한 방식입니다. 설정된 시간이 지나면 자동으로 캐시가 만료됩니다.

ttl-invalidation.ts
typescript
// 인기글 목록: 5분마다 갱신
await redis.setex('popular-posts', 300, JSON.stringify(posts));
 
// 사용자 프로필: 30분마다 갱신
await redis.setex(`user:${id}`, 1800, JSON.stringify(user));

이벤트 기반 무효화

데이터 변경 시 관련 캐시를 즉시 삭제합니다.

event-invalidation.ts
typescript
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;
  }
}

태그 기반 무효화

관련 캐시를 태그로 그룹화하여 한 번에 무효화합니다.

tag-invalidation.ts
typescript
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}`);
Warning

캐시 무효화가 복잡해지면 "캐시를 사용하지 않는 것"이 더 나은 선택일 수 있습니다. 데이터 일관성이 중요한 영역에서는 캐싱 대신 DataLoader의 요청 단위 캐시만으로 충분한 경우가 많습니다.


캐싱 계층별 비교

계층위치적용 범위장점단점
클라이언트 정규화 캐시Apollo Client컴포넌트중복 요청 제거브라우저 한정
CDN 캐시CloudFront/Fastly전역최고 성능GET + Persisted Query 필요
응답 캐시서버 메모리/Redis요청설정 간단세밀한 제어 어려움
리졸버 캐시Redis필드세밀한 제어무효화 복잡
DataLoader 캐시메모리요청 내자동, N+1 해결요청 간 공유 불가

엣지 캐싱

CDN 엣지에서 GraphQL 응답을 캐싱하는 고급 패턴입니다.

edge-caching.ts
typescript
// 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#api-design#frontend

관련 글

웹 개발

9장: 코드 생성과 타입 안전성

GraphQL Code Generator를 활용한 TypeScript 타입 생성, React 훅 자동 생성, Fragment Colocation 패턴을 다룹니다. 코드 퍼스트 스키마 빌더와의 비교, CI 통합 전략도 학습합니다.

2026년 2월 22일·13분
웹 개발

7장: 인증과 인가

GraphQL API에서의 인증과 인가 전략을 다룹니다. JWT 인증 미들웨어, 컨텍스트 기반 사용자 주입, 리졸버 수준 인가, 디렉티브 기반 선언적 인가, RBAC/ABAC 모델을 학습합니다.

2026년 2월 18일·13분
웹 개발

10장: 프로덕션 운영과 모니터링

GraphQL API의 프로덕션 운영 전략을 다룹니다. 스키마 변경 관리, Breaking Change 감지, 스키마 레지스트리, 쿼리 제한, 관측 가능성, 에러 처리, 성능 모니터링을 학습합니다.

2026년 2월 24일·13분
이전 글7장: 인증과 인가
다음 글9장: 코드 생성과 타입 안전성

댓글

목차

약 13분 남음
  • 학습 목표
  • GraphQL과 HTTP 캐싱의 난제
  • Persisted Queries와 CDN 캐싱
    • Persisted Queries
    • Automatic Persisted Queries (APQ)
  • 응답 캐싱: @cacheControl
    • 스키마에 캐시 힌트 추가
    • 캐시 정책 계산
  • 리졸버 수준 캐싱: Redis
    • DataSource에 캐싱 통합
  • 캐시 무효화
    • 시간 기반 무효화 (TTL)
    • 이벤트 기반 무효화
    • 태그 기반 무효화
  • 캐싱 계층별 비교
  • 엣지 캐싱
  • 정리
    • 다음 장 미리보기