본문으로 건너뛰기
Previous
Next
GraphQL 완벽 가이드: API 쿼리 언어

GraphQL 완벽 가이드: API 쿼리 언어

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로 살리면, 그때서야 “아 스키마만 예쁘면 끝이 아니구나”가 몸에 와닿아요.