GraphQL 완벽 가이드: API 쿼리 언어
이 글의 핵심
GraphQL은 클라이언트가 필요한 데이터만 정확히 요청할 수 있는 쿼리 언어입니다. Over-fetching, Under-fetching 문제를 해결하고, 강력한 타입 시스템과 실시간 Subscription으로 현대적인 API를 구축할 수 있습니다.
GraphQL이 뭐냐고만 들었을 때 “REST랑 뭐가 달라?”가 제일 먼저 떠올라요. Facebook(Meta) 쪽에서 나온 쿼리 언어고, 말하자면 클라이언트가 “이 필드만 달라”고 말하는 쪽에 가깝죠. 엔드포인트는 보통 /graphql 하나, 요청은 대체로 POST. 필요한 것만 달라는 그림이라 Over-fetching·Under-fetching 이야기가 자꾸 따라붙는 거고요.
솔직히 말하면, REST가 나을 때도 많아요. 캐시는 HTTP 캐시로 박아두기 쉬운 쪽이 REST 쪽이고, 파일 올리기, 단순 CRUD, CDN에 올릴 응답이 딱 떨어지는 API면 GraphQL 쓰느라 스키마·리졸버 골치 앓는 것보다 REST가 싸고 빠른 경우가 많습니다. GraphQL이 빛나는 쪽은 “한 화면에 붙는 데이터 모양이 제마다 달라서” REST로는 엔드포인트가 쪼개지거나, 한 번에 못 싣고 뎁스만 깊어질 때예요. 그때 “한 번에 그래도 얼마나 싣나”가 중요하죠.
맛보기로 비교해보면, GraphQL은 단일 엔드포인트에서 필드 단위로 가져와요. REST는 /users, /posts 이렇게 쪼개지고 응답 모양이 서버가 정한 대로죠. 버전은 REST는 URL에 /v1 같은 거 붙이는 식이고, GraphQL은 필드 Deprecation 쪽 이야기가 나오는 편이에요. 그리고 N+1 말고도 캐싱이 REST가 유리하다는 건, 그냥 생태계·브라우저 쪽과 잘 맞는다는 뜻에 가깝고요.
N+1 쿼리 이야기 하나 할게요. 옛날(혹은 어제) 처음에 리졸버만 “일단 Post.author에서 user.find 한 번씩” 이렇게 짰을 때, 피드에 글이 20개면 DB 로그에 SELECT가 1 + 20번 찍힌 적 있을 거예요. “어 쿼리 한 번인데?” 하고 봤더니, 상위 posts 한 방이 1이고, 각 글마다 author 리졸버가 돌면서 20방이 붙는 그 클래식한 N+1이죠. 팀에선 그걸 “GraphQL 쓰면 느려진다”로 오해하는 경우도 있고, 사실은 스키마가 예뻐 보이는 대신 리졸버가 한 건씩 SQL을 쏘는 설계 쪽이 문제인 거고요. 그때 DataLoader로 같은 요청 윈도 안에서 userId를 모아 WHERE id IN (...) 한 방으로 뭉치면 숨 쉬어요. “한 번에 해결”이 GraphQL이 주는 느낌과도 맞고, “안 하면 터진다”는 걸 몸으로 한 번 겪으면 잊을 수가 없어요.
프로젝트 붙이려면 graphql이랑 @apollo/server 정도가 기본이에요. TypeScript 쓰면 서버 쪽도 편하고요.
# Apollo Server 설치
npm install @apollo/server graphql
# TypeScript (선택)
npm install -D @types/node typescript
“REST면 라우트 쭉”이 아니라, 타입·리졸버만 잘 잡으면 끝인 그림에 가깝죠. 그래서 첫 세팅은 이상하게 짧게 끝는데, 나중에 N+1·권한·쿼리 비용에서 이자를 갚는 구조이기도 해요.
기본 서버는 스키마에 User, Post, Query, Mutation, Subscription 잡고 리졸버를 묶는 그림입니다. 아래는 작은 샘플이에요.
import { ApolloServer } from '@apollo/server';
import { startStandaloneServer } from '@apollo/server/standalone';
// Type Definitions (Schema)
const typeDefs = `#graphql
type User {
id: ID!
name: String!
email: String!
posts: [Post!]!
}
type Post {
id: ID!
title: String!
content: String
published: Boolean!
author: User!
}
type Query {
users: [User!]!
user(id: ID!): User
posts: [Post!]!
post(id: ID!): Post
}
type Mutation {
createUser(name: String!, email: String!): User!
createPost(authorId: ID!, title: String!, content: String): Post!
updatePost(id: ID!, title: String, content: String, published: Boolean): Post
deletePost(id: ID!): Boolean!
}
type Subscription {
postCreated: Post!
}
`;
// Resolvers
const resolvers = {
Query: {
users: () => users,
user: (parent, { id }) => users.find(u => u.id === id),
posts: () => posts,
post: (parent, { id }) => posts.find(p => p.id === id),
},
Mutation: {
createUser: (parent, { name, email }) => {
const user = { id: String(users.length + 1), name, email };
users.push(user);
return user;
},
createPost: (parent, { authorId, title, content }) => {
const post = {
id: String(posts.length + 1),
title,
content,
published: false,
authorId
};
posts.push(post);
pubsub.publish('POST_CREATED', { postCreated: post });
return post;
},
updatePost: (parent, { id, ...updates }) => {
const post = posts.find(p => p.id === id);
if (!post) throw new Error('Post not found');
Object.assign(post, updates);
return post;
},
deletePost: (parent, { id }) => {
const index = posts.findIndex(p => p.id === id);
if (index === -1) return false;
posts.splice(index, 1);
return true;
}
},
User: {
posts: (user) => posts.filter(p => p.authorId === user.id)
},
Post: {
author: (post) => users.find(u => u.id === post.authorId)
}
};
// 서버 시작
const server = new ApolloServer({ typeDefs, resolvers });
const { url } = await startStandaloneServer(server, { listen: { port: 4000 } });
console.log(`🚀 Server ready at ${url}`);
쿼리는 “다 가져와”가 아니라 필드 골라 담는 느낌이에요. 특정 유저·포스트, 변수, 프래그먼트, alias까지 한 번에 다룰 수 있죠.
# 모든 유저 조회
query {
users {
id
name
email
}
}
# 특정 유저 조회
query {
user(id: "1") {
id
name
posts {
id
title
}
}
}
# 변수 사용
query GetUser($userId: ID!) {
user(id: $userId) {
id
name
email
}
}
# Variables: { "userId": "1" }
fragment UserFields on User {
id
name
email
}
query {
user(id: "1") {
...UserFields
posts {
id
title
}
}
}
query {
user1: user(id: "1") {
name
}
user2: user(id: "2") {
name
}
}
뮤테이션은 읽기랑 똑같이 “필드만” 받는 쪽에 가깝죠.
# 유저 생성
mutation {
createUser(name: "홍길동", email: "[email protected]") {
id
name
email
}
}
# 포스트 생성
mutation CreatePost($authorId: ID!, $title: String!) {
createPost(authorId: $authorId, title: $title) {
id
title
author {
name
}
}
}
# 포스트 업데이트
mutation {
updatePost(id: "1", published: true) {
id
title
published
}
}
N+1을 막는 쪽은 결국 “같은 키로 여러 번 부르는 걸 모아서 한 번”이에요. dataloader가 그 전형적인 답이고, 리졸버에서 load만 부르면 배치로 묶어줍니다. 위에서 말한 “피드 20개 → author 20번”이 여기서 1 + 1 쪽으로 줄어드는 그림이죠.
import DataLoader from 'dataloader';
// Batch 함수
const batchUsers = async (ids: readonly string[]) => {
const users = await db.users.findMany({
where: { id: { in: [...ids] } }
});
return ids.map(id => users.find(u => u.id === id));
};
// DataLoader 생성
const userLoader = new DataLoader(batchUsers);
// Resolver에서 사용
const resolvers = {
Post: {
author: (post, args, context) => {
return context.loaders.user.load(post.authorId);
}
}
};
// Context에 주입
const server = new ApolloServer({
typeDefs,
resolvers,
});
await startStandaloneServer(server, {
context: async () => ({
loaders: {
user: new DataLoader(batchUsers)
}
})
});
프론트는 Apollo Client 쓰는 조합이 흔해요. InMemoryCache, useQuery / useMutation, 뮤테이션 끝나고 refetchQueries로 목록 갱신 같은 패턴까지요.
import { ApolloClient, InMemoryCache, ApolloProvider, useQuery, useMutation, gql } from '@apollo/client';
// Client 설정
const client = new ApolloClient({
uri: 'http://localhost:4000/graphql',
cache: new InMemoryCache()
});
// App에 Provider 추가
function App() {
return (
<ApolloProvider client={client}>
<UserList />
</ApolloProvider>
);
}
// Query Hook
const GET_USERS = gql`
query GetUsers {
users {
id
name
email
}
}
`;
function UserList() {
const { loading, error, data } = useQuery(GET_USERS);
if (loading) return <p>Loading...</p>;
if (error) return <p>Error: {error.message}</p>;
return (
<ul>
{data.users.map(user => (
<li key={user.id}>{user.name}</li>
))}
</ul>
);
}
// Mutation Hook
const CREATE_USER = gql`
mutation CreateUser($name: String!, $email: String!) {
createUser(name: $name, email: $email) {
id
name
email
}
}
`;
function CreateUserForm() {
const [createUser, { loading, error }] = useMutation(CREATE_USER, {
refetchQueries: [{ query: GET_USERS }]
});
const handleSubmit = (e) => {
e.preventDefault();
createUser({
variables: {
name: e.target.name.value,
email: e.target.email.value
}
});
};
return <form onSubmit={handleSubmit}>...</form>;
}
Subscription 쪽은 “서버가 밀어주는” 그림이에요. WebSocket 붙이고, PubSub으로 이벤트 쏘면 클라이언트는 useSubscription으로 받죠. REST만 쓰다가 오면 SSE나 폴링이랑 비교하게 되는 부분이고, 채팅·알림·실시간 대시보드 쪽에 잘 맞아요. 그냥 “HTTP만으로 다 된다”에 익숙한 팀이면 운영·가용성(끊김, 재연결) 이야기도 같이 떠야 합니다.
// 서버
import { PubSub } from 'graphql-subscriptions';
const pubsub = new PubSub();
const resolvers = {
Subscription: {
postCreated: {
subscribe: () => pubsub.asyncIterator(['POST_CREATED'])
}
},
Mutation: {
createPost: (parent, args) => {
const post = createPost(args);
pubsub.publish('POST_CREATED', { postCreated: post });
return post;
}
}
};
// 클라이언트
import { GraphQLWsLink } from '@apollo/client/link/subscriptions';
import { createClient } from 'graphql-ws';
const wsLink = new GraphQLWsLink(
createClient({ url: 'ws://localhost:4000/graphql' })
);
const SUBSCRIBE_POST = gql`
subscription {
postCreated {
id
title
author {
name
}
}
}
`;
function PostFeed() {
const { data } = useSubscription(SUBSCRIBE_POST);
return data && <div>New post: {data.postCreated.title}</div>;
}
소셜 피드 비슷한 거 하나만 더 짚을게요. REST로는 글 목록, 작성자, 썸네일, 댓글 미리보기, 좋아요 수를 나눠서 부르다가 팀이 “화면 수만큼 BFF”를 만드는 식이 되기 쉬운데, GraphQL이면 feed 한 덩어리에 필요한 필드만 쿼리로 박는 그림이 나와요. 예를 들면 대략 이런 느낌이죠.
// 피드 쿼리 — 모바일에서 한 왕복으로 화면을 꽉 채울 때 유리
const GET_FEED = gql`
query GetFeed($limit: Int!, $offset: Int!) {
feed(limit: $limit, offset: $offset) {
id
content
createdAt
author {
id
name
avatar
followersCount
}
images {
url
width
height
}
likesCount
commentsCount
comments(limit: 3) {
id
text
author {
name
avatar
}
}
isLiked
isSaved
}
}
`;
이게 편해보이는 만큼, 리졸버에서 뭐가 몇 번 도는지(앞에 말한 N+1)는 같이 봐야 합니다. DataLoader·쿼리 비용·깊이 제한(악의적으로 깊게 파고드는 쿼리)은 “나중에 손볼게”로 미루면 DB부터 삐걱이고, 팀이 나중에 갈아엎느라 더 아파요.
권한은 REST 때 필터 하던 그 자리에, GraphQL에선 필드마다 생각해줘야 해서(“이 유저한테 이 email 보여?” 같은 거) 팀 룰 없으면 틈이 생깁니다. 에러는 부분 성공이 나올 수 있으니까 클라이언트에서 errors도 같이 보는 쪽이 안전하고요.
정리하면, GraphQL은 “한 번에 예쁘게 싣는” 쪽이 강하고, REST가 나을 때도 많아요 캐싱·단순 자원·파일·운영 익숙함 같은 선에서요. N+1 한 번 터져 보고 DataLoader로 살리면, 그때서야 “아 스키마만 예쁘면 끝이 아니구나”가 몸에 와닿아요.