뒤로가기

GraphQL 아키텍처: 타입 시스템과 효율적인 API 설계 전략

graphql

REST API를 사용하여 애플리케이션을 개발하면서 엔드포인트가 증가하고 API 스펙이 빈번히 변경되는 상황을 경험했습니다. 이는 클라이언트와 서버 간 인터페이스 유지보수를 어렵게 만들고, API 문서를 최신 상태로 유지하는 데 상당한 비용을 발생시켰습니다.

GraphQL은 이러한 문제를 타입 시스템 기반의 스키마선언적 데이터 페칭으로 해결합니다. 이 글에서는 GraphQL의 핵심 개념, 타입 시스템, Resolver 동작 원리, N+1 문제 해결 전략, 그리고 Relay vs Apollo 비교를 상세히 분석합니다.

REST API의 구조적 한계

오버페칭과 언더페칭

오버페칭(Over-fetching):

// REST API 응답
GET /api/users/123
{
  "id": 123,
  "name": "김철수",
  "email": "kim@example.com",
  "address": { /* 전체 주소 정보 */ },
  "phoneNumbers": [ /* 모든 전화번호 */ ],
  "orderHistory": [ /* 주문 내역 100개 */ ],  // ← 불필요한 데이터
  "preferences": { /* 모든 설정 */ }           // ← 불필요한 데이터
}
 
// 실제 필요한 데이터는 name과 email뿐

언더페칭(Under-fetching):

// 사용자 대시보드 렌더링을 위한 REST API 호출
const dashboard = async (userId) => {
  const user = await fetch(`/api/users/${userId}`);        // 1번 요청
  const orders = await fetch(`/api/users/${userId}/orders`); // 2번 요청
  const reviews = await fetch(`/api/users/${userId}/reviews`); // 3번 요청
 
  // Waterfall 발생: 총 3번의 네트워크 요청
  return { user, orders, reviews };
};

API 버전 관리의 복잡성:

// v1: 초기 버전
GET /api/v1/products
 
// v2: 필드 추가
GET /api/v2/products  // 새 필드 포함
 
// 문제: 클라이언트마다 필요한 버전이 다름
// → 여러 버전 동시 유지보수 필요

GraphQL의 핵심 개념

선언적 데이터 페칭

GraphQL은 SQL과 유사한 선언적 쿼리 언어입니다.

GraphQL 쿼리:

query GetUserDashboard($userId: ID!) {
  user(id: $userId) {
    name
    email
    orders(limit: 5) {
      id
      total
      status
    }
    reviews(limit: 3) {
      rating
      comment
    }
  }
}

특징:

  • 단일 요청: 1번의 HTTP 요청으로 모든 데이터 페칭
  • 정확한 데이터: 필요한 필드만 요청 가능
  • 타입 안전성: 쿼리 작성 시점에 타입 검증

스키마 정의 언어 (SDL)

GraphQL은 스펙이지 구현체가 아닙니다. 언어 중립적인 SDL로 스키마를 정의합니다.

# 타입 정의
type User {
  id: ID!                    # Non-null ID
  name: String!
  email: String!
  age: Int
  posts: [Post!]!            # Non-null 배열, Non-null 요소
  createdAt: DateTime!
}
 
type Post {
  id: ID!
  title: String!
  content: String!
  author: User!              # Relation
  comments: [Comment!]!
  publishedAt: DateTime
}
 
type Comment {
  id: ID!
  text: String!
  author: User!
  post: Post!
}
 
# Query 진입점
type Query {
  user(id: ID!): User
  users(limit: Int, offset: Int): [User!]!
  post(id: ID!): Post
}
 
# Mutation 진입점
type Mutation {
  createUser(name: String!, email: String!): User!
  updatePost(id: ID!, title: String, content: String): Post!
}

스칼라 타입:

Int      # 32비트 정수
Float    # 부동소수점
String   # UTF-8 문자열
Boolean  # true/false
ID       # 고유 식별자 (문자열이지만 의미적으로 구분)
 
# 커스텀 스칼라
scalar DateTime
scalar JSON
scalar Upload

Non-null과 List 모디파이어:

