← 홈으로 돌아가기

🔮 GraphQL + Codegen + 캐싱 전략

타입 안전성과 효율적인 데이터 캐싱을 위한 GraphQL 아키텍처

📦 필요한 패키지

npm install @apollo/client graphql
npm install -D @graphql-codegen/cli @graphql-codegen/typescript
npm install -D @graphql-codegen/typescript-operations
npm install -D @graphql-codegen/typescript-react-apollo

✨ GraphQL 스택의 핵심 특징

GraphQL

  • • 필요한 데이터만 요청
  • • 단일 엔드포인트
  • • 타입 시스템 내장
  • • 실시간 구독

Codegen

  • • TypeScript 자동 생성
  • • Hook 자동 생성
  • • 타입 안전성 보장
  • • 개발자 경험 향상

Apollo Client

  • • 지능형 캐싱
  • • 정규화된 저장소
  • • Optimistic UI
  • • DevTools 지원

캐싱 전략 시뮬레이터

💾 Cache First

먼저 캐시를 확인하고, 없을 때만 서버에 요청합니다. 빠른 응답 속도를 제공하지만 오래된 데이터를 표시할 수 있습니다.

fetchPolicy: "cache-first"
요청 로그가 여기에 표시됩니다...

데이터 정규화 (Normalization)

정규화 전 (중복 데이터)

{
  "post1": {
    "id": "1",
    "title": "GraphQL Basics",
    "author": {
      "id": "user1",
      "name": "Alice",
      "email": "alice@example.com"
    }
  },
  "post2": {
    "id": "2",
    "title": "Advanced GraphQL",
    "author": {
      "id": "user1",
      "name": "Alice",
      "email": "alice@example.com"
    }
  }
}
⚠️ 문제점:
  • 동일한 사용자 데이터 중복 저장
  • 메모리 낭비
  • 데이터 일관성 문제

정규화 후 (참조 구조)

{
  "User:user1": {
    "id": "user1",
    "name": "Alice",
    "email": "alice@example.com"
  },
  "Post:1": {
    "id": "1",
    "title": "GraphQL Basics",
    "author": { "__ref": "User:user1" }
  },
  "Post:2": {
    "id": "2",
    "title": "Advanced GraphQL",
    "author": { "__ref": "User:user1" }
  }
}
✅ 장점:
  • 사용자 데이터 1회만 저장
  • 메모리 효율적
  • 자동 업데이트 전파

1️⃣ GraphQL Schema 정의

# schema.graphql
type User {
  id: ID!
  name: String!
  email: String!
  posts: [Post!]!
}

type Post {
  id: ID!
  title: String!
  content: String!
  author: User!
  comments: [Comment!]!
  createdAt: DateTime!
}

type Comment {
  id: ID!
  text: String!
  author: User!
  post: Post!
  createdAt: DateTime!
}

type Query {
  user(id: ID!): User
  users: [User!]!
  post(id: ID!): Post
  posts(limit: Int, offset: Int): [Post!]!
}

type Mutation {
  createPost(title: String!, content: String!): Post!
  updatePost(id: ID!, title: String, content: String): Post!
  deletePost(id: ID!): Boolean!
}

type Subscription {
  postAdded: Post!
  commentAdded(postId: ID!): Comment!
}

2️⃣ GraphQL Codegen 설정

// codegen.ts
import type { CodegenConfig } from '@graphql-codegen/cli';

const config: CodegenConfig = {
  schema: 'http://localhost:4000/graphql', // GraphQL 서버 URL
  documents: ['src/**/*.graphql', 'src/**/*.tsx'], // 쿼리가 있는 파일
  generates: {
    './src/__generated__/': {
      preset: 'client',
      plugins: [],
      presetConfig: {
        gqlTagName: 'gql',
      }
    },
    './src/__generated__/graphql.ts': {
      plugins: [
        'typescript',
        'typescript-operations',
        'typescript-react-apollo'
      ],
      config: {
        withHooks: true, // useQuery, useMutation Hook 자동 생성
        withComponent: false,
        withHOC: false,
      }
    }
  },
};

export default config;

실행 명령어:

npm run codegen

3️⃣ Apollo Client 설정

import { ApolloClient, InMemoryCache, createHttpLink } from '@apollo/client';
import { setContext } from '@apollo/client/link/context';

// HTTP 연결 설정
const httpLink = createHttpLink({
  uri: 'http://localhost:4000/graphql',
});

// 인증 헤더 추가
const authLink = setContext((_, { headers }) => {
  const token = localStorage.getItem('token');
  return {
    headers: {
      ...headers,
      authorization: token ? `Bearer ${token}` : "",
    }
  };
});

// Apollo Client 생성
export const client = new ApolloClient({
  link: authLink.concat(httpLink),
  cache: new InMemoryCache({
    typePolicies: {
      Query: {
        fields: {
          posts: {
            // 페이지네이션 병합 전략
            keyArgs: false,
            merge(existing = [], incoming) {
              return [...existing, ...incoming];
            },
          },
        },
      },
      Post: {
        fields: {
          comments: {
            // 댓글 병합 전략
            merge(existing = [], incoming) {
              return incoming;
            },
          },
        },
      },
    },
  }),
  defaultOptions: {
    watchQuery: {
      fetchPolicy: 'cache-and-network',
      errorPolicy: 'all',
    },
    query: {
      fetchPolicy: 'cache-first',
      errorPolicy: 'all',
    },
    mutate: {
      errorPolicy: 'all',
    },
  },
});

