REST API를 사용하다 보면 API 설계가 생각보다 복잡해지는 순간이 있다.
화면 하나를 구성하기 위해 여러 API를 호출해야 하거나, 반대로 하나의 API가 너무 많은 데이터를 반환해서 클라이언트에서 필요 없는 데이터를 버리는 경우가 생긴다.
GraphQL은 이런 문제를 다른 방식으로 해결한다.
REST가 서버에서 미리 정의한 여러 Endpoint를 제공하는 방식이라면, GraphQL은 하나의 Endpoint를 통해 클라이언트가 필요한 데이터의 구조를 직접 요청하도록 만든다.
GraphQL을 이해할 때 가장 먼저 봐야 할 부분도 이 차이다.
REST API와 GraphQL
REST API에서는 보통 리소스를 기준으로 Endpoint를 나눈다.
GET /users/123
GET /users/123/characters
GET /characters/456/items
화면에서 다음과 같은 데이터가 필요하다고 해보자.
유저 이름
캐릭터 이름
캐릭터 레벨
장착 아이템
아이템 이름
아이템 공격력
REST에서는 필요한 데이터를 가져오기 위해 여러 API를 호출해야 할 수 있다.
GraphQL에서는 클라이언트가 원하는 데이터 구조를 하나의 Query로 표현할 수 있다.
query {
user(id: 123) {
name
characters {
name
level
items {
name
attack
}
}
}
}
서버는 Query에 포함된 필드를 기준으로 데이터를 반환한다.
{
"data": {
"user": {
"name": "Player1",
"characters": [
{
"name": "Knight",
"level": 50,
"items": [
{
"name": "Sword",
"attack": 120
}
]
}
]
}
}
}
REST에서는 서버가 Endpoint와 응답 구조를 결정하는 비중이 크다.
GraphQL에서는 서버가 Schema를 제공하고 클라이언트가 필요한 필드를 선택한다.
GraphQL의 기본 구조
GraphQL을 구성하는 핵심 요소는 Schema, Query, Mutation, Subscription, Resolver다.
Client -> Query / Mutation / Subscription -> GraphQL Server -> Schema -> Resolver -> Service / Repository / Data Source
Schema는 어떤 데이터와 작업을 사용할 수 있는지 정의한다.
Query는 데이터를 조회한다.
Mutation은 데이터를 변경한다.
Subscription은 서버에서 발생하는 이벤트를 실시간으로 전달받을 때 사용한다.
Resolver는 요청받은 필드를 실제 데이터로 만들어 반환한다.
GraphQL을 단순히 REST보다 편한 API라고 생각하기보다, Schema를 기반으로 클라이언트의 데이터 요청을 처리하는 API 시스템으로 보는 편이 정확하다.
Schema
GraphQL에는 Schema가 존재한다.
Schema는 클라이언트가 사용할 수 있는 타입과 필드, 그리고 데이터 간의 관계를 정의한다.
type User {
id: ID!
name: String!
age: Int
characters: [Character!]
}
type Character {
id: ID!
name: String!
level: Int!
}
String, Int, Float, Boolean, ID 같은 기본 타입을 사용할 수 있고 직접 타입을 정의할 수도 있다.
!는 해당 값이 null이 될 수 없다는 의미다.
name: String!
이라면 name은 반드시 값이 존재해야 한다.
반대로
age: Int
라면 age는 null일 수 있다.
Schema를 통해 API에서 사용할 수 있는 데이터 구조와 타입이 명확하게 정의된다.
그래서 GraphQL은 API 계약을 코드 수준에서 명확하게 관리하기 좋다.
Query
Query는 데이터를 조회하기 위한 작업이다.
Schema에서는 다음과 같이 정의할 수 있다.
type Query {
user(id: ID!): User
users: [User!]!
}
클라이언트는 필요한 필드만 선택해서 요청한다.
query {
user(id: "123") {
id
name
}
}
다른 요청에서는 더 많은 데이터를 가져올 수도 있다.
query {
user(id: "123") {
name
age
characters {
name
level
}
}
}
같은 user Query를 사용하더라도 클라이언트가 요청하는 필드에 따라 응답 구조가 달라진다.
이것이 GraphQL의 핵심적인 특징 중 하나다.
Mutation
Query가 데이터 조회라면 Mutation은 데이터 변경을 담당한다.
type Mutation {
createUser(name: String!): User!
updateUser(id: ID!, name: String!): User!
deleteUser(id: ID!): Boolean!
}
클라이언트에서는 다음과 같이 요청할 수 있다.
mutation {
createUser(name: "Player1") {
id
name
}
}
Mutation에서도 반환받을 필드를 직접 선택할 수 있다.
mutation {
createUser(name: "Player1") {
id
name
characters {
name
}
}
}
Mutation을 HTTP의 POST와 1:1로 대응되는 개념으로 보면 안 된다.
GraphQL에서 Mutation은 데이터를 변경하는 작업을 표현하기 위한 Operation 타입이다.
실제 HTTP에서는 일반적으로 POST를 사용하지만 GraphQL 자체가 HTTP Method에 종속된 것은 아니다.
Resolver
Schema만 정의했다고 실제 데이터가 생성되는 것은 아니다.
GraphQL 서버에는 Resolver가 필요하다.
예를 들어 다음 Schema가 있다고 하자.
type Query {
user(id: ID!): User
}
서버에서는 해당 Query를 실제 데이터로 연결하는 Resolver를 작성한다.
const resolvers = {
Query: {
user: (_, args) => {
return userRepository.findById(args.id);
}
}
};
전체적인 흐름은 다음과 같다.
Client -> GraphQL Server -> Query Resolver -> Service / Repository -> Database
Resolver가 반드시 Database에 직접 접근해야 하는 것은 아니다.
REST API와 마찬가지로 Service, Repository, Cache, 외부 API 등을 연결할 수 있다.
Resolver -> Service -> Repository -> Database
Resolver -> External API
Resolver -> Cache
GraphQL은 데이터를 실제로 어디에서 가져오는지를 정의하는 기술이라기보다, 클라이언트의 데이터 요청을 Schema와 Resolver를 통해 처리하는 구조에 가깝다.
Nested Query
GraphQL의 특징이 가장 잘 드러나는 부분이 Nested Query다.
query {
user(id: "123") {
name
characters {
name
level
items {
name
attack
}
}
}
}
Schema에 데이터 관계가 정의되어 있다면 하나의 Query에서 관계를 따라가며 필요한 데이터를 요청할 수 있다.
User -> Character -> Item
REST에서는 이러한 데이터를 여러 Endpoint로 나눠 처리할 수 있다.
GET /users/123 -> GET /users/123/characters -> GET /characters/456/items
GraphQL에서는 클라이언트가 원하는 데이터 구조를 하나의 Query로 표현할 수 있다.
다만 Nested Query가 많다고 해서 서버의 Database Query까지 자동으로 줄어드는 것은 아니다.
오히려 Resolver를 잘못 구현하면 성능 문제가 발생할 수 있다.
대표적인 문제가 N+1 문제다.
N+1 문제
예를 들어 User 100명을 가져오면서 각 User의 Character도 가져온다고 하자.
query {
users {
id
name
characters {
name
}
}
}
Resolver를 단순하게 구현하면 다음과 같은 상황이 발생할 수 있다.
User 조회 1회 -> 각 User의 Character 조회 100회 -> 총 101번의 Database Query
이것이 N+1 문제다.
GraphQL에서는 Nested Resolver를 많이 사용하기 때문에 특히 주의해야 한다.
대표적인 해결 방법으로 DataLoader 같은 Batch 처리 방식을 사용할 수 있다.
User 100명 조회 -> Character ID 수집 -> Character 일괄 조회 -> User별 Character 매핑
따라서 GraphQL에서는 Schema만 설계하는 것이 아니라 Resolver가 실제 데이터를 어떻게 조회하는지도 함께 설계해야 한다.
Over-fetching과 Under-fetching
GraphQL이 자주 해결하는 문제 중 하나가 Over-fetching과 Under-fetching이다.
Over-fetching
필요한 데이터보다 많은 데이터를 받는 경우다.
예를 들어 다음 API가 있다고 하자.
GET /users/123
응답에 다음 데이터가 모두 포함되어 있다고 해보자.
{
"id": 123,
"name": "Player1",
"email": "...",
"age": 25,
"address": "...",
"characters": [],
"friends": [],
"inventory": []
}
화면에서는 이름 하나만 필요한데 불필요한 데이터까지 받아야 한다.
GraphQL에서는 필요한 필드만 요청할 수 있다.
query {
user(id: "123") {
name
}
}
Under-fetching
반대로 필요한 데이터를 가져오기 위해 여러 API를 호출해야 하는 경우다.
User API -> Character API -> Inventory API -> Item API
GraphQL에서는 Schema에 관계가 구성되어 있다면 하나의 Query에서 필요한 데이터 구조를 요청할 수 있다.
Client -> GraphQL Query -> User -> Character -> Inventory -> Item
다만 API 요청 횟수가 줄어든다고 해서 Database Query도 자동으로 줄어드는 것은 아니다.
HTTP Request 감소 != Database Query 감소
이 차이는 GraphQL을 사용할 때 반드시 구분해야 한다.
GraphQL과 REST의 차이
REST와 GraphQL은 단순히 어느 하나가 더 좋은 기술이라고 볼 수 없다.
REST는 리소스와 Endpoint를 중심으로 API를 설계한다.
/users -> /users/{id} -> /characters -> /characters/{id}
GraphQL은 Schema와 Query를 중심으로 데이터를 요청한다.
Client -> Query -> Schema -> Resolver -> Data
REST에서는 서버가 Endpoint와 응답 구조를 정의하는 비중이 크다.
GraphQL에서는 서버가 Schema를 정의하고 클라이언트가 필요한 필드를 선택한다.
따라서 모바일, 웹, 게임 클라이언트처럼 클라이언트마다 필요한 데이터가 크게 다르거나 여러 데이터 관계를 조합해야 하는 서비스에서는 GraphQL이 유용할 수 있다.
반대로 단순한 CRUD API처럼 리소스 구조가 명확하고 HTTP의 표준적인 캐싱과 동작을 적극적으로 활용해야 한다면 REST가 더 단순할 수 있다.
GraphQL을 REST의 상위 호환으로 생각하면 안 된다.
GraphQL의 HTTP 요청
GraphQL은 일반적으로 하나의 Endpoint를 사용한다.
POST /graphql
요청 Body에는 Query가 포함된다.
{
"query": "query { user(id: \"123\") { name } }"
}
HTTP Endpoint는 하나지만 내부에서는 Query와 Mutation 등에 따라 서로 다른 작업이 실행된다.
응답은 일반적으로 data를 포함한다.
{
"data": {
"user": {
"name": "Player1"
}
}
}
오류가 발생하면 errors 필드가 포함될 수 있다.
{
"errors": [
{
"message": "User not found"
}
]
}
따라서 GraphQL에서는 HTTP Status Code만 보는 것이 아니라 GraphQL의 data와 errors 구조를 함께 고려해야 한다.
GraphQL의 장점
GraphQL의 가장 큰 장점은 클라이언트가 필요한 데이터 구조를 직접 선택할 수 있다는 것이다.
특히 여러 종류의 클라이언트가 하나의 백엔드를 사용하는 경우 유용하다.
GraphQL Server -> Web
GraphQL Server -> Android
GraphQL Server -> iOS
GraphQL Server -> Game Client
GraphQL Server -> Admin
각 클라이언트는 필요한 필드만 선택해서 요청할 수 있다.
또한 Schema가 타입 정보를 가지고 있기 때문에 IDE 자동완성, 타입 검사, 문서화, 코드 생성 등의 기능을 활용하기 좋다.
GraphQL의 단점
GraphQL은 클라이언트의 자유도가 높은 만큼 서버 구조가 복잡해질 수 있다.
가장 먼저 Resolver 관리 문제가 생긴다.
Query -> Resolver -> Service -> Repository -> Database
Nested Query가 깊어질수록 Resolver 간의 관계와 데이터 조회 방식을 신경 써야 한다.
N+1 문제도 대표적인 문제다.
또한 클라이언트가 복잡한 Query를 작성할 수 있다는 것은 서버 입장에서는 부하가 될 수 있다.
예를 들어 지나치게 깊은 Query나 지나치게 많은 데이터를 요구하는 요청이 들어올 수 있다.
그래서 실제 서비스에서는 다음과 같은 방어 장치를 고려할 수 있다.
Query Depth 제한 -> Query Complexity 제한 -> Pagination -> Timeout -> Rate Limit
캐싱도 REST보다 단순하지 않을 수 있다.
REST는 URL과 HTTP Method를 기준으로 캐시하기 비교적 쉽지만 GraphQL은 하나의 Endpoint에 다양한 Query가 들어오기 때문에 별도의 캐싱 전략이 필요할 수 있다.
Pagination
GraphQL에서 목록 데이터를 가져올 때는 Pagination도 함께 설계해야 한다.
다음과 같이 모든 사용자를 한 번에 가져오도록 만들면 데이터가 많아졌을 때 문제가 발생할 수 있다.
query {
users {
id
name
}
}
그래서 페이지 크기나 Cursor를 이용해 데이터를 나눠 가져온다.
Cursor 기반 Pagination은 다음과 같은 형태로 구성할 수 있다.
query {
users(first: 20, after: "cursor") {
edges {
node {
id
name
}
}
pageInfo {
hasNextPage
endCursor
}
}
}
전체적인 흐름은 다음과 같다.
Client -> Cursor 전달 -> 다음 데이터 조회 -> 새로운 Cursor 반환 -> 다음 요청
특히 대규모 데이터에서는 Offset 기반보다 Cursor 기반 Pagination이 유리한 경우가 많다.
다만 Pagination 방식은 데이터 특성과 조회 패턴을 고려해서 선택해야 한다.
GraphQL과 인증 / 권한
GraphQL에서는 클라이언트가 원하는 필드를 직접 요청할 수 있기 때문에 권한 처리가 중요하다.
Schema에 필드가 존재한다고 해서 모든 사용자가 접근할 수 있는 것은 아니다.
예를 들어 다음과 같은 Query가 있다고 하자.
query {
user(id: "123") {
name
email
paymentHistory
}
}
로그인한 사용자라고 해서 paymentHistory까지 접근할 수 있다는 의미는 아니다.
실제 권한 검사는 Resolver나 Service 계층에서 수행할 수 있다.
HTTP Request -> Authentication -> GraphQL -> Resolver -> Authorization -> Service -> Data
인증은 사용자가 누구인지 확인하는 것이고, 인가는 해당 사용자가 특정 데이터나 기능에 접근할 수 있는지 확인하는 것이다.
GraphQL의 필드 선택 기능과 별개로 실제 데이터 접근 권한은 서버에서 반드시 통제해야 한다.
GraphQL을 사용할 때 고려할 것
GraphQL을 도입한다고 해서 REST API의 모든 문제가 자동으로 해결되는 것은 아니다.
실제로는 다음 요소들을 함께 설계해야 한다.
Schema -> Type / Query / Mutation / Pagination
Resolver -> Data Fetching / Batch / Authorization / Error Handling
Infrastructure -> Cache / Database / Monitoring / Query Limiting
특히 Resolver가 Database에 직접 접근하도록 무분별하게 구성하면 비즈니스 로직과 데이터 접근 로직이 섞일 수 있다.
그래서 일반적인 애플리케이션 구조에서는 GraphQL을 기존 Service / Repository 구조와 연결해서 사용하는 경우가 많다.
Client -> GraphQL -> Resolver -> Service -> Repository -> Database
GraphQL은 기존 애플리케이션 아키텍처를 전부 대체하는 기술이 아니라 API 계층에서 클라이언트의 데이터 요청 방식을 바꾸는 기술로 보는 편이 이해하기 쉽다.
GraphQL을 어디에 사용할 것인가
GraphQL은 데이터 관계가 복잡하고 클라이언트마다 필요한 데이터가 다른 서비스에서 특히 잘 맞는다.
예를 들어 하나의 백엔드를 여러 클라이언트가 사용한다고 해보자.
GraphQL Server -> Web
GraphQL Server -> Mobile
GraphQL Server -> Game
GraphQL Server -> Admin
각 클라이언트가 필요한 데이터가 다르고 하나의 화면을 구성하기 위해 여러 리소스를 조합해야 한다면 GraphQL의 장점을 활용하기 좋다.
반대로 단순한 CRUD API라면 REST로 충분할 수 있다.
GET /users/{id}
POST /users
PUT /users/{id}
DELETE /users/{id}
이 정도의 구조라면 GraphQL을 추가하면서 얻는 이점보다 API 계층이 복잡해지는 비용이 더 클 수도 있다.
GraphQL을 사용하기 위한 이유가 필요해서 GraphQL을 도입하는 것은 좋은 설계가 아니다.
전체 구조
GraphQL의 전체적인 흐름을 하나로 정리하면 다음과 같다.
Client -> Query / Mutation -> GraphQL Endpoint -> Schema -> Resolver -> Service -> Repository / External API / Cache -> Database
예를 들어 User와 Character를 함께 조회한다면 다음과 같은 흐름이 만들어질 수 있다.
Client -> Query -> User Resolver -> User Service -> User Repository -> Database
Client -> Query -> Character Resolver -> Character Service -> Character Repository -> Database
Nested Query를 효율적으로 처리한다면 Batch 로딩이나 DataLoader 등을 추가할 수 있다.
Nested Query -> Resolver -> Batch Loader -> 일괄 조회 -> 데이터 매핑 -> Response
그리고 전체 요청에는 인증과 권한 검사, Query 제한, 모니터링 등의 기반 기능이 붙는다.
Client -> Authentication -> GraphQL -> Query Validation -> Resolver -> Authorization -> Service -> Repository -> Database
REST와 GraphQL을 한 번에 비교하면
REST -> Resource -> Endpoint -> Fixed Response
GraphQL -> Schema -> Query -> Selected Fields
REST는 서버가 제공하는 리소스와 Endpoint를 중심으로 API를 설계한다.
GraphQL은 서버가 Schema를 정의하고 클라이언트가 필요한 데이터 구조를 선택한다.
REST -> Server가 API 구조를 정의 -> Client가 Endpoint 호출
GraphQL -> Server가 Schema 정의 -> Client가 필요한 필드 선택 -> Server가 Query 실행
두 방식의 차이를 이 정도로 잡아두면 실제 프로젝트에서 어떤 API 방식을 선택해야 할지도 판단하기 쉬워진다.
정리
GraphQL을 공부하면서 가장 먼저 잡아야 할 구조는 다음과 같다.
Client -> Query / Mutation -> GraphQL Server -> Schema -> Resolver -> Service -> Repository -> Database
클라이언트는 Schema에 정의된 데이터 구조를 기준으로 필요한 필드를 선택한다.
query {
user(id: "123") {
name
characters {
name
level
}
}
}
GraphQL이 해결하려는 대표적인 문제는 Over-fetching과 Under-fetching이다.
반면 자유로운 Query 구조 때문에 N+1, 과도한 Query, 권한, Pagination, 캐싱 등의 문제를 서버에서 별도로 관리해야 한다.
그래서 GraphQL을 단순히 REST API를 대체하는 기술이라고 이해하기보다는 다음과 같이 구분하는 편이 좋다.
REST -> Resource / Endpoint 중심
GraphQL -> Schema / Query 중심
GraphQL의 본질은 클라이언트가 필요한 데이터 구조를 선언하고, 서버가 Schema와 Resolver를 통해 그 요청을 처리하는 것에 있다.
결국 중요한 것은 Query 문법 자체가 아니다.
Schema 설계 -> Query 설계 -> Resolver 구현 -> Data Fetching -> 성능 / 권한 / 캐싱 관리
GraphQL을 제대로 사용하려면 Schema가 실제 애플리케이션의 Service, Repository, Database 구조와 어떻게 연결되는지를 이해해야 한다.