
GraphQL vs REST: 뷔페 먹을래? 정식 먹을래?
페이스북은 왜 REST API를 버렸을까? 원하는 데이터만 쏙쏙 골라 담는 GraphQL의 매력과 치명적인 단점 (캐싱, N+1 문제) 분석.

페이스북은 왜 REST API를 버렸을까? 원하는 데이터만 쏙쏙 골라 담는 GraphQL의 매력과 치명적인 단점 (캐싱, N+1 문제) 분석.
내 서버는 왜 걸핏하면 뻗을까? OS가 한정된 메모리를 쪼개 쓰는 처절한 사투. 단편화(Fragmentation)와의 전쟁.

로버트 C. 마틴(Uncle Bob)이 제안한 클린 아키텍처의 핵심은 무엇일까요? 양파 껍질 같은 계층 구조와 의존성 규칙(Dependency Rule)을 통해 프레임워크와 UI로부터 독립적인 소프트웨어를 만드는 방법을 정리합니다.

미로를 탈출하는 두 가지 방법. 넓게 퍼져나갈 것인가(BFS), 한 우물만 팔 것인가(DFS). 최단 경로는 누가 찾을까?

프론트엔드 개발자가 알아야 할 4가지 저장소의 차이점과 보안 이슈(XSS, CSRF), 그리고 언제 무엇을 써야 하는지에 대한 명확한 기준.

