프롤로그 - 대시보드 하나 만드는데 API 5번?
스타트업에서 관리자 대시보드를 만들던 날, 나는 진짜 화가 났다. 화면 하나를 그리는데 REST API를 다섯 번이나 호출해야 했기 때문이다.
// 대시보드 하나 로드하는데...
const user = await fetch('/api/users/me'); // 1번
const stats = await fetch('/api/stats/summary'); // 2번
const recentPosts = await fetch('/api/posts?limit=5'); // 3번
const notifications = await fetch('/api/notifications'); // 4번
const team = await fetch('/api/teams/1'); // 5번
네트워크 탭을 보니 5개의 요청이 폭포수처럼 떨어졌다. 그것도 각각 Loading Spinner를 띄워야 했다. 첫 번째 API에서 userId를 받아야 두 번째 API를 호출할 수 있는 구조였으니까. 이게 2024년에 하는 개발이 맞나 싶었다.
더 웃긴 건, GET /api/users/me가 반환하는 데이터의 절반은 쓰지도 않았다는 점이다. address, phoneNumber, createdAt 같은 필드들이 JSON에 잔뜩 담겨왔는데, 정작 내가 필요한 건 name과 avatar 두 개뿐이었다. 모바일 환경에서 이런 식으로 데이터를 낭비하면 사용자 데이터 요금이 펑펑 나간다.
"API를 하나로 합칠 수 없을까? 내가 원하는 필드만 요청할 수는 없을까?"
이 질문의 답이 바로 GraphQL이었다. 그리고 이 기술을 이해하고 나니, 페이스북이 왜 2012년에 이걸 만들 수밖에 없었는지 완벽하게 와닿았다.
"김밥천국" vs "호텔 뷔페"
GraphQL을 이해하는 가장 쉬운 방법은 식당 비유다. 내가 처음 이 개념을 받아들였을 때 머릿속에 딱 들어온 그림이다.
REST API는 '김밥천국(Set Menu)'이다. 메뉴판에 있는 대로만 시켜야 한다. 주방장(Backend Developer)이 정해놓은 세트를 통째로 받는다.
- A세트 주세요 (
GET /users/1): 김밥 + 라면 + 돈가스가 나온다. - 문제점 1 (Over-fetching): 난 김밥만 먹고 싶은데, 라면이랑 돈가스까지 강제로 받아야 한다. 데이터 낭비.
- 문제점 2 (Under-fetching): 김밥도 먹고 싶고 떡볶이도 먹고 싶은데, 세트 메뉴에 그런 조합이 없으면 이모님을 두 번 불러야 한다. (
GET /users/1,GET /posts)
GraphQL은 '호텔 뷔페(Buffet)'다. 접시(Query)를 들고 내가 원하는 것만 골라 담는다. 주방장은 뷔페 테이블(Schema)만 차려놓고, 손님(Client)이 알아서 조합한다.
- 주문: "김밥 2개랑, 떡볶이 국물만 조금 주세요."
- 결과: 정확히 그것만 준다. 잔반(낭비되는 데이터)이 없고, 접시 하나에 다 담아오니 한 번만 왔다 갔다 하면 된다.
이 비유를 듣고 나니, GraphQL의 핵심 철학이 한 문장으로 정리됐다:
"클라이언트가 데이터를 정의한다 (Client-Driven Data Fetching)"
REST는 서버가 "이거 줄게"라고 정해주는 구조라면, GraphQL은 클라이언트가 "이거 주세요"라고 요청하는 구조다. 주도권이 완전히 뒤바뀐 것이다.
고통의 시작 - REST API의 한계
페이스북이 GraphQL을 만들게 된 계기는 모바일 앱이었다. 2012년, 스마트폰 화면은 작고 네트워크는 느렸다. 그런데 REST API는 이런 문제점들을 안고 있었다.
문제 1 - Over-Fetching (필요 이상의 데이터)
// GET /api/users/1
{
"id": 1,
"name": "Ratia",
"email": "ratia@example.com",
"phoneNumber": "010-1234-5678",
"address": "Seoul, Korea",
"createdAt": "2025-01-01T00:00:00Z",
"updatedAt": "2025-05-16T10:30:00Z",
"preferences": {
"theme": "dark",
"language": "ko",
"notifications": true
},
"billing": {
"plan": "premium",
"nextPayment": "2025-06-01"
}
}
화면에는 name만 띄우는데, 이 거대한 JSON이 통째로 날아온다. 모바일 3G 환경에서 이게 반복되면 사용자 경험이 최악이 된다.
문제 2 - Under-Fetching (여러 번 요청)
SNS 피드를 생각해보자. 게시글 하나를 보여주려면:
GET /posts/123- 게시글 정보GET /users/456- 작성자 정보GET /posts/123/comments- 댓글 목록GET /posts/123/likes- 좋아요 수
4번의 API 호출이 필요하다. 이를 "Waterfall Request"라고 부른다. 첫 번째 응답을 받아야 두 번째 요청을 보낼 수 있는 구조.
문제 3 - API Endpoint 폭발
화면이 100개면 엔드포인트도 100개가 필요하다. 모바일 팀이 "여기서 createdAt도 보여주고 싶어요"라고 하면 Backend 개발자는 새로운 엔드포인트를 만들거나 기존 API를 수정해야 한다. Frontend 변경 → Backend 배포 → 다시 테스트라는 긴 사이클이 반복된다.
나는 이런 구조가 비효율적이라는 걸 이해했다. 그래서 GraphQL이 어떻게 이 문제를 해결하는지 제대로 정리해본다.
GraphQL의 핵심: Schema Definition Language (SDL)
GraphQL은 타입 시스템으로 돌아간다. 모든 데이터의 모양을 .graphql 파일에 미리 정의한다. 이게 바로 뷔페 테이블(Menu)이다.
# schema.graphql
type User {
id: ID! # ! = Non-Nullable (필수)
name: String!
email: String!
avatar: String
posts: [Post!]! # Post 배열 (빈 배열 가능, null 불가)
createdAt: DateTime! # Custom Scalar
}
type Post {
id: ID!
title: String!
content: String!
author: User! # Relation (관계)
comments: [Comment!]!
likes: Int!
publishedAt: DateTime
}
type Comment {
id: ID!
text: String!
author: User!
}
# Custom Scalar 정의 (날짜/시간)
scalar DateTime
# Root Query (Entry Point)
type Query {
user(id: ID!): User
post(id: ID!): Post
posts(limit: Int, offset: Int): [Post!]!
me: User # 현재 로그인한 사용자
}
# Root Mutation (데이터 변경)
type Mutation {
createPost(title: String!, content: String!): Post!
deletePost(id: ID!): Boolean!
likePost(id: ID!): Post!
}
# Root Subscription (실시간 구독)
type Subscription {
postAdded: Post!
commentAdded(postId: ID!): Comment!
}
이 Schema를 보면 뷔페 테이블에 어떤 음식이 있는지 한눈에 알 수 있다. User는 name, email, avatar를 가지고 있고, posts 필드를 통해 Post와 연결된다. 마치 SQL의 ERD(Entity Relationship Diagram)처럼 데이터의 그래프 구조를 그대로 표현한다.
Custom Scalar: 타입 확장
기본 타입(String, Int, Boolean, ID) 외에 커스텀 타입을 만들 수 있다.
// Custom Scalar 구현 (JavaScript)
const { GraphQLScalarType } = require('graphql');
const DateTime = new GraphQLScalarType({
name: 'DateTime',
description: 'ISO-8601 형식의 날짜/시간',
serialize(value) {
return value.toISOString(); // DB → Client
},
parseValue(value) {
return new Date(value); // Client → DB
},
});
이렇게 하면 "2025-05-17T10:30:00Z" 같은 문자열을 자동으로 Date 객체로 변환해준다.
세 가지 Operation: Query, Mutation, Subscription
GraphQL의 모든 작업은 세 가지 타입으로 나뉜다.
1. Query (데이터 읽기)
# 사용자 정보 + 최근 게시글 3개
query GetUserWithPosts {
user(id: "1") {
name
avatar
posts(limit: 3) {
id
title
likes
publishedAt
}
}
}
# 응답
{
"data": {
"user": {
"name": "Ratia",
"avatar": "/avatars/ratia.jpg",
"posts": [
{
"id": "101",
"title": "GraphQL 배우는 중",
"likes": 42,
"publishedAt": "2025-05-16T10:00:00Z"
},
{
"id": "102",
"title": "N+1 문제 해결법",
"likes": 38,
"publishedAt": "2025-05-15T14:30:00Z"
},
{
"id": "103",
"title": "Apollo Client 세팅",
"likes": 29,
"publishedAt": "2025-05-14T09:20:00Z"
}
]
}
}
}
REST였다면: GET /users/1, GET /users/1/posts?limit=3 두 번 호출.
GraphQL: 한 번에 해결.
2. Mutation (데이터 변경)
# 게시글 생성
mutation CreatePost {
createPost(
title: "GraphQL은 뷔페다"
content: "REST는 김밥천국, GraphQL은 호텔 뷔페..."
) {
id
title
author {
name
}
publishedAt
}
}
# 응답
{
"data": {
"createPost": {
"id": "104",
"title": "GraphQL은 뷔페다",
"author": {
"name": "Ratia"
},
"publishedAt": "2025-05-17T11:00:00Z"
}
}
}
주목할 점: 생성된 데이터를 바로 원하는 형태로 받을 수 있다. REST는 POST /posts 후에 GET /posts/104를 다시 호출해야 하는 경우가 많다.
3. Subscription (실시간 구독)
# 새 댓글 실시간 수신 (WebSocket)
subscription OnCommentAdded {
commentAdded(postId: "101") {
id
text
author {
name
avatar
}
}
}
# 누군가 댓글을 달면 자동으로 푸시
{
"data": {
"commentAdded": {
"id": "501",
"text": "좋은 글 감사합니다!",
"author": {
"name": "김개발",
"avatar": "/avatars/kim.jpg"
}
}
}
}
실시간 채팅, 알림, 주식 시세 같은 기능에 사용된다. REST에서는 Long Polling이나 Server-Sent Events를 별도로 구현해야 하지만, GraphQL은 Subscription이 표준 스펙이다.
Resolver: Query에서 DB까지의 여행
Schema는 "메뉴판"이고, Query는 "주문서"다. 그럼 실제 음식(데이터)은 누가 만들까? 바로 Resolver다.
// resolver.js
const resolvers = {
Query: {
// user(id: ID!): User
user: async (parent, args, context, info) => {
const { id } = args;
// DB에서 사용자 조회
return await context.db.user.findUnique({ where: { id } });
},
// me: User
me: async (parent, args, context) => {
const userId = context.currentUser.id; // JWT 토큰에서 추출
return await context.db.user.findUnique({ where: { id: userId } });
},
},
Mutation: {
// createPost(title: String!, content: String!): Post!
createPost: async (parent, args, context) => {
const { title, content } = args;
const authorId = context.currentUser.id;
return await context.db.post.create({
data: { title, content, authorId },
});
},
},
// Field Resolver (중첩된 필드 해결)
User: {
// User.posts 필드를 요청했을 때
posts: async (parent, args, context) => {
// parent = 현재 User 객체
return await context.db.post.findMany({
where: { authorId: parent.id },
take: args.limit,
});
},
},
Post: {
// Post.author 필드를 요청했을 때
author: async (parent, context) => {
return await context.db.user.findUnique({
where: { id: parent.authorId },
});
},
},
};
Resolver의 4가지 인자
- parent: 부모 객체 (중첩 쿼리에서 사용)
- args: Query에 전달된 인자 (
id,limit등) - context: 공통 데이터 (DB 연결, 인증 정보)
- info: Query의 메타 정보 (요청된 필드 목록)
결국 이거였다: GraphQL은 Query를 보고 Resolver를 재귀적으로 실행하는 엔진이다. user(id: 1) { name, posts { title } }라는 Query가 들어오면:
Query.userResolver 실행 → User 객체 반환User.postsResolver 실행 → Post 배열 반환- 각 Post의
title필드만 추출
이 과정이 트리를 순회하듯 진행된다. 그래서 이름이 Graph QL인 것이다.
치명적인 함정: N+1 Problem
Resolver를 순진하게 짜면 성능 재앙이 벌어진다. 이게 GraphQL의 가장 큰 약점이다.
문제 상황
# 사용자 10명과 각자의 게시글 제목
query {
users(limit: 10) {
name
posts {
title
}
}
}
순진한 Resolver:
const resolvers = {
Query: {
users: async () => {
return await db.user.findMany({ take: 10 }); // 1번의 쿼리
},
},
User: {
posts: async (parent) => {
// 각 User마다 실행됨!
return await db.post.findMany({ where: { authorId: parent.id } }); // 10번의 쿼리
},
},
};
결과: 1 + 10 = 11번의 DB 쿼리
만약 사용자가 100명이면? 101번. 1000명이면? 1001번.
이게 바로 N+1 문제다. 목록을 가져오는 1번의 쿼리 + 각 항목마다 추가 쿼리 N번.
해결책: DataLoader
페이스북이 만든 DataLoader 라이브러리는 이 문제를 Batching + Caching으로 해결한다.
// dataloader.js
const DataLoader = require('dataloader');
// Batch Function: 여러 ID를 한 번에 조회
const batchUsers = async (userIds) => {
// [1, 2, 3] -> SELECT * FROM users WHERE id IN (1, 2, 3)
const users = await db.user.findMany({
where: { id: { in: userIds } },
});
// ID 순서대로 정렬해서 반환 (중요!)
return userIds.map(id => users.find(user => user.id === id));
};
const userLoader = new DataLoader(batchUsers);
// 사용
const user1 = await userLoader.load(1);
const user2 = await userLoader.load(2);
const user3 = await userLoader.load(3);
// 실제로는 1번의 쿼리로 합쳐짐: SELECT * WHERE id IN (1,2,3)
Resolver에 적용:
const resolvers = {
Post: {
author: async (parent, args, context) => {
// N번 호출되지만, DataLoader가 자동으로 Batching
return await context.loaders.user.load(parent.authorId);
},
},
};
// Context에 Loader 주입
const server = new ApolloServer({
typeDefs,
resolvers,
context: () => ({
db,
loaders: {
user: new DataLoader(batchUsers),
post: new DataLoader(batchPosts),
},
}),
});
Before: 101번의 쿼리 After: 2번의 쿼리 (Users 1번 + Posts 1번, IN 절 사용)
DataLoader를 처음 이해했을 때, "이게 진짜 마법이구나" 싶었다. Event Loop의 한 Tick 동안 쌓인 요청을 자동으로 모아서 한 번에 실행해준다. 개발자는 load(id) 함수만 호출하면 된다.
중급 기능: Fragment, Variables, Directives
1. Fragment (재사용 가능한 필드 조각)
# Fragment 정의
fragment UserInfo on User {
id
name
avatar
createdAt
}
# 여러 곳에서 재사용
query {
me {
...UserInfo
posts {
author {
...UserInfo
}
}
}
}
코드 중복을 줄이고, "이 화면에서는 항상 이 필드들을 보여줘"라는 컴포넌트 단위의 데이터 요구사항을 표현할 수 있다.
2. Variables (동적 인자)
# Variable 선언 ($userId = 변수명, ID! = 타입)
query GetUser($userId: ID!, $postLimit: Int = 5) {
user(id: $userId) {
name
posts(limit: $postLimit) {
title
}
}
}
# 별도로 전달
{
"userId": "123",
"postLimit": 10
}
쿼리 문자열을 동적으로 조립하는 대신, 변수로 분리하면 보안(SQL Injection 방지)과 캐싱에 유리하다.
3. Directives (조건부 필드)
query GetUser($userId: ID!, $withPosts: Boolean!) {
user(id: $userId) {
name
posts @include(if: $withPosts) {
title
}
}
}
# withPosts = true일 때만 posts 필드 요청
@include(if: Boolean): 조건이 true일 때만 포함@skip(if: Boolean): 조건이 true일 때 제외
화면 상태에 따라 필요한 데이터만 요청할 수 있다.
실제 코드: Apollo Client + React
클라이언트 라이브러리 중 가장 많이 쓰이는 Apollo Client를 React에 연결해보자.
1. 세팅
// apolloClient.js
import { ApolloClient, InMemoryCache, HttpLink } from '@apollo/client';
const client = new ApolloClient({
link: new HttpLink({ uri: 'https://api.example.com/graphql' }),
cache: new InMemoryCache(),
});
export default client;
// index.js
import { ApolloProvider } from '@apollo/client';
import client from './apolloClient';
ReactDOM.render(
<ApolloProvider client={client}>
<App />
</ApolloProvider>,
document.getElementById('root')
);
2. useQuery Hook (데이터 읽기)
// UserProfile.jsx
import { useQuery, gql } from '@apollo/client';
const GET_USER = gql`
query GetUser($userId: ID!) {
user(id: $userId) {
id
name
avatar
posts(limit: 5) {
id
title
likes
}
}
}
`;
function UserProfile({ userId }) {
const { loading, error, data } = useQuery(GET_USER, {
variables: { userId },
});
if (loading) return <Spinner />;
if (error) return <p>Error: {error.message}</p>;
const { user } = data;
return (
<div>
<img src={user.avatar} alt={user.name} />
<h1>{user.name}</h1>
<ul>
{user.posts.map(post => (
<li key={post.id}>
{post.title} ({post.likes} likes)
</li>
))}
</ul>
</div>
);
}
REST였다면: useEffect 안에서 fetch를 여러 번 호출하고, useState로 각각 관리.
Apollo Client: useQuery 한 줄로 끝. Loading/Error 상태도 자동 관리.
3. useMutation Hook (데이터 변경)
import { useMutation, gql } from '@apollo/client';
const LIKE_POST = gql`
mutation LikePost($postId: ID!) {
likePost(id: $postId) {
id
likes
}
}
`;
function LikeButton({ postId }) {
const [likePost, { loading }] = useMutation(LIKE_POST, {
variables: { postId },
// Optimistic UI: 응답 전에 UI 먼저 업데이트
optimisticResponse: {
likePost: {
id: postId,
likes: (prev) => prev + 1,
},
},
// 캐시 업데이트
update(cache, { data: { likePost } }) {
cache.modify({
id: cache.identify({ __typename: 'Post', id: postId }),
fields: {
likes() {
return likePost.likes;
},
},
});
},
});
return (
<button onClick={likePost} disabled={loading}>
{loading ? '...' : 'Like'}
</button>
);
}
Optimistic UI: 서버 응답을 기다리지 않고 UI를 먼저 업데이트해서 체감 속도를 높인다. 실패하면 롤백.
캐싱 전략 - Normalized Cache의 마법
Apollo Client의 진짜 강점은 Normalized Cache다. 같은 객체를 여러 Query에서 사용해도 한 곳에만 저장된다.
// Cache 구조 (내부적으로 이렇게 정규화됨)
{
"User:1": {
"__typename": "User",
"id": "1",
"name": "Ratia",
"avatar": "/avatars/ratia.jpg"
},
"Post:101": {
"__typename": "Post",
"id": "101",
"title": "GraphQL 배우는 중",
"likes": 42,
"author": { "__ref": "User:1" } // Reference
},
"ROOT_QUERY": {
"user({\"id\":\"1\"})": { "__ref": "User:1" },
"post({\"id\":\"101\"})": { "__ref": "Post:101" }
}
}
User:1이 여러 Query에 나타나도 하나의 객체로 관리된다. likePost Mutation으로 Post:101.likes를 수정하면, 이 Post를 참조하는 모든 컴포넌트가 자동으로 리렌더링된다.
Cache Policy 설정
const client = new ApolloClient({
cache: new InMemoryCache({
typePolicies: {
Query: {
fields: {
posts: {
// Pagination: 기존 목록에 추가
keyArgs: false,
merge(existing = [], incoming) {
return [...existing, ...incoming];
},
},
},
},
},
}),
});
Cache-First: 캐시에 있으면 네트워크 요청 안 함 (기본값) Network-Only: 항상 서버에서 새로 받음 Cache-and-Network: 캐시 먼저 보여주고, 백그라운드에서 새 데이터 받아서 업데이트
GraphQL vs REST: 선택 기준 매트릭스
이제 언제 뭘 써야 할지 명확하게 정리해본다.
| 상황 | 추천 | 이유 |
|---|---|---|
| 대시보드, Admin Panel | GraphQL | 복잡한 관계형 데이터를 한 번에 가져와야 함 |
| 모바일 앱 | GraphQL | 데이터 절약 필수, Over-fetching 방지 |
| 공개 API (GitHub, Twitter) | REST | 표준 HTTP 캐싱, 문서화 용이, 진입 장벽 낮음 |
| 단순 CRUD (블로그, 게시판) | REST | 복잡한 관계가 없으면 GraphQL이 오버 엔지니어링 |
| 실시간 기능 (채팅, 알림) | GraphQL | Subscription 표준 지원 |
| 파일 업로드 위주 | REST | multipart/form-data가 표준, GraphQL은 복잡 |
| 마이크로서비스 간 통신 | REST / gRPC | 내부 API는 GraphQL의 유연성이 불필요 |
| 레거시 시스템 통합 | REST | 기존 인프라와 호환성 |
실제 사례
- GitHub API v4: GraphQL 채택 (복잡한 Repository, Issue, PR 관계)
- Shopify API: GraphQL + REST 병행 (상품 조회는 REST, 복잡한 주문은 GraphQL)
- Netflix: 내부적으로 GraphQL-like Federation (마이크로서비스 통합)
보안 - Query Depth & Complexity 제한
GraphQL의 유연성은 악의적인 공격에도 취약하다.
공격 시나리오: Query Bomb
# 무한 중첩 쿼리로 서버 마비
query EvilQuery {
user(id: "1") {
posts {
author {
posts {
author {
posts {
author {
posts {
# ... 100단계 중첩
}
}
}
}
}
}
}
}
}
방어책 1 - Query Depth 제한
const depthLimit = require('graphql-depth-limit');
const server = new ApolloServer({
typeDefs,
resolvers,
validationRules: [depthLimit(5)], // 최대 5단계까지만 허용
});
방어책 2 - Query Complexity 분석
const { createComplexityLimitRule } = require('graphql-validation-complexity');
// 각 필드에 비용(Cost) 할당
const complexityRule = createComplexityLimitRule(1000, {
scalarCost: 1,
objectCost: 5,
listFactor: 10,
});
const server = new ApolloServer({
validationRules: [complexityRule],
});
posts(limit: 100) { comments { author } }처럼 리스트를 중첩하면 100 * 10 * 5 = 5000의 비용이 계산되고, 제한을 넘으면 거부된다.
방어책 3: Rate Limiting
// IP당 분당 100개 요청 제한
const rateLimit = require('express-rate-limit');
app.use('/graphql', rateLimit({
windowMs: 60 * 1000, // 1분
max: 100,
}));
REST는 URL별로 제한하기 쉽지만, GraphQL은 모든 요청이 /graphql로 오니까 Query별로 분석해야 한다.
내가 받아들인 핵심
GraphQL을 실제에 도입하고 6개월이 지났다. 이제 이 기술이 언제 빛나고 언제 독이 되는지 명확히 이해했다.
GraphQL이 빛나는 순간
- 프론트엔드가 빠르게 변할 때: "여기 필드 하나만 더 주세요"가 Backend 배포 없이 해결된다.
- 모바일 앱: 3G 환경에서 Over-fetching은 치명타다.
- 복잡한 관계형 데이터: SNS, E-commerce처럼 User-Post-Comment-Like가 얽힌 구조.
GraphQL이 독이 되는 순간
- 팀에 GraphQL 전문가가 없을 때: N+1 문제를 모르고 쓰면 DB가 터진다.
- 공개 API: 외부 개발자들은 REST를 더 선호한다. 학습 비용이 낮으니까.
- 단순한 CRUD: 게시판 하나 만드는데 Schema + Resolver + DataLoader 세팅은 과하다.
결국 이거였다
"GraphQL은 복잡성을 Backend에서 Frontend로 옮긴 기술이다."
REST는 Backend가 모든 걸 결정한다. "이 엔드포인트는 이 데이터를 준다." 명확하지만 유연하지 않다.
GraphQL은 Frontend가 결정한다. "나는 이 데이터가 필요해." 유연하지만 Backend는 그만큼 더 복잡해진다. DataLoader, Query Complexity, Caching을 모두 신경 써야 한다.
스타트업이라면 초기엔 REST로 빠르게 만들고, 복잡도가 올라가면 GraphQL로 전환하는 게 현실적이다. 처음부터 GraphQL을 선택하면 러닝 커브에 시간을 너무 많이 쏟게 된다.
하지만 일단 GraphQL을 제대로 이해하고 나면, REST로 돌아가기 힘들다. 뷔페의 자유를 한번 맛보면 김밥천국이 답답하게 느껴지니까.