GraphQL 완벽 가이드: API 쿼리 언어
이 글의 핵심
GraphQL은 클라이언트가 필요한 데이터만 정확히 요청할 수 있는 쿼리 언어입니다. Over-fetching, Under-fetching 문제를 해결하고, 강력한 타입 시스템과 실시간 Subscription으로 현대적인 API를 구축할 수 있습니다.
GraphQL이란
GraphQL은 Facebook(현 Meta)이 만든 API 쿼리 언어입니다. 클라이언트가 “이 필드만 달라”고 정확히 요청할 수 있다는 점이 REST와 가장 다른 부분입니다. 엔드포인트는 보통 /graphql 하나만 두고, 요청은 대체로 HTTP POST로 전송됩니다. 필요한 필드만 지정해서 받아올 수 있는 구조이기 때문에, REST에서 자주 언급되는 Over-fetching(필요 이상으로 많은 데이터를 받는 문제)과 Under-fetching(필요한 데이터를 채우기 위해 여러 번 요청해야 하는 문제)을 동시에 완화할 수 있습니다.
REST API와의 차이
실무에서는 REST가 더 적합한 경우도 많습니다. HTTP 캐시를 그대로 활용하기 쉬운 쪽은 REST이며, 파일 업로드, 단순한 CRUD, CDN에 그대로 캐싱할 수 있는 응답이 필요한 API라면 GraphQL의 스키마·리졸버 설계 비용을 들이는 것보다 REST가 더 간단하고 빠른 경우가 많습니다. 반대로 GraphQL이 강점을 보이는 쪽은, 화면마다 필요한 데이터 모양이 제각각이라 REST로는 엔드포인트가 계속 분화되거나, 관계가 깊은 데이터를 한 번에 가져오기 어려운 상황입니다.
두 방식을 간단히 비교하면 다음과 같습니다.
- 엔드포인트 구조: GraphQL은 단일 엔드포인트에서 필드 단위로 데이터를 가져오는 반면, REST는
/users,/posts처럼 리소스별로 엔드포인트가 나뉘고 응답 모양은 서버가 정한 대로 고정됩니다. - 버전 관리: REST는 URL에
/v1,/v2를 붙이는 방식이 흔하고, GraphQL은 필드 단위 Deprecation(@deprecated지시자)으로 하위 호환을 관리하는 편입니다. - 캐싱: REST는 HTTP 캐시, CDN 등 기존 웹 생태계와 잘 맞물리는 반면, GraphQL은 요청 본문이 매번 달라질 수 있어 캐싱 전략을 별도로 설계해야 합니다.
N+1 문제, 처음 만났을 때
N+1 문제는 GraphQL을 실무에 도입하면서 거의 누구나 한 번은 겪게 되는 함정입니다. 리졸버를 단순하게 “필요할 때마다 user.find를 한 번씩 호출”하는 방식으로 작성하면, 피드에 게시글이 20개 있을 때 DB에는 SELECT가 총 21번(게시글 목록 1번 + 작성자 조회 20번) 발생합니다. 상위 posts 쿼리는 한 번만 호출되지만, 각 게시글의 author 필드를 채우는 리졸버가 게시글 수만큼 반복 호출되기 때문입니다.
이 현상을 두고 “GraphQL을 쓰면 느려진다”고 오해하는 경우가 있지만, 실제 원인은 GraphQL 자체가 아니라 리졸버가 항목마다 개별 쿼리를 날리는 설계입니다. 이 문제는 DataLoader를 도입해 같은 요청 안에서 필요한 userId를 모아 WHERE id IN (...) 한 번으로 묶어 조회하면 해결됩니다. 아래 DataLoader로 N+1 문제 해결하기 절에서 구체적인 구현을 다룹니다.
개발 환경 설정
프로젝트에 GraphQL 서버를 붙이려면 graphql과 @apollo/server 패키지가 기본으로 필요합니다. TypeScript를 사용하면 스키마와 리졸버의 타입을 함께 관리할 수 있어 개발 경험이 좋아집니다.
# Apollo Server 설치
npm install @apollo/server graphql
# TypeScript (선택)
npm install -D @types/node typescript
REST처럼 라우트를 하나하나 추가하는 대신, 타입 정의와 리졸버만 정확히 작성하면 API가 완성되는 구조입니다. 덕분에 초기 세팅은 비교적 빠르게 끝나지만, 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}`);
위 예제에서 눈여겨볼 부분은 User.posts와 Post.author처럼 타입별로 별도 리졸버를 둘 수 있다는 점입니다. Apollo Server는 클라이언트가 요청한 필드만 골라 해당 리졸버를 호출하므로, 쿼리에 posts를 포함하지 않으면 User.posts 리졸버는 아예 실행되지 않습니다. 이 덕분에 클라이언트가 필요로 하는 데이터의 양에 맞춰 서버 작업량도 자연스럽게 줄어듭니다.
쿼리 작성하기
쿼리는 “전체를 가져오는” 방식이 아니라 필요한 필드만 선택해서 요청하는 방식입니다. 특정 리소스 조회, 변수 사용, 프래그먼트, 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
}
}
}
Alias로 같은 필드를 여러 번 요청하기
동일한 필드를 다른 인자로 여러 번 조회해야 할 때는 alias를 사용합니다. REST였다면 별도 요청이 필요했을 상황을 한 번의 쿼리로 처리할 수 있습니다.
query {
user1: user(id: "1") {
name
}
user2: user(id: "2") {
name
}
}
뮤테이션 작성하기
뮤테이션은 데이터를 변경하는 연산이지만, 응답으로 받을 필드를 선택한다는 점은 쿼리와 동일합니다.
# 유저 생성
mutation {
createUser(name: "홍길동", email: "hong@example.com") {
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
}
}
뮤테이션 응답에도 원하는 필드를 지정할 수 있기 때문에, 생성이나 수정 직후 클라이언트가 별도의 조회 요청을 보내지 않고도 최신 상태를 즉시 받아볼 수 있습니다.
DataLoader로 N+1 문제 해결하기
N+1 문제를 근본적으로 해결하는 방법은 “같은 키로 여러 번 호출되는 요청을 모아 한 번에 처리”하는 것입니다. dataloader 라이브러리가 이 패턴의 표준적인 구현체이며, 리졸버에서는 load 메서드만 호출하면 내부적으로 배치 처리가 이루어집니다. 앞서 설명한 “게시글 20개 → 작성자 조회 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)
}
})
});
DataLoader는 요청 단위로 새로 생성해야 한다는 점이 중요합니다. 위 예제처럼 context 함수 안에서 매 요청마다 새 DataLoader 인스턴스를 만들어야 캐시가 요청 간에 잘못 공유되는 문제를 피할 수 있습니다. 같은 요청 내에서는 동일한 키로 여러 번 load를 호출해도 배치와 캐시가 함께 작동하므로, 한 화면에서 같은 작성자의 글이 여러 개 나와도 실제 DB 조회는 한 번만 일어납니다.
클라이언트에서 사용하기: Apollo Client
프론트엔드에서는 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>;
}
useQuery는 loading, error, data 세 가지 상태를 함께 반환하므로 로딩·에러 처리를 별도 라이브러리 없이 컴포넌트 안에서 바로 다룰 수 있습니다. useMutation의 refetchQueries 옵션은 뮤테이션 성공 후 지정한 쿼리를 다시 실행해 캐시를 최신 상태로 맞춰주며, 더 세밀한 제어가 필요하다면 update 콜백으로 캐시를 직접 조작하는 방법도 있습니다.
Subscription으로 실시간 데이터 받기
Subscription은 서버가 특정 이벤트가 발생할 때마다 클라이언트로 데이터를 밀어주는(push) 방식입니다. WebSocket 연결 위에서 동작하며, 서버는 PubSub으로 이벤트를 발행하고 클라이언트는 useSubscription으로 이를 수신합니다. REST와 폴링(polling)에 익숙한 팀이라면 Server-Sent Events(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>;
}
이 예제에서 pubsub.publish는 createPost 뮤테이션이 성공한 직후 호출되어, 해당 이벤트를 구독 중인 모든 클라이언트에게 새 게시글 정보를 즉시 전달합니다. 실서비스에서는 PubSub을 단일 서버 인메모리 구현 대신 Redis 기반 구현(graphql-redis-subscriptions 등)으로 교체해, 서버 인스턴스가 여러 대로 늘어나도 이벤트가 모든 인스턴스에 정상적으로 전파되도록 구성하는 것이 일반적입니다.
실전 예제: 소셜 피드 쿼리
소셜 피드 화면을 예로 들면 GraphQL의 장점이 더 뚜렷하게 드러납니다. REST라면 게시글 목록, 작성자 정보, 썸네일, 댓글 미리보기, 좋아요 수를 각각 다른 엔드포인트로 나누어 호출하다가, 화면 종류가 늘어날 때마다 화면 전용 BFF(Backend for Frontend)를 추가하는 방향으로 흘러가기 쉽습니다. 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
}
}
`;
이런 쿼리가 편리해 보이는 만큼, 리졸버에서 실제로 몇 번의 DB 조회가 발생하는지(앞서 설명한 N+1 문제)는 반드시 함께 점검해야 합니다. author, images, comments처럼 배열 안에서 다시 배열을 채우는 필드가 많을수록 DataLoader 적용 여부가 성능에 직접적인 영향을 줍니다. DataLoader 적용, 쿼리 비용 제한, 깊이 제한을 “나중에 처리하겠다”고 미루면 트래픽이 늘어난 시점에 DB 부하로 먼저 문제가 드러나며, 이미 서비스 중인 스키마를 뒤늦게 바꾸는 작업은 처음부터 설계하는 것보다 훨씬 부담이 큽니다.
프로덕션에서 고려할 것들
REST에서는 미들웨어나 컨트롤러 단위로 처리하던 권한 검사를, GraphQL에서는 필드 단위로 고려해야 하는 경우가 많습니다. 예를 들어 “이 사용자에게 다른 사용자의 email 필드를 보여줘도 되는가”와 같은 판단을 리졸버나 필드 단위 가드에서 해야 하므로, 팀 차원의 공통 규칙이 없으면 권한 누락이 생기기 쉽습니다. 또한 GraphQL 응답은 일부 필드만 실패하는 부분 성공(partial success)이 가능하므로, 클라이언트에서도 data뿐 아니라 errors 배열을 함께 확인하는 처리가 필요합니다.
운영 환경에서 흔히 함께 설정하는 항목은 다음과 같습니다.
- 인트로스펙션 비활성화: 개발 단계에서는 유용하지만, 공개 API에서는 스키마 노출을 줄이기 위해 끄거나 제한하는 경우가 많습니다.
- 쿼리 깊이 제한(depth limit): 중첩된 쿼리를 지나치게 깊게 보내는 요청을 차단합니다.
- 쿼리 비용 분석(complexity limit): 필드별 비용을 매겨 한 요청이 소비할 수 있는 리소스를 상한선으로 묶습니다.
- 페이지네이션: 목록 필드는 무제한 반환이 아니라
limit/offset또는 커서 기반 페이지네이션으로 제한합니다.
정리
GraphQL은 화면마다 필요한 데이터 모양이 다르고, 한 번의 요청으로 관계가 깊은 데이터를 함께 가져와야 하는 상황에서 강점을 발휘합니다. 반면 캐싱, 단순한 리소스 CRUD, 파일 처리, 기존 운영 경험을 그대로 활용하고 싶은 경우에는 REST가 더 나은 선택이 될 수 있습니다. N+1 문제를 한 번 직접 겪고 DataLoader로 해결해 보면, 스키마 설계만으로 끝나는 것이 아니라 리졸버 구현과 운영 설정까지 함께 관리해야 좋은 GraphQL API가 완성된다는 점을 체감하게 됩니다.
같이 보면 좋은 글 (내부 링크)
이 주제와 연결되는 다른 글입니다.
- NestJS 완벽 가이드: 엔터프라이즈급 Node.js 프레임워크
- API 설계 가이드 | REST vs GraphQL vs gRPC 완벽 비교
- Axios 완벽 가이드 | HTTP 클라이언트·Interceptor·에러 처리·실전 활용
- Convex 완벽 가이드 | 실시간 백엔드·타입 안전성·React·Serverless·실전 활용