스타트업에서 관리자 대시보드를 만들던 날, 나는 진짜 화가 났다. 화면 하나를 그리는데 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년에 이걸 만들 수밖에 없었는지 완벽하게 와닿았다.
GraphQL을 이해하는 가장 쉬운 방법은 식당 비유다. 내가 처음 이 개념을 받아들였을 때 머릿속에 딱 들어온 그림이다.
REST API는 '김밥천국(Set Menu)'이다. 메뉴판에 있는 대로만 시켜야 한다. 주방장(Backend Developer)이 정해놓은 세트를 통째로 받는다.
GET /users/1): 김밥 + 라면 + 돈가스가 나온다.GET /users/1, GET /posts)GraphQL은 '호텔 뷔페(Buffet)'다. 접시(Query)를 들고 내가 원하는 것만 골라 담는다. 주방장은 뷔페 테이블(Schema)만 차려놓고, 손님(Client)이 알아서 조합한다.
이 비유를 듣고 나니, GraphQL의 핵심 철학이 한 문장으로 정리됐다:
"클라이언트가 데이터를 정의한다 (Client-Driven Data Fetching)"
REST는 서버가 "이거 줄게"라고 정해주는 구조라면, GraphQL은 클라이언트가 "이거 주세요"라고 요청하는 구조다. 주도권이 완전히 뒤바뀐 것이다.
페이스북이 GraphQL을 만들게 된 계기는 모바일 앱이었다. 2012년, 스마트폰 화면은 작고 네트워크는 느렸다. 그런데 REST API는 이런 문제점들을 안고 있었다.
// 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 환경에서 이게 반복되면 사용자 경험이 최악이 된다.
SNS 피드를 생각해보자. 게시글 하나를 보여주려면:
GET /posts/123 - 게시글 정보GET /users/456 - 작성자 정보GET /posts/123/comments - 댓글 목록GET /posts/123/likes - 좋아요 수4번의 API 호출이 필요하다. 이를 "Waterfall Request"라고 부른다. 첫 번째 응답을 받아야 두 번째 요청을 보낼 수 있는 구조.
화면이 100개면 엔드포인트도 100개가 필요하다. 모바일 팀이 "여기서 createdAt도 보여주고 싶어요"라고 하면 Backend 개발자는 새로운 엔드포인트를 만들거나 기존 API를 수정해야 한다. Frontend 변경 → Backend 배포 → 다시 테스트라는 긴 사이클이 반복된다.
나는 이런 구조가 비효율적이라는 걸 이해했다. 그래서 GraphQL이 어떻게 이 문제를 해결하는지 제대로 정리해본다.
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)처럼 데이터의 그래프 구조를 그대로 표현한다.
기본 타입(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 객체로 변환해준다.
GraphQL의 모든 작업은 세 가지 타입으로 나뉜다.
# 사용자 정보 + 최근 게시글 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: 한 번에 해결.
# 게시글 생성
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를 다시 호출해야 하는 경우가 많다.
# 새 댓글 실시간 수신 (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이 표준 스펙이다.
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 },
});
},
},
};
id, limit 등)결국 이거였다: GraphQL은 Query를 보고 Resolver를 재귀적으로 실행하는 엔진이다. user(id: 1) { name, posts { title } }라는 Query가 들어오면:
Query.user Resolver 실행 → User 객체 반환User.posts Resolver 실행 → Post 배열 반환title 필드만 추출이 과정이 트리를 순회하듯 진행된다. 그래서 이름이 Graph QL인 것이다.
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 라이브러리는 이 문제를 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 정의
fragment UserInfo on User {
id
name
avatar
createdAt
}
# 여러 곳에서 재사용
query {
me {
...UserInfo
posts {
author {
...UserInfo
}
}
}
}
코드 중복을 줄이고, "이 화면에서는 항상 이 필드들을 보여줘"라는 컴포넌트 단위의 데이터 요구사항을 표현할 수 있다.
# Variable 선언 ($userId = 변수명, ID! = 타입)
query GetUser($userId: ID!, $postLimit: Int = 5) {
user(id: $userId) {
name
posts(limit: $postLimit) {
title
}
}
}
# 별도로 전달
{
"userId": "123",
"postLimit": 10
}
쿼리 문자열을 동적으로 조립하는 대신, 변수로 분리하면 보안(SQL Injection 방지)과 캐싱에 유리하다.
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에 연결해보자.
// 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')
);
// 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 상태도 자동 관리.
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를 먼저 업데이트해서 체감 속도를 높인다. 실패하면 롤백.
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를 참조하는 모든 컴포넌트가 자동으로 리렌더링된다.
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: 캐시 먼저 보여주고, 백그라운드에서 새 데이터 받아서 업데이트
이제 언제 뭘 써야 할지 명확하게 정리해본다.
| 상황 | 추천 | 이유 |
|---|---|---|
| 대시보드, 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 | 기존 인프라와 호환성 |
GraphQL의 유연성은 악의적인 공격에도 취약하다.
# 무한 중첩 쿼리로 서버 마비
query EvilQuery {
user(id: "1") {
posts {
author {
posts {
author {
posts {
author {
posts {
# ... 100단계 중첩
}
}
}
}
}
}
}
}
}
const depthLimit = require('graphql-depth-limit');
const server = new ApolloServer({
typeDefs,
resolvers,
validationRules: [depthLimit(5)], // 최대 5단계까지만 허용
});
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의 비용이 계산되고, 제한을 넘으면 거부된다.
// 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에서 Frontend로 옮긴 기술이다."
REST는 Backend가 모든 걸 결정한다. "이 엔드포인트는 이 데이터를 준다." 명확하지만 유연하지 않다.
GraphQL은 Frontend가 결정한다. "나는 이 데이터가 필요해." 유연하지만 Backend는 그만큼 더 복잡해진다. DataLoader, Query Complexity, Caching을 모두 신경 써야 한다.
스타트업이라면 초기엔 REST로 빠르게 만들고, 복잡도가 올라가면 GraphQL로 전환하는 게 현실적이다. 처음부터 GraphQL을 선택하면 러닝 커브에 시간을 너무 많이 쏟게 된다.
하지만 일단 GraphQL을 제대로 이해하고 나면, REST로 돌아가기 힘들다. 뷔페의 자유를 한번 맛보면 김밥천국이 답답하게 느껴지니까.