REST API를 사용하여 애플리케이션을 개발하면서 엔드포인트가 증가하고 API 스펙이 빈번히 변경되는 상황을 경험했습니다. 이는 클라이언트와 서버 간 인터페이스 유지보수를 어렵게 만들고, API 문서를 최신 상태로 유지하는 데 상당한 비용을 발생시켰습니다.
GraphQL은 이러한 문제를 타입 시스템 기반의 스키마와 선언적 데이터 페칭으로 해결합니다. 이 글에서는 GraphQL의 핵심 개념, 타입 시스템, Resolver 동작 원리, N+1 문제 해결 전략, 그리고 Relay vs Apollo 비교를 상세히 분석합니다.
오버페칭(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은 SQL과 유사한 선언적 쿼리 언어입니다.
GraphQL 쿼리:
query GetUserDashboard($userId: ID!) {
user(id: $userId) {
name
email
orders(limit: 5) {
id
total
status
}
reviews(limit: 3) {
rating
comment
}
}
}특징:
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 UploadNon-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는 각 필드의 데이터를 가져오는 함수입니다.
// 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 → "김철수"
문제 상황:
// 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철학: 컴포넌트가 필요한 데이터를 자체적으로 선언합니다.
// 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} />
</>
);
};장점:
id를 기반으로 자동 정규화@connection 디렉티브로 무한 스크롤 자동 처리단점:
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} />
</>
);
};장점:
getDataFromTree(), Next.js 통합InMemoryCache 커스터마이징 가능단점:
typePolicies로 직접 구성| 기능 | Relay | Apollo Client |
|---|---|---|
| Fragment Colocation | [지원] 강제 (권장) | [부분] 선택적 |
| Compiler | [지원] 필수 (최적화) | [미지원] 선택적 |
| SSR | [미지원] 미지원 | [지원] 공식 지원 |
| 캐시 정규화 | [지원] 자동 | [부분] 수동 설정 |
| Pagination | [지원] @connection |
[부분] fetchMore 수동 |
| 학습 곡선 | 높음 | 낮음 |
| 대규모 앱 확장성 | [지원] 우수 | [부분] 수동 관리 필요 |
// 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 } }"
}# 스키마 전체 조회
query IntrospectionQuery {
__schema {
types {
name
fields {
name
type {
name
}
}
}
}
}GraphQL Playground:
http://localhost:4000/graphql → 자동 생성된 IDE
- 자동 완성
- 타입 힌트
- 실시간 문서
- 쿼리 실행
// 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
}
}// [주의] 전체 시스템 한 번에 마이그레이션
// 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 }
}
}
}
}문제:
# 악의적 쿼리 (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)
})
]
});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 }
})
]
});// 기존 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
}
}import { stitchSchemas } from '@graphql-tools/stitch';
// 여러 GraphQL 서버 통합
const gatewaySchema = stitchSchemas({
subschemas: [
{
schema: usersSchema, # Users 마이크로서비스
executor: usersExecutor
},
{
schema: ordersSchema, # Orders 마이크로서비스
executor: ordersExecutor
}
]
});// 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의 한계를 극복합니다.
핵심 권장사항:
스키마 우선 설계:
N+1 문제 해결:
클라이언트 선택:
점진적 마이그레이션:
참고 자료:
Context API와 React Query의 장점을 결합한 최적화된 상태 관리 솔루션