name: String     # nullable String
name: String!    # non-null String
tags: [String!]  # nullable 배열, non-null 요소
tags: [String!]! # non-null 배열, non-null 요소
tags: [String]!  # non-null 배열, nullable 요소

Resolver 동작 원리

Resolver 체인

Resolver는 각 필드의 데이터를 가져오는 함수입니다.

// GraphQL 서버 구현 (Apollo Server)
const resolvers = {
  Query: {
    user: async (parent, args, context) => {
      // args.id로 사용자 조회
      return await context.db.user.findUnique({
        where: { id: args.id }
      });
    }
  },
 
  User: {
    // 각 필드마다 Resolver 정의 가능
    posts: async (parent, args, context) => {
      // parent는 User Resolver의 반환값
      return await context.db.post.findMany({
        where: { authorId: parent.id }
      });
    },
 
    email: (parent, args, context) => {
      // 권한 확인
      if (context.user.id !== parent.id) {
        throw new Error('Unauthorized');
      }
      return parent.email;
    }
  },
 
  Post: {
    author: async (parent, args, context) => {
      return await context.db.user.findUnique({
        where: { id: parent.authorId }
      });
    },
 
    comments: async (parent, args, context) => {
      return await context.db.comment.findMany({
        where: { postId: parent.id }
      });
    }
  }
};

실행 흐름:

Query:
  user(id: "123") {
    name
    posts {
      title
      author { name }
    }
  }

실행 순서:
1. Query.user(id: "123")       → User 객체 반환
2. User.name                   → "김철수" (기본 필드, Resolver 불필요)
3. User.posts                  → Post[] 배열 반환
4. Post[0].title               → "첫 포스트"
5. Post[0].author              → User 객체 반환
6. User.name                   → "김철수"

N+1 문제

문제 상황:

// 10명의 사용자와 각 사용자의 게시글 조회
query {
  users {          # 1번 쿼리: SELECT * FROM users
    name
    posts {        # N번 쿼리: 사용자마다 SELECT * FROM posts WHERE authorId = ?
      title
    }
  }
}
 
// 총 1 + N번의 데이터베이스 쿼리 발생 (N = 10)

해결책 1: DataLoader (Facebook 패턴)

import DataLoader from 'dataloader';
 
// Batch 함수: 여러 키를 받아 한 번에 조회
const batchUsers = async (userIds: string[]) => {
  const users = await db.user.findMany({
    where: { id: { in: userIds } }
  });
 
  // userIds 순서에 맞게 정렬
  const userMap = new Map(users.map(u => [u.id, u]));
  return userIds.map(id => userMap.get(id));
};
 
// Context에 DataLoader 추가
const context = {
  loaders: {
    user: new DataLoader(batchUsers)
  }
};
 
// Resolver에서 사용
const resolvers = {
  Post: {
    author: async (parent, args, context) => {
      // 자동으로 배치 처리됨
      return await context.loaders.user.load(parent.authorId);
    }
  }
};

DataLoader 동작:

요청:
  posts {
    title
    author { name }  # 10개 포스트의 author 조회
  }

Without DataLoader:
  SELECT * FROM posts
  SELECT * FROM users WHERE id = 1  # Post[0]
  SELECT * FROM users WHERE id = 2  # Post[1]
  ...
  SELECT * FROM users WHERE id = 10 # Post[9]
  → 1 + 10 = 11개 쿼리

With DataLoader:
  SELECT * FROM posts
  SELECT * FROM users WHERE id IN (1, 2, ..., 10)  # 배치
  → 2개 쿼리만 실행

해결책 2: JOIN 기반 Resolver

const resolvers = {
  Query: {
    users: async (parent, args, context) => {
      // Prisma의 include로 JOIN
      return await context.db.user.findMany({
        include: {
          posts: true  // LEFT JOIN posts
        }
      });
    }
  }
};
 
// SQL:
// SELECT users.*, posts.*
// FROM users
// LEFT JOIN posts ON posts.authorId = users.id

Relay vs Apollo Client

Relay: Fragment Colocation

철학: 컴포넌트가 필요한 데이터를 자체적으로 선언합니다.

// UserProfile.tsx
import { graphql, useFragment } from 'react-relay';
 
