GraphQL API Design: REST의 한계에서 출발해 GraphQL을 도입하고 돌아온 여정
REST API의 Over-fetching, Under-fetching 문제를 해결하기 위해 GraphQL을 도입한 실전 경험. Schema 설계부터 N+1 해결까지 스토리텔링으로 풀어냅니다.
GraphQL API Design: REST의 한계에서 출발해 GraphQL을 도입하고 돌아온 여정
모든 좋은 기술 도입에는 이야기가 있습니다. 이 글은 댄 하먼의 8단 스토리 서클(Story Circle) 구조를 따라, REST API의 한계에서 출발해 GraphQL을 도입하고, 결국 하이브리드 아키텍처로 돌아오기까지의 여정을 기술적으로 풀어냅니다.
TL;DR
- REST API는 단순하지만, 클라이언트가 다양해지면 Over-fetching과 Under-fetching 문제가 심화됩니다
- GraphQL은 클라이언트가 필요한 데이터를 정확히 요청할 수 있는 쿼리 언어이자 런타임입니다
- N+1 문제는 DataLoader 패턴으로, 보안은 쿼리 복잡도 제한으로 해결할 수 있습니다
- 현실적인 최적해는 REST와 GraphQL을 용도에 맞게 조합하는 하이브리드 아키텍처입니다
1단계: 안정 (You) — REST API로 잘 돌아가던 시절
모든 이야기는 주인공의 일상에서 시작됩니다.
우리의 API도 마찬가지였습니다. 전형적인 RESTful 아키텍처에서 각 리소스는 깔끔한 엔드포인트를 갖고 있었습니다. 사용자 정보가 필요하면 /users/:id, 게시글 목록이 필요하면 /posts를 호출하면 됐습니다.
# 깔끔한 REST 엔드포인트들
@app.get("/api/users/{user_id}")
def get_user(user_id: int):
user = db.query(User).get(user_id)
return {
"id": user.id,
"name": user.name,
"email": user.email,
"avatar": user.avatar,
"bio": user.bio,
"created_at": user.created_at,
"follower_count": user.follower_count,
"following_count": user.following_count,
"post_count": user.post_count,
}
@app.get("/api/users/{user_id}/posts")
def get_user_posts(user_id: int):
posts = db.query(Post).filter(Post.user_id == user_id).all()
return [serialize_post(p) for p in posts]
코드도 직관적이었고, HTTP 메서드와 상태 코드가 명확한 의미를 전달했습니다. GET, POST, PUT, DELETE — 이 네 가지면 세상의 모든 CRUD를 표현할 수 있었습니다.
모든 것이 편안한 컴포트 존이었습니다.
2단계: 욕구 (Need) — Over-fetching, Under-fetching 문제 발생
이야기의 주인공에게는 해결해야 할 문제가 생깁니다.
모바일 앱이 출시되면서 문제가 시작됐습니다. 같은 “사용자 프로필” 화면이라도 웹과 모바일이 필요로 하는 데이터가 달랐습니다.
문제 1: Over-fetching (과도한 데이터 전송)
모바일 프로필 카드에는 이름과 아바타만 필요한데, API는 항상 모든 필드를 반환했습니다.
모바일이 필요한 것: { name, avatar }
API가 반환하는 것: { id, name, email, avatar, bio, created_at,
follower_count, following_count, post_count }
불필요한 데이터 전송량: ~70%
모바일 사용자의 데이터 요금이 낭비되고 있었습니다.
문제 2: Under-fetching (데이터 부족으로 인한 다중 요청)
프로필 페이지를 하나 그리려면 여러 엔드포인트를 호출해야 했습니다.
// 프로필 페이지를 위한 3번의 API 호출
const user = await fetch(`/api/users/${id}`); // 1번째 요청
const posts = await fetch(`/api/users/${id}/posts`); // 2번째 요청
const followers = await fetch(`/api/users/${id}/followers`); // 3번째 요청
// 각 게시글의 댓글까지 필요하다면?
const comments = await Promise.all(
posts.map(post => fetch(`/api/posts/${post.id}/comments`)) // N번의 추가 요청
);
문제 3: 엔드포인트 폭발
클라이언트별 요구사항을 맞추다 보니 엔드포인트가 기하급수적으로 늘어났습니다.
/api/users/:id # 기본 사용자 정보
/api/users/:id?fields=name,avatar # 모바일용 (필드 필터링 시도)
/api/users/:id/summary # 요약 정보
/api/users/:id/full # 전체 정보 (관리자용)
/api/users/:id/with-posts # 게시글 포함
/api/users/:id/with-stats # 통계 포함
REST의 구조적 한계가 명확했습니다. 리소스 중심 설계가 클라이언트의 다양한 데이터 요구를 따라가지 못하고 있었습니다.
3단계: 진입 (Go) — GraphQL이라는 낯선 세계 진입
주인공은 익숙한 세계를 떠나 새로운 영역에 발을 들입니다.
우리는 Facebook이 2015년에 공개한 GraphQL이라는 쿼리 언어에 주목했습니다. 핵심 아이디어는 단순했습니다. 클라이언트가 필요한 데이터의 형태를 직접 정의한다.
┌─────────────────────────────────────────────────────────┐
│ REST 방식 │
├─────────────────────────────────────────────────────────┤
│ 서버가 응답 형태를 결정 │
│ GET /users/1 → { id, name, email, avatar, bio, ... } │
│ 클라이언트는 받은 것을 그대로 사용하거나 버림 │
└─────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────┐
│ GraphQL 방식 │
├─────────────────────────────────────────────────────────┤
│ 클라이언트가 응답 형태를 결정 │
│ query { user(id: 1) { name, avatar } } │
│ → { "user": { "name": "김개발", "avatar": "..." } } │
│ 정확히 요청한 것만 반환 │
└─────────────────────────────────────────────────────────┘
단일 엔드포인트 /graphql로 모든 요청을 처리한다는 점이 가장 낯설었습니다. URL로 리소스를 표현하던 REST와는 완전히 다른 패러다임이었습니다.
4단계: 탐색 (Search) — Schema, Resolver, Type System 학습
낯선 세계에서 주인공은 시행착오를 겪으며 적응합니다.
Schema 정의: API의 계약서
GraphQL의 출발점은 Schema입니다. 타입 시스템을 기반으로 API가 제공하는 데이터의 구조를 명확히 정의합니다.
# schema.graphql — 모든 것의 시작
type User {
id: ID!
name: String!
email: String!
avatar: String
bio: String
posts(first: Int = 10, after: String): PostConnection!
followers: [User!]!
followerCount: Int!
}
type Post {
id: ID!
title: String!
content: String!
author: User!
comments(first: Int = 20): [Comment!]!
createdAt: DateTime!
likeCount: Int!
}
type Comment {
id: ID!
text: String!
author: User!
createdAt: DateTime!
}
type PostConnection {
edges: [PostEdge!]!
pageInfo: PageInfo!
}
type PostEdge {
node: Post!
cursor: String!
}
type PageInfo {
hasNextPage: Boolean!
endCursor: String
}
type Query {
user(id: ID!): User
post(id: ID!): Post
feed(first: Int = 20, after: String): PostConnection!
}
type Mutation {
createPost(input: CreatePostInput!): Post!
updatePost(id: ID!, input: UpdatePostInput!): Post!
deletePost(id: ID!): Boolean!
}
input CreatePostInput {
title: String!
content: String!
}
input UpdatePostInput {
title: String
content: String
}
Schema는 프론트엔드와 백엔드 사이의 계약서 역할을 했습니다. 별도의 API 문서 없이도 Schema만으로 어떤 데이터를 어떻게 요청할 수 있는지 명확히 알 수 있었습니다.
Resolver 구현: Schema에 생명 불어넣기
Schema가 “무엇을 제공하는가”를 정의한다면, Resolver는 “어떻게 가져오는가”를 구현합니다.
# Python + Strawberry 기반 Resolver 구현
import strawberry
from typing import Optional, List
@strawberry.type
class UserType:
id: strawberry.ID
name: str
email: str
avatar: Optional[str] = None
bio: Optional[str] = None
@strawberry.field
async def posts(
self, info, first: int = 10, after: Optional[str] = None
) -> "PostConnection":
# 게시글은 user.posts가 요청될 때만 로드
cursor = decode_cursor(after) if after else None
posts = await PostRepository.find_by_user(
user_id=self.id, limit=first + 1, after=cursor
)
has_next = len(posts) > first
edges = [
PostEdge(node=post, cursor=encode_cursor(post.id))
for post in posts[:first]
]
return PostConnection(
edges=edges,
page_info=PageInfo(
has_next_page=has_next,
end_cursor=edges[-1].cursor if edges else None
)
)
@strawberry.field
async def follower_count(self) -> int:
return await FollowerRepository.count(user_id=self.id)
@strawberry.type
class Query:
@strawberry.field
async def user(self, id: strawberry.ID) -> Optional[UserType]:
user = await UserRepository.find_by_id(id)
if not user:
return None
return UserType(
id=user.id, name=user.name,
email=user.email, avatar=user.avatar
)
핵심은 필드 단위의 해석(resolution) 입니다. 클라이언트가 posts를 요청하지 않으면 해당 Resolver는 아예 실행되지 않습니다. Over-fetching 문제가 구조적으로 사라진 것입니다.
5단계: 발견 (Find) — N+1 문제, DataLoader, 페이지네이션 핵심 패턴 발견
시행착오 끝에 주인공은 목표에 도달합니다.
GraphQL을 프로덕션에 올리면서 반드시 해결해야 할 핵심 패턴들을 발견했습니다.
N+1 문제: GraphQL의 아킬레스건
피드에서 게시글 20개를 가져올 때, 각 게시글의 작성자 정보를 조회하면 21번의 DB 쿼리가 발생합니다.
Query: feed(first: 20)
SQL 실행:
1. SELECT * FROM posts LIMIT 20 -- 1번
2. SELECT * FROM users WHERE id = 1 -- 게시글 1의 작성자
3. SELECT * FROM users WHERE id = 2 -- 게시글 2의 작성자
4. SELECT * FROM users WHERE id = 3 -- 게시글 3의 작성자
...
21. SELECT * FROM users WHERE id = 15 -- 게시글 20의 작성자
총 21번의 쿼리 (1 + N)
DataLoader: N+1 문제의 해법
Facebook이 만든 DataLoader 패턴이 해답이었습니다. 개별 요청을 모아서 배치 처리합니다.
from dataloader import DataLoader
# 배치 로딩 함수 정의
async def batch_load_users(user_ids: list[int]) -> list[User]:
"""여러 ID를 한 번의 쿼리로 가져온다"""
users = await db.query(User).filter(User.id.in_(user_ids)).all()
# DataLoader는 요청 순서대로 결과를 반환해야 한다
user_map = {u.id: u for u in users}
return [user_map.get(uid) for uid in user_ids]
# DataLoader 인스턴스 생성 (요청 단위 스코프)
user_loader = DataLoader(batch_fn=batch_load_users)
# Resolver에서 사용
@strawberry.type
class PostType:
id: strawberry.ID
title: str
content: str
author_id: int # 내부 필드
@strawberry.field
async def author(self, info) -> UserType:
# 개별 호출처럼 보이지만, DataLoader가 자동으로 배치 처리
user = await info.context.user_loader.load(self.author_id)
return UserType.from_model(user)
DataLoader 적용 후:
1. SELECT * FROM posts LIMIT 20 -- 1번
2. SELECT * FROM users WHERE id IN (1, 2, 3, ..., 15) -- 1번 (배치!)
총 2번의 쿼리 (21번 → 2번)
커서 기반 페이지네이션
오프셋 기반 페이지네이션 대신 커서 기반 방식을 채택했습니다. 실시간으로 데이터가 추가되는 피드에서 페이지가 밀리는 문제를 방지할 수 있었습니다.
# 클라이언트 쿼리
query {
feed(first: 10, after: "Y3Vyc29yOjEwMA==") {
edges {
node {
id
title
author { name avatar }
}
cursor
}
pageInfo {
hasNextPage
endCursor
}
}
}
6단계: 대가 (Take) — 성능 최적화, 보안, 캐싱 전략
원하는 것을 얻었지만, 주인공은 무거운 대가를 치릅니다.
GraphQL은 공짜가 아니었습니다. 클라이언트에게 쿼리의 자유를 준 만큼, 서버는 새로운 종류의 위협에 노출되었습니다.
대가 1: 악의적 쿼리 공격
클라이언트가 자유롭게 쿼리를 작성할 수 있다는 건, 의도적으로 서버를 죽이는 쿼리도 보낼 수 있다는 뜻이었습니다.
# 악의적 중첩 쿼리 — 서버 리소스를 고갈시킬 수 있음
query MaliciousQuery {
user(id: "1") {
followers {
followers {
followers {
followers {
followers {
name # 기하급수적 DB 조회 발생
}
}
}
}
}
}
}
# 해결: 쿼리 복잡도 제한 (Query Complexity Analysis)
from graphql import parse
from graphql.validation import validate
class QueryComplexityAnalyzer:
"""각 필드에 비용을 할당하고, 총 비용이 임계치를 넘으면 거부"""
FIELD_COSTS = {
"followers": 10, # 리스트 필드는 비용 높음
"posts": 8,
"comments": 5,
"user": 1,
"name": 0,
"email": 0,
}
MAX_COMPLEXITY = 100
MAX_DEPTH = 7
def analyze(self, query_string: str) -> dict:
ast = parse(query_string)
complexity = self._calculate_complexity(ast)
depth = self._calculate_depth(ast)
return {
"complexity": complexity,
"depth": depth,
"allowed": complexity <= self.MAX_COMPLEXITY and depth <= self.MAX_DEPTH
}
def _calculate_complexity(self, node, depth=0):
total = 0
for field in self._get_fields(node):
field_name = field.name.value
cost = self.FIELD_COSTS.get(field_name, 1)
child_complexity = self._calculate_complexity(field, depth + 1)
total += cost + child_complexity
return total
# 미들웨어로 적용
async def complexity_middleware(request, next_handler):
analyzer = QueryComplexityAnalyzer()
result = analyzer.analyze(request.query)
if not result["allowed"]:
raise GraphQLError(
f"Query too complex: {result['complexity']} "
f"(max: {analyzer.MAX_COMPLEXITY})"
)
return await next_handler(request)
대가 2: 캐싱의 어려움
REST에서는 URL 기반으로 HTTP 캐싱이 자연스러웠습니다. GET /users/1의 응답을 그대로 캐싱하면 됐습니다. 하지만 GraphQL은 단일 엔드포인트에 POST 요청을 보내므로 HTTP 캐싱이 기본적으로 작동하지 않습니다.
# 해결: 응답 캐싱 전략
import hashlib
import json
from functools import lru_cache
class GraphQLCacheLayer:
def __init__(self, redis_client):
self.redis = redis_client
def _query_hash(self, query: str, variables: dict) -> str:
"""쿼리와 변수를 결합하여 캐시 키 생성"""
raw = json.dumps({"q": query, "v": variables}, sort_keys=True)
return f"gql:{hashlib.sha256(raw.encode()).hexdigest()[:16]}"
async def execute_with_cache(self, query, variables, ttl=60):
cache_key = self._query_hash(query, variables)
# 캐시 히트
cached = await self.redis.get(cache_key)
if cached:
return json.loads(cached)
# 캐시 미스 — 실제 실행
result = await self.graphql_engine.execute(query, variables)
# Mutation이 아닌 경우만 캐싱
if not self._is_mutation(query):
await self.redis.setex(cache_key, ttl, json.dumps(result))
return result
대가 3: 에러 핸들링의 복잡성
REST에서는 HTTP 상태 코드가 성공/실패를 명확히 구분했지만, GraphQL은 항상 200 OK를 반환하면서 errors 필드로 에러를 전달합니다. 부분 성공이라는 새로운 개념도 등장했습니다.
{
"data": {
"user": {
"name": "김개발",
"posts": null
}
},
"errors": [
{
"message": "Posts service temporarily unavailable",
"path": ["user", "posts"],
"extensions": {
"code": "SERVICE_UNAVAILABLE",
"retryAfter": 30
}
}
]
}
사용자 이름은 정상적으로 반환하면서 게시글 로드만 실패한 상태입니다. 클라이언트가 이런 부분 응답을 처리하는 로직을 갖춰야 했습니다.
7단계: 귀환 (Return) — REST + GraphQL 하이브리드 아키텍처 구축
대가를 치른 주인공은 원래 세계로 돌아옵니다.
모든 것을 GraphQL로 바꾸는 것이 답이 아니라는 깨달음을 얻었습니다. REST가 더 적합한 영역은 분명히 존재했습니다. 우리는 하이브리드 아키텍처를 구축했습니다.
┌────────────────────────────────────────────────────────┐
│ API Gateway │
│ (nginx / Kong) │
└──────────┬──────────────────────────┬──────────────────┘
│ │
┌──────▼──────┐ ┌───────▼───────┐
│ /graphql │ │ /api/v2/* │
│ (GraphQL) │ │ (REST) │
└──────┬──────┘ └───────┬───────┘
│ │
클라이언트 앱용 서비스 간 통신용
- 프로필 페이지 - 웹훅 수신
- 피드 - 파일 업로드
- 검색 결과 - 헬스체크
- 대시보드 - 외부 파트너 API
// 하이브리드 API Gateway 라우팅 설정 (Node.js + Express)
const express = require('express');
const { ApolloServer } = require('@apollo/server');
const { expressMiddleware } = require('@apollo/server/express4');
const app = express();
// ---- REST 엔드포인트: 단순 CRUD, 파일 처리, 외부 연동 ----
app.post('/api/v2/webhooks/stripe', stripeWebhookHandler);
app.post('/api/v2/upload', multer().single('file'), uploadHandler);
app.get('/api/v2/health', (req, res) => res.json({ status: 'ok' }));
// 외부 파트너용 REST API (단순하고 안정적인 인터페이스)
app.get('/api/v2/partners/products', partnerProductsHandler);
app.post('/api/v2/partners/orders', partnerOrderHandler);
// ---- GraphQL 엔드포인트: 복잡한 데이터 조합이 필요한 클라이언트 ----
const apolloServer = new ApolloServer({
typeDefs,
resolvers,
plugins: [
complexityPlugin({ maxComplexity: 100, maxDepth: 7 }),
cachePlugin({ redis }),
],
});
await apolloServer.start();
app.use('/graphql', expressMiddleware(apolloServer, {
context: async ({ req }) => ({
user: await authenticateRequest(req),
loaders: createDataLoaders(), // 요청마다 새 DataLoader 인스턴스
}),
}));
app.listen(4000, () => console.log('Hybrid API running on :4000'));
역할 분담 기준
| 용도 | GraphQL 사용 | REST 사용 |
|---|---|---|
| 클라이언트 앱 데이터 조회 | O | |
| 복잡한 관계형 데이터 | O | |
| 파일 업로드/다운로드 | O | |
| 웹훅 수신 | O | |
| 외부 파트너 API | O | |
| 서비스 간 내부 통신 | O | |
| 실시간 알림 (Subscription) | O | |
| 헬스체크/모니터링 | O |
8단계: 변화 (Change) — API 설계에 대한 근본적 시각 변화
여정을 마친 주인공은 근본적으로 변화합니다.
GraphQL을 경험한 후, API 설계를 바라보는 관점 자체가 달라졌습니다.
Before vs After: 사고방식의 변화
[Before] "서버가 응답 형태를 결정한다"
[After] "클라이언트가 필요한 데이터를 선언한다"
[Before] "엔드포인트 하나 = 리소스 하나"
[After] "하나의 쿼리로 관계된 데이터를 자유롭게 탐색한다"
[Before] "REST 아니면 GraphQL — 하나를 골라야 한다"
[After] "각각이 빛나는 영역이 다르다. 함께 쓰면 된다"
Pros & Cons 비교
| 항목 | GraphQL | REST |
|---|---|---|
| 데이터 효율성 | 필요한 필드만 요청 가능 | 엔드포인트가 반환하는 전체 데이터 수신 |
| 타입 안전성 | Schema로 강력한 타입 보장 | OpenAPI/Swagger로 보완 필요 |
| 캐싱 | 별도 전략 필요 (복잡) | HTTP 캐싱 자연스럽게 활용 |
| 파일 업로드 | 별도 처리 필요 | multipart/form-data로 간단 |
| 학습 곡선 | Schema, Resolver, DataLoader 등 높음 | HTTP 메서드만 이해하면 시작 가능 |
| 디버깅 | 단일 엔드포인트로 로그 추적 어려움 | URL 기반 추적 용이 |
| 실시간 | Subscription 내장 | WebSocket 별도 구현 필요 |
| 에러 핸들링 | 부분 응답 처리 필요 (항상 200) | HTTP 상태 코드로 명확한 구분 |
FAQ
Q: GraphQL을 도입하면 REST API를 전부 마이그레이션해야 하나요?
아닙니다. 우리의 경험상 가장 효과적인 접근은 클라이언트 앱이 사용하는 데이터 조회 API부터 GraphQL로 전환하고, 나머지는 REST로 유지하는 것입니다. 파일 처리, 웹훅, 외부 연동은 REST가 더 적합합니다.
Q: N+1 문제는 DataLoader로 완전히 해결되나요?
대부분의 경우 해결됩니다. 하지만 DataLoader는 요청 단위(request-scoped)로 동작하므로, 서로 다른 요청 간에는 배치가 되지 않습니다. 극단적인 성능이 필요한 경우에는 Resolver 내에서 직접 JOIN 쿼리를 작성하거나, Lookahead를 사용해 필요한 필드를 미리 파악하는 방법도 있습니다.
Q: GraphQL Subscription은 프로덕션에서 안정적인가요?
WebSocket 기반이므로 연결 관리에 신경을 써야 합니다. 대규모 서비스에서는 Redis Pub/Sub과 결합하여 여러 서버 인스턴스 간 메시지를 공유하는 구조가 필요합니다. 단순 알림이라면 Server-Sent Events(SSE)가 더 가벼운 대안이 될 수 있습니다.
Q: Schema 변경 시 하위 호환성은 어떻게 유지하나요?
필드를 삭제하는 대신 @deprecated 디렉티브를 사용합니다. 새 필드를 추가하는 것은 항상 안전합니다. 기존 필드의 타입을 변경하는 것은 Breaking Change이므로, 새 필드명으로 추가한 뒤 점진적으로 마이그레이션하는 것이 안전합니다.
마치며
댄 하먼의 스토리 서클은 “여정을 통해 근본적으로 변화한다” 는 것을 가르쳐줍니다.
GraphQL 도입 여정도 마찬가지였습니다. REST의 편안한 세계를 떠나, Schema와 Resolver의 학습 곡선을 넘고, N+1과 보안이라는 대가를 치르고 돌아왔습니다. 하지만 돌아온 우리는 “REST vs GraphQL”이라는 이분법 대신, 각 도구의 강점을 조합하는 하이브리드 사고를 갖게 되었습니다.
Bottom Line: GraphQL은 “REST의 대체제”가 아니라 “API 설계 도구 상자의 새로운 도구”입니다. 클라이언트의 데이터 요구가 복잡해지는 지점에서 GraphQL은 빛나고, 단순함과 표준화가 중요한 지점에서는 REST가 여전히 최선입니다. 둘 다 잘 아는 것이 진짜 실력입니다.
이런 글도 함께 읽어보세요
- REST API 디자인 패턴 — REST를 제대로 설계하는 실전 패턴
- Event-Driven Architecture 여정 — 동기 방식의 한계를 넘는 또 다른 이야기
- Webhook 설계 패턴 — 안정적인 Webhook 시스템 구축 가이드