// App에서 사용
import { ApolloProvider } from '@apollo/client';

function App() {
  return (
    <ApolloProvider client={client}>
      <YourApp />
    </ApolloProvider>
  );
}

4️⃣ GraphQL Query 작성

// queries/posts.graphql
query GetPosts($limit: Int, $offset: Int) {
  posts(limit: $limit, offset: $offset) {
    id
    title
    content
    createdAt
    author {
      id
      name
      email
    }
    comments {
      id
      text
      author {
        id
        name
      }
    }
  }
}

query GetPost($id: ID!) {
  post(id: $id) {
    id
    title
    content
    createdAt
    author {
      id
      name
      email
    }
  }
}

mutation CreatePost($title: String!, $content: String!) {
  createPost(title: $title, content: $content) {
    id
    title
    content
    createdAt
  }
}

// ✅ Codegen이 자동으로 생성하는 Hook:
// - useGetPostsQuery
// - useGetPostQuery
// - useCreatePostMutation

5️⃣ 컴포넌트에서 사용

import { useGetPostsQuery, useCreatePostMutation } from './__generated__/graphql';

function PostList() {
  // ✅ 자동 생성된 Hook 사용 (타입 안전)
  const { data, loading, error, refetch } = useGetPostsQuery({
    variables: { limit: 10, offset: 0 },
    fetchPolicy: 'cache-and-network',
  });

  const [createPost, { loading: creating }] = useCreatePostMutation({
    // 캐시 업데이트 전략
    update(cache, { data: newPost }) {
      cache.modify({
        fields: {
          posts(existingPosts = []) {
            const newPostRef = cache.writeFragment({
              data: newPost?.createPost,
              fragment: gql`
                fragment NewPost on Post {
                  id
                  title
                  content
                }
              `,
            });
            return [newPostRef, ...existingPosts];
          },
        },
      });
    },
    // Optimistic Response (즉시 UI 업데이트)
    optimisticResponse: {
      createPost: {
        __typename: 'Post',
        id: 'temp-id',
        title: newTitle,
        content: newContent,
        createdAt: new Date().toISOString(),
        author: {
          __typename: 'User',
          id: currentUser.id,
          name: currentUser.name,
          email: currentUser.email,
        },
      },
    },
  });

  if (loading) return <div>Loading...</div>;
  if (error) return <div>Error: {error.message}</div>;

  return (
    <div>
      {data?.posts.map((post) => (
        <div key={post.id}>
          <h2>{post.title}</h2>
          <p>{post.content}</p>
          <span>by {post.author.name}</span>
        </div>
      ))}
    </div>
  );
}

6️⃣ 캐시 직접 조작

import { useApolloClient } from '@apollo/client';

function PostActions({ postId }: { postId: string }) {
  const client = useApolloClient();

  // 1. 캐시에서 읽기
  const readCache = () => {
    const post = client.readFragment({
      id: `Post:${postId}`,
      fragment: gql`
        fragment PostData on Post {
          id
          title
          content
        }
      `,
    });
    console.log('Cached post:', post);
  };

  // 2. 캐시에 쓰기
  const updateCache = () => {
    client.writeFragment({
      id: `Post:${postId}`,
      fragment: gql`
        fragment UpdatePost on Post {
          title
        }
      `,
      data: {
        title: 'Updated Title',
      },
    });
  };

  // 3. 특정 쿼리 무효화
  const invalidateQuery = () => {
    client.refetchQueries({
      include: ['GetPosts'],
    });
  };

  // 4. 캐시 초기화
  const clearCache = () => {
    client.cache.reset();
  };

  // 5. 특정 필드 업데이트
  const updateField = () => {
    client.cache.modify({
      id: `Post:${postId}`,
      fields: {
        title(cachedTitle) {
          return cachedTitle + ' (Updated)';
        },
      },
    });
  };

  return <div>...</div>;
}

📋 캐싱 전략 선택 가이드

cache-first (기본값)

캐시를 먼저 확인하고 없을 때만 서버 요청

사용 사례: 자주 변경되지 않는 데이터 (사용자 프로필, 설정)

cache-and-network

캐시 데이터 즉시 반환 + 백그라운드 서버 요청

사용 사례: 빠른 초기 로딩이 중요한 데이터 (피드, 게시글 목록)

network-only

항상 서버에서 최신 데이터 가져옴

사용 사례: 실시간 데이터, 금융 정보, 재고 현황

no-cache

서버에서 가져오고 캐시에 저장하지 않음

사용 사례: 일회성 데이터, 민감한 정보

✅ 장점

  • 타입 안전성: Codegen으로 완벽한 타입 추론
  • 효율적 캐싱: 정규화된 캐시로 중복 제거
  • 개발자 경험: Hook 자동 생성
  • Optimistic UI: 즉각적인 사용자 피드백
  • 단일 엔드포인트: 복잡도 감소
  • Over-fetching 방지: 필요한 데이터만 요청

⚠️ 주의사항

  • 초기 설정: 러닝 커브 존재
  • 번들 크기: Apollo Client ~130KB
  • 캐시 복잡도: 정규화 이해 필요
  • N+1 문제: 서버 측 DataLoader 필요
  • 파일 업로드: 별도 처리 필요