const UserProfile = (props) => {
  const data = useFragment(
    graphql`
      fragment UserProfile_user on User {
        name
        email
        avatar
      }
    `,
    props.user
  );
 
  return (
    <div>
      <img src={data.avatar} alt={data.name} />
      <h1>{data.name}</h1>
      <p>{data.email}</p>
    </div>
  );
};
// UserDashboard.tsx (부모 컴포넌트)
import { graphql, useLazyLoadQuery } from 'react-relay';
import UserProfile from './UserProfile';
 
const UserDashboard = ({ userId }) => {
  const data = useLazyLoadQuery(
    graphql`
      query UserDashboardQuery($userId: ID!) {
        user(id: $userId) {
          ...UserProfile_user  # Fragment 조합
          posts {
            id
            title
          }
        }
      }
    `,
    { userId }
  );
 
  return (
    <>
      <UserProfile user={data.user} />
      <PostList posts={data.user.posts} />
    </>
  );
};

장점:

  • 자동 최적화: Relay Compiler가 Fragment를 분석하여 최적 쿼리 생성
  • 캐시 정규화: id를 기반으로 자동 정규화
  • Pagination: @connection 디렉티브로 무한 스크롤 자동 처리

단점:

  • 학습 곡선: Relay 특화 개념 (Fragment, Connection, @refetchable 등)
  • Build 과정: Relay Compiler 실행 필요
  • SSR 미지원: 공식 SSR 지원 없음 (커뮤니티 솔루션 필요)

Apollo Client: 유연성

import { gql, useQuery } from '@apollo/client';
 
const GET_USER = gql`
  query GetUser($userId: ID!) {
    user(id: $userId) {
      name
      email
      avatar
      posts {
        id
        title
      }
    }
  }
`;
 
const UserDashboard = ({ userId }) => {
  const { data, loading, error } = useQuery(GET_USER, {
    variables: { userId }
  });
 
  if (loading) return <Spinner />;
  if (error) return <Error message={error.message} />;
 
  return (
    <>
      <UserProfile user={data.user} />
      <PostList posts={data.user.posts} />
    </>
  );
};

장점:

  • SSR 공식 지원: getDataFromTree(), Next.js 통합
  • 유연한 캐싱: InMemoryCache 커스터마이징 가능
  • 낮은 학습 곡선: React Query와 유사한 API

단점:

  • 수동 최적화: Fragment 조합을 직접 관리해야 함
  • 캐시 정규화 수동 설정: typePolicies로 직접 구성

비교표

기능 Relay Apollo Client
Fragment Colocation [지원] 강제 (권장) [부분] 선택적
Compiler [지원] 필수 (최적화) [미지원] 선택적
SSR [미지원] 미지원 [지원] 공식 지원
캐시 정규화 [지원] 자동 [부분] 수동 설정
Pagination [지원] @connection [부분] fetchMore 수동
학습 곡선 높음 낮음
대규모 앱 확장성 [지원] 우수 [부분] 수동 관리 필요

GraphQL의 장점

1. 단일 엔드포인트

// REST API: 여러 엔드포인트
POST /api/v1/users
GET /api/v1/users/:id
GET /api/v1/posts
GET /api/v2/posts/:id  # 버전 관리
 
// GraphQL: 단일 엔드포인트
POST /graphql
{
  "query": "query { user(id: 123) { name } }"
}

2. Introspection (자체 문서화)

# 스키마 전체 조회
query IntrospectionQuery {
  __schema {
    types {
      name
      fields {
        name
        type {
          name
        }
      }
    }
  }
}

GraphQL Playground:

http://localhost:4000/graphql → 자동 생성된 IDE
- 자동 완성
- 타입 힌트
- 실시간 문서
- 쿼리 실행

3. 시스템 마이그레이션 유리

// GraphQL 서버가 여러 백엔드 API 통합
const resolvers = {
  User: {
    profile: async (parent) => {
      // Legacy API 호출
      return await fetch(`https://legacy.api.com/users/${parent.id}`);
    },
 
    orders: async (parent) => {
      // 새 마이크로서비스 호출
      return await fetch(`https://orders.api.com/users/${parent.id}/orders`);
    }
  }
};
 
