GraphQL 입문 - REST API와 무엇이 다른가
REST API의 한계
REST API는 지난 15년간 웹 API의 표준처럼 사용되어 왔습니다. 리소스 중심 설계, HTTP 메서드 활용, 무상태성 등 깔끔한 원칙을 가지고 있죠. 하지만 프론트엔드가 복잡해지면서 몇 가지 불편한 점들이 드러났습니다.
Over-fetching
사용자 이름만 필요한데 API가 모든 정보를 다 내려줍니다.
// GET /users/1 응답
{
"id": 1,
"name": "김철수",
"email": "kim@example.com",
"address": "서울시 강남구...",
"phoneNumber": "010-1234-5678",
"createdAt": "2024-01-01",
"updatedAt": "2024-01-15",
"profileImage": "https://...",
"preferences": { ... }
// 20개 필드 더...
}
모바일 환경에서는 이런 불필요한 데이터 전송이 네트워크 비용으로 이어집니다.
Under-fetching
반대로 필요한 데이터를 한 번에 못 가져오는 경우도 있습니다. 사용자 정보와 그 사람이 작성한 게시글, 그리고 각 게시글의 댓글 수를 보여주려면:
GET /users/1
GET /users/1/posts
GET /posts/101/comments/count
GET /posts/102/comments/count
GET /posts/103/comments/count
...
화면 하나 그리는데 API를 여러 번 호출해야 합니다. N+1 문제와 비슷한 양상이죠.
버전 관리의 어려움
API 스펙이 바뀌면 /v1/users, /v2/users 같은 버전 관리가 필요합니다. 클라이언트마다 다른 버전을 사용하면 서버에서 여러 버전을 동시에 관리해야 하고, 이건 생각보다 귀찮습니다.
GraphQL이란
GraphQL은 2015년 Facebook이 공개한 쿼리 언어입니다. 지금은 GitHub, Shopify, Twitter, Airbnb 등 많은 회사에서 사용 중이에요. REST가 "서버가 정해준 대로 받아라"라면, GraphQL은 "클라이언트가 원하는 것만 요청해라"입니다.
간단하게 정리하면 이렇습니다:
- 단일 엔드포인트: 모든 요청이
/graphql하나로 들어감 - 클라이언트 주도 쿼리: 필요한 필드만 명시해서 요청
- 타입 시스템: 스키마로 API 구조를 명확하게 정의
# 클라이언트가 원하는 것만 요청
query {
user(id: 1) {
name
posts {
title
commentCount
}
}
}
// 딱 요청한 것만 응답
{
"data": {
"user": {
"name": "김철수",
"posts": [
{ "title": "첫 번째 글", "commentCount": 5 },
{ "title": "두 번째 글", "commentCount": 3 }
]
}
}
}
한 번의 요청으로 필요한 데이터를 정확히 가져왔습니다.
REST vs GraphQL 비교
| 구분 | REST | GraphQL |
|---|---|---|
| 엔드포인트 | 리소스마다 다름 (/users, /posts) |
단일 (/graphql) |
| 데이터 결정권 | 서버 | 클라이언트 |
| Over-fetching | 발생 가능 | 없음 |
| Under-fetching | 발생 가능 | 없음 (한 번에 요청) |
| HTTP 메서드 | GET, POST, PUT, DELETE | POST (주로) |
| 캐싱 | HTTP 캐싱 활용 용이 | 별도 구현 필요 |
| 파일 업로드 | 쉬움 | 복잡함 |
| 학습 곡선 | 낮음 | 상대적으로 높음 |
GraphQL 핵심 개념
Schema
GraphQL의 핵심은 스키마입니다. API가 어떤 데이터를 제공하는지, 어떤 타입이 있는지 정의합니다.
type User {
id: ID!
name: String!
email: String
posts: [Post!]!
}
type Post {
id: ID!
title: String!
content: String!
author: User!
commentCount: Int!
}
type Query {
user(id: ID!): User
users: [User!]!
post(id: ID!): Post
}
type Mutation {
createUser(name: String!, email: String): User!
createPost(title: String!, content: String!, authorId: ID!): Post!
}
!는 Non-null을 의미합니다. 처음엔 헷갈리는데 표로 정리하면 명확해요.
| 표기 | 배열 자체 | 배열 요소 | 예시 |
|---|---|---|---|
[Post] |
null 가능 | null 가능 | null, [null, post1] |
[Post!] |
null 가능 | null 불가 | null, [post1, post2] |
[Post]! |
null 불가 | null 가능 | [], [null, post1] |
[Post!]! |
null 불가 | null 불가 | [], [post1, post2] |
Query (조회)
데이터를 읽을 때 사용합니다. REST의 GET에 해당하죠.
query GetUserWithPosts {
user(id: "1") {
name
email
posts {
title
commentCount
}
}
}
Mutation (변경)
데이터를 생성, 수정, 삭제할 때 사용합니다. REST의 POST, PUT, DELETE에 해당합니다.
mutation CreatePost {
createPost(
title: "GraphQL 시작하기"
content: "GraphQL은..."
authorId: "1"
) {
id
title
}
}
Subscription (구독)
실시간 데이터가 필요할 때 사용합니다. WebSocket 기반으로 동작해요.
subscription OnPostCreated {
postCreated {
id
title
author {
name
}
}
}
새 게시글이 생성될 때마다 클라이언트에 푸시됩니다.
Spring Boot에서 GraphQL 구현
Spring Boot 3.2 이상에서 Spring for GraphQL이 공식 지원됩니다. 설정도 간단해서 금방 시작할 수 있어요.
의존성 추가
// build.gradle
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-graphql'
implementation 'org.springframework.boot:spring-boot-starter-web'
}
스키마 정의
src/main/resources/graphql/schema.graphqls 파일을 생성합니다.
type Query {
user(id: ID!): User
users: [User!]!
}
type Mutation {
createUser(input: CreateUserInput!): User!
}
input CreateUserInput {
name: String!
email: String
}
type User {
id: ID!
name: String!
email: String
posts: [Post!]!
}
type Post {
id: ID!
title: String!
content: String!
}
input과 type의 차이가 뭘까요? input은 인자로만 쓸 수 있고, type은 반환값으로만 쓸 수 있습니다. Mutation의 인자가 복잡해지면 input 타입으로 묶어서 전달하는 게 깔끔해요.
Controller 구현
@Controller
public class UserController {
private final UserService userService;
private final PostService postService;
public UserController(UserService userService, PostService postService) {
this.userService = userService;
this.postService = postService;
}
@QueryMapping
public User user(@Argument Long id) {
return userService.findById(id);
}
@QueryMapping
public List<User> users() {
return userService.findAll();
}
@MutationMapping
public User createUser(@Argument CreateUserInput input) {
return userService.create(input.name(), input.email());
}
// User 타입의 posts 필드 resolver
@SchemaMapping(typeName = "User", field = "posts")
public List<Post> posts(User user) {
return postService.findByAuthorId(user.getId());
}
}
어노테이션만 보면 감이 오죠? @QueryMapping은 Query 타입, @MutationMapping은 Mutation 타입의 필드를 처리합니다. @SchemaMapping은 특정 타입의 중첩 필드를 해결하는 resolver입니다.
설정
# application.yml
spring:
graphql:
graphiql:
enabled: true # 개발용 UI 활성화
path: /graphql
http://localhost:8080/graphiql에서 쿼리를 테스트할 수 있습니다.
N+1 문제와 DataLoader
GraphQL에서도 N+1 문제가 발생할 수 있습니다. 10명의 사용자를 조회하면서 각각의 posts를 가져오면 1 + 10번의 쿼리가 실행되죠.
query {
users {
name
posts { # 각 user마다 별도 쿼리 발생
title
}
}
}
이를 해결하기 위해 DataLoader를 사용합니다. 여러 요청을 모아서 한 번에 처리하는 배칭 기법인데, 원리는 간단해요. 각 User의 posts 요청을 바로 실행하지 않고 모아뒀다가 한 번에 WHERE author_id IN (1, 2, 3, ...) 쿼리로 처리하는 겁니다.
@Controller
public class UserController {
@SchemaMapping(typeName = "User", field = "posts")
public CompletableFuture<List<Post>> posts(
User user,
DataLoader<Long, List<Post>> postsDataLoader) {
return postsDataLoader.load(user.getId());
}
}
@Configuration
public class DataLoaderConfig {
@Bean
public BatchLoaderRegistry batchLoaderRegistry(PostService postService) {
return registry -> registry
.forName("postsDataLoader")
.registerMappedBatchLoader((Set<Long> userIds, BatchLoaderEnvironment env) -> {
// userIds = [1, 2, 3, ...] 한 번에 조회
Map<Long, List<Post>> postsByUserId = postService.findByAuthorIds(userIds);
return Mono.just(postsByUserId);
});
}
}
10명의 사용자를 조회해도 posts는 1번의 쿼리로 끝납니다. 다만 설정이 좀 번거롭긴 해요.
언제 GraphQL을 선택할까
GraphQL이 적합한 경우
- 다양한 클라이언트: 웹, 모바일, TV 앱 등 각기 다른 데이터가 필요할 때
- 복잡한 데이터 관계: 여러 리소스를 조합해서 보여줘야 할 때
- 빠른 프로토타이핑: 프론트엔드가 백엔드 변경 없이 필요한 데이터를 가져갈 수 있음
- 실시간 기능: Subscription으로 실시간 업데이트가 필요할 때
REST가 더 나은 경우
- 단순한 CRUD: 리소스가 명확하고 관계가 단순할 때
- 파일 업로드가 많은 경우: REST가 훨씬 간단함
- HTTP 캐싱 활용: CDN 캐싱이 중요한 경우
- 팀이 REST에 익숙할 때: 학습 비용도 비용임
실제로 많은 회사들이 둘을 함께 사용합니다. 외부 공개 API는 REST로, 내부 BFF(Backend For Frontend)는 GraphQL로 구성하는 식이죠.
주의사항
보안
클라이언트가 쿼리를 자유롭게 작성할 수 있다는 건 위험할 수도 있습니다.
# 악의적인 쿼리 - 깊은 중첩
query {
users {
posts {
author {
posts {
author {
posts {
# 계속...
}
}
}
}
}
}
}
이런 쿼리는 서버를 죽일 수도 있습니다. 그래서 쿼리 깊이 제한, 복잡도 분석 같은 보호 장치가 필수입니다.
@Configuration
public class GraphQLConfig {
@Bean
public GraphQlSourceBuilderCustomizer graphQlSourceBuilderCustomizer() {
return builder -> builder.configureGraphQl(graphQL ->
graphQL.instrumentation(new MaxQueryDepthInstrumentation(10))
);
}
}
더 세밀한 제어가 필요하면 graphql-java의 Instrumentation을 직접 구현하거나, 쿼리 복잡도를 계산하는 로직을 추가할 수 있습니다.
인증/인가는 어떻게 할까요? Spring Security와 조합해서 사용합니다. @PreAuthorize 어노테이션을 resolver 메서드에 붙이거나, 커스텀 directive를 만들어서 스키마 레벨에서 제어할 수 있어요. REST와 크게 다르지 않습니다.
캐싱
REST는 URL 기반이라 HTTP 캐싱이 자연스럽습니다. GraphQL은 POST 요청이 기본이고 요청 본문이 매번 다르니까 캐싱이 까다롭습니다.
Apollo Client 같은 클라이언트 라이브러리의 캐싱 기능을 활용하거나, Persisted Query를 사용해서 쿼리를 해시화하는 방법이 있습니다.
에러 처리
GraphQL은 부분 실패를 허용합니다. 재밌는 점은 에러가 발생해도 HTTP 상태 코드는 대부분 200입니다. 에러 정보는 응답 본문의 errors 필드에 담겨요. data와 errors가 동시에 있을 수 있습니다.
{
"data": {
"user": {
"name": "김철수",
"posts": null
}
},
"errors": [
{
"message": "posts 조회 실패",
"path": ["user", "posts"]
}
]
}
클라이언트에서 이런 부분 실패를 적절히 처리해야 합니다.
정리
GraphQL은 클라이언트가 필요한 데이터를 정확히 요청할 수 있게 해주는 쿼리 언어입니다. 한 줄로 요약하면? "원하는 것만 요청하고, 요청한 것만 받는다."
핵심 포인트:
- 단일 엔드포인트로 모든 요청 처리
- Schema로 API 구조를 타입 안전하게 정의
- Query/Mutation/Subscription으로 읽기/쓰기/실시간 처리
- DataLoader로 N+1 문제 해결
REST를 대체하는 게 아니라 상황에 맞게 선택하면 됩니다. 복잡한 데이터 요구사항이 있고 다양한 클라이언트를 지원해야 한다면 GraphQL이 좋은 선택입니다. 단순한 CRUD라면 REST가 더 적합할 수 있습니다.