// 클라이언트는 변경 불필요
query {
  user(id: 123) {
    profile { name }   # Legacy API
    orders { total }   # New API
  }
}

주의사항 및 모범 사례

1. 점진적 도입

// [주의] 전체 시스템 한 번에 마이그레이션
// GraphQL로 모든 엔드포인트 교체 → 위험
 
// [권장] 단계적 마이그레이션
// Phase 1: 읽기 전용 쿼리만 GraphQL 전환
query {
  users { name email }
}
 
// Phase 2: Mutation 추가
mutation {
  createUser(name: "김철수", email: "kim@example.com") {
    id
  }
}
 
// Phase 3: 복잡한 Relation 처리
query {
  user(id: 123) {
    posts {
      comments {
        author { name }
      }
    }
  }
}

2. 쿼리 복잡도 제한

문제:

# 악의적 쿼리 (Depth 공격)
query MaliciousQuery {
  user(id: 1) {
    posts {
      comments {
        author {
          posts {
            comments {
              author {
                posts {
                  # ... 무한 깊이
                }
              }
            }
          }
        }
      }
    }
  }
}

해결책:

import { createComplexityLimitRule } from 'graphql-validation-complexity';
 
const server = new ApolloServer({
  schema,
  validationRules: [
    createComplexityLimitRule(1000, {
      onCost: (cost) => console.log('Query cost:', cost)
    })
  ]
});

3. 인프라 모니터링

import { ApolloServerPluginLandingPageDisabled } from '@apollo/server/plugin/disabled';
import { ApolloServerPluginUsageReporting } from '@apollo/server/plugin/usageReporting';
 
const server = new ApolloServer({
  schema,
  plugins: [
    // Production에서 Playground 비활성화
    process.env.NODE_ENV === 'production'
      ? ApolloServerPluginLandingPageDisabled()
      : undefined,
 
    // Apollo Studio 연동
    ApolloServerPluginUsageReporting({
      sendVariableValues: { none: true },
      sendHeaders: { none: true }
    })
  ]
});

실전 마이그레이션 전략

1. REST API 래핑

// 기존 REST API를 GraphQL로 래핑
const resolvers = {
  Query: {
    user: async (parent, args) => {
      const response = await fetch(`https://api.example.com/users/${args.id}`);
      return await response.json();
    }
  }
};
 
// 클라이언트는 GraphQL 쿼리 사용
query {
  user(id: 123) {
    name
    email
  }
}

2. Schema Stitching

import { stitchSchemas } from '@graphql-tools/stitch';
 
// 여러 GraphQL 서버 통합
const gatewaySchema = stitchSchemas({
  subschemas: [
    {
      schema: usersSchema,  # Users 마이크로서비스
      executor: usersExecutor
    },
    {
      schema: ordersSchema,  # Orders 마이크로서비스
      executor: ordersExecutor
    }
  ]
});

3. Apollo Federation

// Users 서비스
const typeDefs = gql`
  type User @key(fields: "id") {
    id: ID!
    name: String!
  }
`;
 
// Orders 서비스
const typeDefs = gql`
  extend type User @key(fields: "id") {
    id: ID! @external
    orders: [Order!]!
  }
`;
 
// Gateway가 자동으로 통합
query {
  user(id: 123) {
    name        # Users 서비스
    orders {    # Orders 서비스
      total
    }
  }
}

결론

GraphQL은 타입 시스템 기반의 스키마와 선언적 데이터 페칭으로 REST API의 한계를 극복합니다.

핵심 권장사항:

  1. 스키마 우선 설계:

    • SDL로 먼저 스키마 정의
    • Introspection으로 자동 문서화
    • 타입 안전성 보장
  2. N+1 문제 해결:

    • DataLoader로 배치 처리
    • JOIN 기반 Resolver 활용
    • 쿼리 복잡도 제한
  3. 클라이언트 선택:

    • Relay: 대규모 앱, Fragment Colocation 필요 시
    • Apollo: SSR 필요, 유연한 구조 선호 시
  4. 점진적 마이그레이션:

    • REST API 래핑부터 시작
    • Schema Stitching/Federation으로 확장
    • 인프라 모니터링 필수

참고 자료: