graphQL 학습
graphQL 학습한 내용을 정리합니다.
GraphQL이란 무엇인가
개념, 목적, 타입/스키마, REST 비교, 클라이언트/서버, Spring/Apollo 예제, 마이그레이션 전략
GraphQL은 페이스북(현 Meta) 에서 2012년경 모바일 앱을 위해 내부적으로 개발하여 2015년에 오픈소스로 공개하였습니다.
GraphQL은 API를 위한 쿼리 언어(Query Language) 이고, 서버 측에서 그 쿼리를 타입 기반 스키마에 맞게 실행하는 런타임입니다.
요약하자면
“API를 위한 타입 시스템 + 질의 언어” 라고 할 수 있습니다.
전통적인 REST에서 GET /users/1, GET /users/1/posts 처럼 엔드포인트 단위로 자원을 가져온다면, GraphQL에서는 보통 단일 엔드포인트(POST /graphql)에 아래와 같이 쿼리를 날립니다.
1
2
3
4
5
6
7
8
9
10
query {
user(id: 1) {
id
name
posts(limit: 10) {
id
title
}
}
}
서버는 이 쿼리 구조 그대로 JSON을 반환합니다.
1
2
3
4
5
6
7
8
9
10
11
{
"data": {
"user": {
"id": 1,
"name": "Tom",
"posts": [
{ "id": 101, "title": "First Post" }
]
}
}
}
REST 기반 API의 문제점 (특히 모바일 환경)
GraphQL은 기존 REST 기반 API의 문제점을 보완합니다.
- Over-fetching (데이터를 너무 많이 가져옴)
- 화면은
id,name만 필요하지만,GET /users/1응답에는 수십 개 필드 포함. - 모바일 네트워크 환경에서는 불필요한 데이터 전송 비용 + 느린 응답.
- 화면은
- Under-fetching (필요한 데이터를 다 못 가져옴)
- 한 화면에 사용자 정보 + 최근 글 + 친구 리스트가 필요.
- REST에서는:
GET /users/1GET /users/1/postsGET /users/1/friends
- 여러 번 API 호출이 필요 -> 왕복 횟수 증가, 복잡한 API 조합 로직이 클라이언트에 생김.
- 버전 관리의 어려움
v1,v2엔드포인트를 나누다가 어느 순간 버전 지옥.- 과거 클라이언트 호환 때문에 오래된 버전도 계속 유지해야 함.
GraphQL의 목적과 특징을 요약하자면 아래와 같이 정리할 수 있습니다.
- 클라이언트 중심 데이터 패칭
- 클라이언트(웹/모바일)가 화면에 필요한 데이터 구조를 직접 정의.
- Over/Under-fetching 해소
- 딱 요청한 필드만 내려준다.
- 여러 리소스를 한 번의 요청으로 조합해서 가져온다.
- 명확한 스키마 기반 계약(Contract)
- 타입 시스템으로 서버–클라이언트 간 계약을 명시.
- 자동 문서화, 자동 완성, 정적 분석 도구 활용 가능.
- 유연한 진화
- 스키마에 필드를 추가하는 방식으로 비파괴적 변화를 쉽게 가져갈 수 있음.
- 버전 번호(v1, v2…) 대신 필드 수준에서 진화.
REST와 차이점 요약
REST와 차이점을 요약해보겠습니다.
구조적 차이
| 항목 | REST | GraphQL |
|---|---|---|
| Endpoints | 리소스별 여러 엔드포인트 | 보통 단일 엔드포인트 (/graphql) |
| 데이터 요청 방식 | 서버가 응답 구조를 결정 | 클라이언트가 응답 구조를 정의 |
| 타입 시스템 | 보통 명시적 타입 없음(JSON 스펙) | 엄격한 타입 시스템 & 스키마 |
| 오버/언더 패칭 | 자주 발생 | 최소화 |
| 버전 관리 | /v1, /v2 등 엔드포인트 버전 | 필드 추가/Deprecated로 진화, 보통 버전 안 나눔 |
장단점
장점
- 클라이언트는 필요한 데이터만 딱 가져옴 -> 네트워크 효율.
- 한 번의 요청으로 여러 리소스를 가져올 수 있음.
- 스키마 기반으로 문서화/자동완성/타입체킹 등 개발자 경험( DX ) 향상.
단점/고려 사항
- 서버 구현 복잡도 ↑ (리졸버, 권한, N+1 문제 등)
- 캐싱 전략이 REST에 비해 난이도가 있음 (특히 HTTP 레벨 캐싱)
- 단순 CRUD 위주의 API에서 오버엔지니어링이 될 수 있음.
타입 시스템과 스키마
GraphQL의 핵심은 타입 기반 스키마입니다. 서버는 “어떤 타입이 있고, 그 타입이 어떤 필드를 가지며, 어떤 쿼리/변경이 가능한지”를 SDL(Schema Definition Language)로 정의합니다.
기본 스칼라 타입 (Scalars)
대표적인 내장 스칼라 타입:
Int: 32-bit 정수Float: 부동소수점 숫자String: 문자열Boolean: 참/거짓ID: 고유 식별자 (문자열이지만 의미상 식별자)
추가로 DateTime, Email 같은 커스텀 스칼라 타입을 정의해서 사용할 수 있습니다.
오브젝트 타입 (Object Types)
실제 데이터 구조를 표현하는 타입.
1
2
3
4
5
6
7
8
9
10
11
12
13
type User {
id: ID!
name: String!
age: Int
posts: [Post!]!
}
type Post {
id: ID!
title: String!
content: String
author: User!
}
!: Non-null (반드시 값이 있어야 함)[Post!]!- 바깥
!: 리스트 전체가 null이면 안 됨 - 안쪽
!: 리스트 요소 하나하나가 null이면 안 됨
- 바깥
기타 타입들
Enum
1 2 3 4
enum Role { USER ADMIN }
Input Type (입력용 타입)
1 2 3 4
input CreatePostInput { title: String! content: String }
Interface / Union
- 여러 타입이 공통 필드를 공유하거나, 여러 타입 중 하나가 올 수 있는 구조를 표현할 때 사용.
기본 문법: Query, Mutation, Subscription
Query (읽기)
query: 읽기 전용 작업$userId: 변수(Variable)user(id: $userId): 필드 + 인자(argument)
1
2
3
4
5
6
7
8
9
10
query GetUserWithPosts($userId: ID!) {
user(id: $userId) {
id
name
posts(limit: 5) {
id
title
}
}
}
Mutation (쓰기/변경)
- 상태를 변경하는 작업 (생성, 수정, 삭제 등)
- 응답에서도 변경 결과를 조회할 수 있는 구조를 지정 가능.
1
2
3
4
5
6
7
8
9
10
mutation CreatePost($input: CreatePostInput!) {
createPost(input: $input) {
id
title
author {
id
name
}
}
}
Subscription (실시간)
- WebSocket 등을 이용해 실시간 데이터 스트림을 제공.
- 새 글 생성, 알림, 채팅 메시지 등.
1
2
3
4
5
6
7
8
9
10
subscription OnPostCreated {
postCreated {
id
title
author {
id
name
}
}
}
Fragment와 재사용
중복되는 필드 세트를 재사용할 때 사용이 가능하도록 fragment를 사용할 수 있습니다.
1
2
3
4
5
6
7
8
9
10
11
fragment PostFields on Post {
id
title
content
}
query {
posts {
...PostFields
}
}
스키마 (Schema)와 SDL
서버는 스키마를 SDL(Schema Definition Language)로 정의합니다.
- Query 타입: 읽기용 엔트리 포인트
- Mutation 타입: 쓰기/변경용 엔트리 포인트
- Subscription 타입: (있다면) 실시간 이벤트 엔트리 포인트
이 스키마를 기반으로:
- 클라이언트는 어떤 필드를 요청할 수 있는지 알게됩니다.
- 서버는 요청이 스키마에 맞는지 검증합니다.
추가적으로 graphQL client tool(Altair)에서도 이 정보를 바탕으로 스키마를 가져옵니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
schema {
query: Query
mutation: Mutation
}
type Query {
user(id: ID!): User
users: [User!]!
posts: [Post!]!
}
type Mutation {
createUser(name: String!): User!
createPost(input: CreatePostInput!): Post!
}
type User {
id: ID!
name: String!
posts: [Post!]!
}
type Post {
id: ID!
title: String!
content: String
author: User!
}
input CreatePostInput {
title: String!
content: String
authorId: ID!
}
GraphQL 서버(Server) 개념
GraphQL 서버는 크게 세 가지를 합니다.
- 스키마 정의
- SDL로 타입/쿼리/뮤테이션/서브스크립션 정의.
- 리졸버(Resolver) 구현
- 각 필드에 대해 “이 데이터를 어떻게 가져올 것인가”를 구현.
예시 (pseudo-code):
1 2 3 4 5 6 7 8 9 10
const resolvers = { Query: { user: (_, { id }, { dataSources }) => dataSources.userRepo.findById(id), }, User: { posts: (user, _, { dataSources }) => dataSources.postRepo.findByUserId(user.id), }, }
- 실행 & 검증
- 클라이언트 쿼리를 스키마에 맞춰 검증
- 라우팅/리졸버 호출/결과 합성
서버에서 고려할 것들
- N+1 문제
- 예: users 100명 가져온 후, 각 user의 posts를 개별 쿼리로 호출하는 구조.
DataLoader같은 도구로 batch & cache 처리 필요.
- 권한/보안
- 필드 레벨 권한 체크.
- 클라이언트가 원하는 필드를 마음대로 합성할 수 있기 때문에, 접근 제어, rate-limit, depth-limit 등이 중요.
- 에러 처리
- GraphQL 응답은
data와errors를 동시에 포함할 수 있음.
- GraphQL 응답은
GraphQL 클라이언트(Client) 개념
클라이언트는 “스키마를 활용하는 소비자“입니다.
하는 일들은 아래와 같습니다.
- 쿼리/뮤테이션 정의 & 요청
- GraphQL 쿼리를 문자열 혹은 gql 템플릿으로 정의하고 서버에 전송.
- 캐싱 / 상태 관리
- 응답 데이터를 캐시에 저장해서 다음 렌더에 재사용.
- 요청을 최소화하고 UI 상태와 동기화.
- 정적 분석 & 타입 생성
- 스키마 + 쿼리로부터 TypeScript 타입을 자동 생성.
- 오타나 잘못된 필드를 빌드 타임에 잡을 수 있음.
대표적인 클라이언트 라이브러리:
- Apollo Client
- 상태 관리, 캐싱, 에러 핸들링, DevTools 등 풀스택 지원.
- Relay
- 페이스북에서 만든 클라이언트.
- 매우 강력한 캐싱/성능 최적화, 규칙이 엄격한 편.
Spring + GraphQL 예제
여기서는 Spring Boot 3 + spring-graphql 조합을 기준으로 간단한 예제로 설명합니다.
springboot4 + kotlin sample은 github-sample를 참조해주세요.
의존성 설정 (Gradle 예시)
- gradle.kts
1
2
3
4
5
6
7
dependencies {
implementation("org.springframework.boot:spring-boot-starter-web")
implementation("org.springframework.boot:spring-boot-starter-graphql")
implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
implementation("org.springframework.boot:spring-boot-starter-data-jpa")
runtimeOnly("com.h2database:h2")
}
spring-boot-starter-graphql이 GraphQL HTTP 엔드포인트 + 스키마 실행을 담당합니다.
스키마 정의 (src/main/resources/graphql/schema.graphqls)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
type Query {
users: [User!]!
user(id: ID!): User
posts: [Post!]!
}
type Mutation {
createUser(name: String!): User!
createPost(input: CreatePostInput!): Post!
}
type User {
id: ID!
name: String!
posts: [Post!]!
}
type Post {
id: ID!
title: String!
content: String
author: User!
}
input CreatePostInput {
title: String!
content: String
authorId: ID!
}
Query: 읽기 엔트리 포인트Mutation: 생성/수정/삭제 등 상태 변경User.posts,Post.author는 연관 관계 필드
엔티티 & 리포지토리 (JPA 예시)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
import jakarta.persistence.*
@Entity
class UserEntity(
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Long = 0,
val name: String,
@OneToMany(mappedBy = "author", fetch = FetchType.LAZY)
val posts: MutableList<PostEntity> = mutableListOf()
)
@Entity
class PostEntity(
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Long = 0,
val title: String,
val content: String? = null,
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "author_id")
val author: UserEntity
)
interface UserRepository : JpaRepository<UserEntity, Long>
interface PostRepository : JpaRepository<PostEntity, Long>
GraphQL 리졸버 구현
spring-graphql에서는 보통 다음 애노테이션들을 사용합니다.
@QueryMapping@MutationMapping@SchemaMapping(특정 타입의 필드 리졸버)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
import org.springframework.graphql.data.method.annotation.*
import org.springframework.stereotype.Controller
@Controller
class UserGraphQLController(
private val userRepository: UserRepository,
private val postRepository: PostRepository
) {
@QueryMapping
fun users(): List<UserEntity> =
userRepository.findAll()
@QueryMapping
fun user(@Argument id: Long): UserEntity? =
userRepository.findById(id).orElse(null)
@QueryMapping
fun posts(): List<PostEntity> =
postRepository.findAll()
@MutationMapping
fun createUser(@Argument name: String): UserEntity =
userRepository.save(UserEntity(name = name))
@MutationMapping
fun createPost(@Argument input: CreatePostInput): PostEntity {
val author = userRepository.findById(input.authorId.toLong())
.orElseThrow { IllegalArgumentException("user not found") }
val post = PostEntity(
title = input.title,
content = input.content,
author = author
)
return postRepository.save(post)
}
// User.posts 필드 리졸버
@SchemaMapping(typeName = "User", field = "posts")
fun posts(user: UserEntity): List<PostEntity> =
postRepository.findAllByAuthorId(user.id)
}
data class CreatePostInput(
val title: String,
val content: String?,
val authorId: Long
)
postRepository.findAllByAuthorId 같은 커스텀 메서드는 JPA 메서드 쿼리로 추가합니다.
1
2
3
interface PostRepository : JpaRepository<PostEntity, Long> {
fun findAllByAuthorId(authorId: Long): List<PostEntity>
}
예시 쿼리
1
2
3
4
5
6
7
8
9
10
query {
users {
id
name
posts {
id
title
}
}
}
Response:
1
2
3
4
5
6
7
8
9
10
11
12
13
{
"data": {
"users": [
{
"id": "1",
"name": "Alice",
"posts": [
{ "id": "10", "title": "Hello GraphQL" }
]
}
]
}
}
GraphQL 쿼리 문장 구조와 고급 기능
이 섹션에서는 operation name, arguments, variables, alias, fragments, directives, inline fragments, __typename, meta fields, introspection 등 GraphQL 쿼리를 실제로 작성할 때 자주 쓰이는 개념들을 정리합니다.
Operation Name (작업 이름)
GraphQL 요청은 보통 query, mutation, subscription 중 하나의 operation으로 시작합니다.
이때 해당 operation에 이름을 붙일 수 있는데, 이를 operation name이라고 합니다.
1
2
3
4
5
6
7
8
9
10
query GetUserWithPosts {
user(id: 1) {
id
name
posts {
id
title
}
}
}
GetUserWithPosts가 operation name입니다.- 장점
- 서버/클라이언트 로그, 모니터링에서 어떤 쿼리가 호출됐는지 추적하기 쉬움
- 에러 분석, 성능 분석, APM 도구 연동 시 유용
- Best Practice
- 실제 서비스에서는 거의 항상 operation name을 붙이는 것을 권장합니다.
1
2
3
4
5
6
mutation CreateUserAndPost {
createUser(name: "Alice") {
id
name
}
}
여기서 CreateUserAndPost도 operation name입니다.
Arguments
GraphQL에서 필드는 인자를 받을 수 있습니다.
예를 들어, 특정 ID를 가진 유저를 조회하거나, 페이징 정보를 넘길 수 있습니다.
1
2
3
4
5
6
7
8
9
10
query {
user(id: 1) { # id가 1인 유저
id
name
}
posts(limit: 10) { # 최근 글 10개
id
title
}
}
user(id: 1)에서id: 1이 인자(argument)posts(limit: 10)에서limit: 10이 인자
스키마에서는 다음과 같이 정의될 수 있습니다.
1
2
3
4
type Query {
user(id: ID!): User
posts(limit: Int, offset: Int): [Post!]!
}
Variables
실제 애플리케이션에서는 값(예: ID, 검색어, 페이징 정보)을 쿼리 문자열에 하드코딩하지 않고
변수(variables) 로 분리해서 보내는 것이 일반적입니다.
1
2
3
4
5
6
7
8
9
10
query GetUser($userId: ID!, $limit: Int!) {
user(id: $userId) {
id
name
posts(limit: $limit) {
id
title
}
}
}
변수는 HTTP 요청 body에 따로 JSON 형태로 전달됩니다.
1
2
3
4
5
6
7
{
"query": "query GetUser($userId: ID!, $limit: Int!) { user(id: $userId) { id name posts(limit: $limit) { id title } } }",
"variables": {
"userId": "1",
"limit": 5
}
}
장점:
- 같은 쿼리 구조를 재사용하고, 값만 바꿔서 요청 가능
- 서버 측에서 쿼리 캐싱 및 최적화에 유리
- 보안/로그 관점에서도 민감한 값을 쿼리 문자열에 직접 넣지 않게 됨
Alias
GraphQL은 쿼리 응답 JSON의 키 이름이 필드 이름과 동일합니다.
그런데 같은 필드를 서로 다른 인자로 여러 번 호출해야 할 때, 또는 응답 필드 이름을 바꾸고 싶을 때 alias를 사용합니다.
1
2
3
4
5
6
7
8
9
10
query {
latestPosts: posts(limit: 5) {
id
title
}
popularPosts: posts(orderBy: POPULAR, limit: 5) {
id
title
}
}
응답 예시:
1
2
3
4
5
6
7
8
9
10
{
"data": {
"latestPosts": [
{ "id": "10", "title": "최신 글 1" }
],
"popularPosts": [
{ "id": "42", "title": "인기 글 1" }
]
}
}
latestPosts,popularPosts는 alias- 실제 스키마의 필드 이름은
posts하나뿐이지만,
alias를 통해 응답에서 서로 다른 키로 구분할 수 있습니다.
Fragments
서로 다른 쿼리에서 공통되는 필드 묶음이 있다면 fragment로 재사용할 수 있습니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
fragment UserBasicFields on User {
id
name
}
query GetUserAndAuthor($userId: ID!, $postId: ID!) {
user(id: $userId) {
...UserBasicFields
}
post(id: $postId) {
id
title
author {
...UserBasicFields
}
}
}
fragment UserBasicFields on User:User타입에 붙일 수 있는 필드 묶음 정의...UserBasicFields: 해당 fragment를 펼쳐서 사용하는 부분
장점:
- 중복 제거
- API 스펙 변경 시, fragment만 수정하면 여러 쿼리에서 반영 가능
Directives (지시자) – @include, @skip
Directive는 쿼리 실행 시 조건부로 필드를 포함/제외하는 등의 제어를 할 수 있게 해줍니다.
GraphQL 스펙에 기본으로 포함된 디렉티브:
@include(if: Boolean!)@skip(if: Boolean!)
예시:
1
2
3
4
5
6
7
8
9
10
11
12
13
query GetUser(
$id: ID!
$withPosts: Boolean! = false
) {
user(id: $id) {
id
name
posts @include(if: $withPosts) {
id
title
}
}
}
변수 예시:
1
2
3
4
{
"id": "1",
"withPosts": true
}
$withPosts가true이면posts필드 포함false이면posts필드는 아예 요청하지 않음
이 외에도 서버/라이브러리에서 커스텀 디렉티브를 정의해
권한 검사, 로깅, 캐싱 힌트 등을 구현하기도 합니다.
Inline Fragments
Union/Interface 타입처럼 “실제 런타임 타입이 여러 개 중 하나일 수 있는” 경우,
타입별로 다른 필드를 요청하고 싶을 때 inline fragment를 사용합니다.
스키마 예시:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
interface SearchResult {
id: ID!
}
type User implements SearchResult {
id: ID!
name: String!
}
type Post implements SearchResult {
id: ID!
title: String!
}
type Query {
search(keyword: String!): [SearchResult!]!
}
쿼리 예시:
1
2
3
4
5
6
7
8
9
10
11
12
query Search($keyword: String!) {
search(keyword: $keyword) {
__typename
id
... on User {
name
}
... on Post {
title
}
}
}
... on User { name }: 실제 타입이User인 경우에만name요청... on Post { title }: 실제 타입이Post인 경우에만title요청- 이때
__typename을 함께 요청하면, 런타임에서 어떤 타입이 왔는지 구분 가능
__typename 과 Meta Fields
__typename은 GraphQL에서 모든 타입에 공통으로 존재하는 메타 필드입니다.
1
2
3
4
5
6
7
query {
user(id: 1) {
__typename
id
name
}
}
응답 예시:
1
2
3
4
5
6
7
8
9
{
"data": {
"user": {
"__typename": "User",
"id": "1",
"name": "Alice"
}
}
}
주요 용도:
- 캐싱 / Normalization (예: Apollo Client, Relay)
- 캐시 키를
__typename + id조합으로 쓰는 경우가 많음
- 캐시 키를
- Union/Interface 처리 시, 실제 타입 식별
- 클라이언트 코드에서 타입 별 분기 처리
이 밖에도 스키마 introspection 관련해서 사용되는 __schema, __type 같은 메타 필드들이 있습니다. (아래 introspection 파트 참고)
Introspection
Introspection은 GraphQL 스키마 자체를 쿼리로 조회할 수 있는 기능입니다.
즉, “이 서버에 어떤 타입과 필드가 있는지”를 GraphQL 쿼리로 물어볼 수 있습니다.
대표적인 예:
1
2
3
4
5
6
7
8
query IntrospectionExample {
__schema {
types {
name
kind
}
}
}
또는 특정 타입에 대한 정보:
1
2
3
4
5
6
7
8
9
10
11
12
13
query GetUserTypeInfo {
__type(name: "User") {
name
kind
fields {
name
type {
name
kind
}
}
}
}
이 기능 덕분에:
- GraphiQL, Apollo Sandbox, Playground 같은 IDE 도구에서 자동완성/문서를 제공할 수 있고
graphql-code-generator같은 도구들이 스키마 정보를 바탕으로 TypeScript 타입을 자동 생성할 수 있습니다.
보안 상의 이유로, 운영 환경에서 introspection을 부분적으로 차단하거나 제한하는 설정을 두는 경우도 있습니다.
(예: 비공개 필드를 노출시키지 않도록, 특정 사용자는 introspection 쿼리를 막는 등)
종합 예제
아래는 operation name, variables, arguments, alias, fragment, directive, inline fragment, __typename 등을 한 번에 사용하는 예시입니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
query SearchPage(
$keyword: String!
$userLimit: Int! = 5
$postLimit: Int! = 10
$includeFollowers: Boolean! = false
) {
userResults: searchUsers(keyword: $keyword, limit: $userLimit) {
...UserListItem
}
postResults: searchPosts(keyword: $keyword, limit: $postLimit) {
...PostListItem
}
mixedResults: search(keyword: $keyword) {
__typename
... on User {
id
name
followers @include(if: $includeFollowers) {
id
name
}
}
... on Post {
id
title
author {
id
name
}
}
}
}
fragment UserListItem on User {
id
name
}
fragment PostListItem on Post {
id
title
author {
id
name
}
}
이 쿼리에는 다음 요소들이 모두 포함되어 있습니다.
SearchPage-> operation name$keyword,$userLimit,$postLimit,$includeFollowers-> variablessearchUsers(keyword: $keyword, limit: $userLimit)-> argumentsuserResults,postResults-> alias...UserListItem,...PostListItem-> fragmentsfollowers @include(if: $includeFollowers)-> directive... on User,... on Post-> inline fragments__typename-> meta field
이런 기능들을 조합하면, 클라이언트는 하나의 쿼리로 화면에 필요한 데이터를 매우 유연하게 구성할 수 있습니다.
스키마 / 리졸버 / 미들웨어 / AST 심화
앞에서 개념적으로 스키마와 리졸버를 다뤘지만, 여기서는 “실제로 GraphQL이 어떻게 실행되는지” 관점에서 스키마, 리졸버, 미들웨어, AST를 한 번에 정리합니다.
GraphQL 실행 파이프라인 개요
일반적인 GraphQL 요청의 흐름은 아래와 같습니다.
- HTTP 레벨
- 클라이언트가
/graphql엔드포인트로 요청 (query,variables,operationName포함)
- 클라이언트가
- 파싱 (Parsing)
- 쿼리 문자열을 AST(Abstract Syntax Tree) 로 변환
- 검증 (Validation)
- AST가 스키마와 맞는지(존재하는 타입/필드인지, 타입이 맞는지 등) 검증
- 실행 (Execution)
- 루트 타입(
Query,Mutation,Subscription)부터 시작해서, - 각 필드에 해당하는 리졸버 함수를 호출하면서 트리를 따라 내려감
- 루트 타입(
- 결과 합성 (Result Building)
- 각 필드 리졸버가 리턴한 값을 모아서, 요청 쿼리 구조에 맞는 JSON을 구성
- 에러 처리 / 미들웨어 / 플러그인
- 실행 중 발생한 에러를 수집하고, 미들웨어/플러그인에서 로깅, 마스킹, 포맷팅 등 수행
이 과정의 핵심 요소가 스키마, 리졸버, AST, 그리고 이를 둘러싼 미들웨어/플러그인입니다.
스키마 (Schema) 심화
이미 기본적인 스키마 정의(Schema Definition Language)를 봤지만, 실행 관점에서 보면 스키마는 다음 역할을 합니다.
- 타입 시스템
- 어떤 타입(
User,Post,Query,Mutation등)이 있는지 정의 - 각 타입이 어떤 필드를 가지며, 필드 타입과 인자 타입이 무엇인지 정의
- 어떤 타입(
- 엔트리 포인트
schema { query: Query, mutation: Mutation, subscription: Subscription }- 실제 실행 시 루트가 되는 타입들
- 검증 기준
- 쿼리 AST를 검증할 때 “이 필드가 스키마에 존재하는가?”, “인자의 타입이 맞는가?” 등 기준 제공
- 도구/IDE의 기반
- GraphiQL, Apollo Studio, Relay DevTools 등이 자동완성과 문서화, lint를 제공하는 기준
Node (graphql-js)에서 실행 가능한 스키마를 코드로 직접 만들 수도 있습니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import { buildSchema, graphql } from "graphql";
const schema = buildSchema(`
type Query {
hello: String
}
`);
const rootValue = {
hello: () => "Hello world!",
};
graphql({
schema,
source: "{ hello }",
rootValue,
}).then((response) => {
console.log(response);
});
buildSchema가 SDL 문자열을GraphQLSchema객체로 파싱- 이후
graphql함수는source(쿼리)를 파싱 -> 검증 -> 실행
Spring에서는 schema.graphqls를 리소스로 두면, spring-graphql이 이를 읽어서 내부적으로 GraphQLSchema 객체를 구성합니다.
리졸버 (Resolver) 심화
리졸버는 “특정 필드의 값을 어떻게 가져올 것인가” 를 정의하는 함수입니다.
리졸버 시그니처 (Node, graphql-js)
graphql-js 기준, 리졸버의 시그니처는 보통 다음과 같습니다.
1
(fieldParent, args, context, info) => any
fieldParent(또는parent,root)- 상위 필드의 리턴값
- 예:
Query.user리졸버에서User를 리턴하면,User.posts리졸버의fieldParent는 그User객체
args- 필드에 전달된 인자(Arguments)
- 예:
user(id: 1)->{ id: 1 }
context- 요청 전체에서 공유되는 컨텍스트 (로그인 유저, 데이터소스, 트랜잭션 등)
info- 필드/쿼리 관련 메타 정보 (AST 노드, path, returnType 등)
예시:
1
2
3
4
5
6
7
8
9
10
11
12
13
const resolvers = {
Query: {
user: async (_, args, ctx) => {
const { id } = args;
return ctx.dataSources.userRepo.findById(id);
},
},
User: {
posts: (user, args, ctx) => {
return ctx.dataSources.postRepo.findByUserId(user.id, args.limit);
},
},
};
리졸버 체인 & 병렬 실행
- GraphQL 실행기는 같은 레벨(동일한 부모 필드의 하위 필드들)을 가능한 한 병렬로 실행합니다.
- 예를 들어,
user { posts { title }, followers { name } }의 경우posts와followers리졸버는 서로 독립이므로 동시에 실행될 수 있습니다.
- 이를 잘 활용하면 병렬 I/O로 성능 최적화가 가능하지만,
- DB 커넥션/쿼리 수, rate limit 등도 함께 고려해야 합니다.
Spring에서의 리졸버
Spring에서는 애노테이션 기반으로 리졸버를 구성합니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
@Controller
class UserGraphQLController(
private val userRepository: UserRepository
) {
@QueryMapping
fun user(@Argument id: Long): UserEntity? =
userRepository.findById(id).orElse(null)
@SchemaMapping(typeName = "User", field = "posts")
fun posts(user: UserEntity): List<PostEntity> =
postRepository.findAllByAuthorId(user.id)
}
@QueryMapping->Query.user@SchemaMapping(typeName = "User", field = "posts")->User.posts리졸버
미들웨어 / 플러그인 / 인터셉터
GraphQL 스펙 자체에는 “미들웨어”라는 개념이 명시돼 있지는 않지만,
실제 구현체에서는 아래와 같은 형태로 미들웨어/플러그인/인터셉터를 제공합니다.
Apollo Server 미들웨어/플러그인 예시
Apollo Server에서는 플러그인을 통해 요청 전/후, 응답 전/후에 훅을 걸 수 있습니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import { ApolloServer } from "@apollo/server";
const loggingPlugin = {
async requestDidStart(requestContext) {
console.log("Incoming GraphQL request:", requestContext.request.operationName);
return {
async willSendResponse(ctx) {
console.log("Outgoing response:", ctx.response.http?.status || 200);
},
};
},
};
const server = new ApolloServer({
typeDefs,
resolvers,
plugins: [loggingPlugin],
});
사용 사례:
- 요청/응답 로깅
- 성능 측정 (타이머)
- 권한 체크 (context 세팅 시점에)
- 에러 포맷팅/마스킹
Express 레벨 미들웨어
GraphQL 이전 단계에서 Express/Koa 미들웨어로도 공통 처리를 합니다.
1
2
3
app.use(cors());
app.use(authMiddleware); // JWT 검증, 유저 식별
app.use("/graphql", bodyParser.json(), expressMiddleware(server, { context: ... }));
Spring에서의 인터셉터/Instrumentations
Spring + GraphQL에서는 다음과 같은 레벨에서 미들웨어/인터셉터를 둘 수 있습니다.
- 웹 레벨
HandlerInterceptor로 HTTP 요청/응답 공통 처리 (로깅, 인증, 트레이싱 등)
- GraphQL 레벨
GraphQlSourceBuilderCustomizer를 사용해Instrumentation등록Instrumentation을 이용하면 GraphQL 실행 전/후, 각 필드 실행 전/후에 훅을 걸 수 있음
예 (Kotlin, 단순 예시 pseudo code):
1
2
3
4
5
6
7
8
@Bean
fun loggingInstrumentation(): Instrumentation =
object : SimpleInstrumentation() {
override fun beginExecution(parameters: InstrumentationExecutionParameters): InstrumentationContext<ExecutionResult> {
println("GraphQL execution started: ${parameters.operation}")
return super.beginExecution(parameters)
}
}
이렇게 하면 쿼리 실행에 대한 로깅/트레이싱, 복잡도 측정, rate limit 등 다양한 공통 로직을 삽입할 수 있습니다.
AST (Abstract Syntax Tree)
AST는 쿼리 문자열을 구조화한 트리 형태의 표현입니다.
왜 AST가 중요한가
- 검증(Validation): 스키마와 비교해 잘못된 필드/타입을 찾을 수 있음
- 분석/최적화: 쿼리의 복잡도, 깊이, 요청된 필드 목록 등을 분석해 제한/최적화
- 도구 제작: Lint, Formatter, Query Analyzer, IDE 자동완성 등
graphql-js에서 AST 사용 예시
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import { parse, visit } from "graphql";
const source = `
query GetUser($id: ID!) {
user(id: $id) {
id
name
posts {
id
title
}
}
}
`;
const ast = parse(source);
// 단순히 어떤 필드들이 요청됐는지 출력
visit(ast, {
Field(node) {
console.log("Field:", node.name.value);
},
});
출력 예시:
1
2
3
4
5
6
Field: user
Field: id
Field: name
Field: posts
Field: id
Field: title
이런 방식으로:
- “쿼리 깊이(depth)가 10을 넘으면 차단”
- “특정 필드는 로그를 남기기”
- “특정 디렉티브가 붙은 필드는 별도의 처리”
같은 규칙을 구현할 수 있습니다.
쿼리 복잡도(Complexity) 계산
AST를 이용해 쿼리 복잡도를 정량화하고 제한할 수 있습니다.
- 각 필드에 가중치 부여 (예: DB 조회 1회 = 1점, 연관 컬렉션 = 5점 등)
- AST를 순회하면서 총점을 계산
- 특정 threshold 이상이면 실행 전 차단
이런 메커니즘은 악의적인 쿼리(무한히 깊은 nested 쿼리)나
리소스 엄청 먹는 쿼리를 방지하는 데 중요합니다.
요약
- 스키마(Schema)
- 타입 시스템과 엔트리 포인트를 정의하며, 검증/도구/문서화의 기준이 됨
- 리졸버(Resolver)
- 각 필드의 실제 데이터를 어떻게 가져올지를 구현하는 함수
(parent, args, context, info)시그니처를 가지며, 체인/병렬 실행이 가능
- 미들웨어/플러그인/인터셉터
- GraphQL 전/후, 필드 실행 전/후에 공통 로직을 삽입
- 로깅, 인증, 권한, 복잡도 제한, 트레이싱 등에 사용
- AST
- 쿼리 문자열의 구조화된 표현으로, 검증/분석/최적화/도구 제작의 핵심
parse+visit패턴으로 손쉽게 조작 가능
이제 이 문서에는 스키마, 리졸버, 미들웨어(플러그인/인터셉터), AST 까지 포함해서
실제 GraphQL 서버가 요청을 처리하는 전체 그림을 볼 수 있습니다.
실제 REST -> GraphQL 마이그레이션 전략
실제 서비스에선 보통 REST를 한번에 버리고 GraphQL로 갈아타지 않습니다. 대부분은 기존 REST + 새로운 GraphQL 레이어를 공존시키고, 점진적으로 옮겨갑니다.
전형적인 기존 REST API 예시
예를 들어, 기존에 이런 API들이 있다고 해본다면,
GET /api/users/:idGET /api/users/:id/postsGET /api/posts/:idPOST /api/usersPOST /api/posts
모바일 앱에서 “사용자 정보 + 최근 글 5개”를 보여주고 싶다면:
GET /api/users/1GET /api/users/1/posts?limit=5
두 번 호출해야 합니다.
GraphQL로의 목표 상태
GraphQL에서는 이렇게 한 번에 가져올 수 있습니다.
1
2
3
4
5
6
7
8
9
10
query GetUserWithPosts {
user(id: 1) {
id
name
posts(limit: 5) {
id
title
}
}
}
마이그레이션 단계 전략
1. BFF / Gateway 패턴으로 GraphQL 레이어 추가
- 기존 REST API는 그대로 두고,
- 그 앞에 GraphQL Gateway를 둡니다.
- GraphQL 서버의 리졸버는 내부적으로 기존 REST API를 호출하여 데이터를 가져옵니다.
예: Node + Apollo 기준
1
2
3
4
5
6
7
8
9
10
11
12
13
const resolvers = {
Query: {
user: async (_, { id }, { dataSources }) => {
// 기존 REST API 호출
return dataSources.userAPI.getUserById(id);
},
},
User: {
posts: async (user, { limit }, { dataSources }) => {
return dataSources.postAPI.getPostsByUser(user.id, limit);
},
},
};
이 방식의 장점:
- 기존 백엔드(REST) 변경 최소화.
- 클라이언트는 새로운 GraphQL 엔드포인트만 사용.
- 내부적으로 여러 REST 호출을 GraphQL 서버에서 조합 -> 클라이언트는 단일 호출.
2. 점진적으로 Domain 단위로 GraphQL 네이티브 구현
처음에는 모든 리졸버가 REST 프록시처럼 동작하지만,
점점 도메인별로 다음과 같이 변경할 수 있습니다.
- User 도메인 리졸버는 직접 DB/레포지토리 접근
- Post 도메인도 마찬가지
- REST API는 차차 deprecated 처리 후 클라이언트 호환성 확인 후 제거
이런 식으로 REST -> GraphQL 네이티브 구현 비율을 늘려갑니다.
3. 스키마 설계 기준: 클라이언트 Use Case 우선
REST는 리소스 중심(예: /users, /posts)으로 설계했다면, GraphQL 스키마는 화면/도메인 Use Case 중심으로 설계하는게 훨씬 효율적입니다.
예:
- “마이페이지 화면에 필요한 데이터”
- 내 프로필, 알림 개수, 최근 주문 내역, 포인트
- 이를 위해 REST에서는 여러 엔드포인트 조합이 필요했지만,
- GraphQL에서는
meQuery 하나로 모두 가져오도록 설계할 수 있습니다.
1
2
3
4
5
6
7
8
9
10
type Query {
me: MeResponse!
}
type MeResponse {
profile: User!
notifications: [Notification!]!
recentOrders: [Order!]!
pointBalance: Int!
}
4. 인증/권한, 모니터링, N+1 이슈 같이 설계
마이그레이션 할 때 함께 고려해야 할 것들:
- N+1 쿼리 방지
- 예:
users100명 + 각posts호출 -> DB 101번 쿼리 DataLoader, batch API 등으로 묶어서 처리
- 예:
- 필드 레벨 권한 제어
- 예:
User.email은 본인만 노출
- 예:
- 쿼리 복잡도 제한
- 너무 깊은 nested 쿼리 방지, rate limit, depth limit, 비용 계산 등
- 로깅/모니터링
- 어떤 쿼리가 가장 많이 호출되는지, 응답 시간은 어떤지 등
마이그레이션 예시: REST -> GraphQL
기존 REST:
GET /users/:idGET /users/:id/postsGET /users/:id/followers
GraphQL 스키마:
1
2
3
4
5
6
7
8
9
10
type Query {
user(id: ID!): User
}
type User {
id: ID!
name: String!
posts(limit: Int): [Post!]!
followers(limit: Int): [User!]!
}
리졸버는 처음엔 기존 REST 호출을 조합해서 구현합니다.
1
2
3
4
5
6
7
8
9
10
11
12
const resolvers = {
Query: {
user: (_, { id }, { restClient }) =>
restClient.get(`/users/${id}`),
},
User: {
posts: (user, { limit }, { restClient }) =>
restClient.get(`/users/${user.id}/posts`, { params: { limit } }),
followers: (user, { limit }, { restClient }) =>
restClient.get(`/users/${user.id}/followers`, { params: { limit } }),
},
};
이렇게 GraphQL을 API Aggregator로 먼저 활용한 뒤, 점차 REST/DB 구조를 GraphQL 중심으로 재정비해 나가는 패턴이 일반적입니다.
GraphQL 도입이 과한 케이스 / 적합한 케이스 비교
GraphQL은 강력하지만 모든 API 문제의 해답은 아닙니다. 도입하기 전에 “진짜 이 복잡도를 감당할 만큼 이점이 있는가?”를 보는 게 중요합니다.
적합한 케이스 (도입 추천)
- 클라이언트가 다양하고 복잡한 데이터 조합이 필요한 경우
- 웹, iOS, Android, 3rd-party 파트너 등 여러 클라이언트가 있는 B2C 서비스
- 각 클라이언트가 화면마다 필요한 필드가 조금씩 다름
- 예: SNS, 전자상거래 플랫폼, B2C 대형 서비스
- 모바일 / 저대역폭 환경에서 Over-fetching 문제가 심각한 경우
- 화면 하나에 여러 리소스를 그려야 하는데, REST 조합으로 API 호출이 너무 많음
- GraphQL로 한 번의 요청에 필요한 데이터만 딱 가져오는 구조가 큰 이점
- 클라이언트/서버 모두 타입 기반 개발을 하고 싶은 경우
- TypeScript + GraphQL Codegen
- Kotlin + GraphQL Schema -> DTO 생성
- “스키마 = 단일 소스 오브 트루스(SSOT)”로 사용하고 싶은 조직
- BFF 레이어에서 여러 마이크로서비스 데이터를 모아서 내려줘야 하는 경우
- 10개 이상의 마이크로서비스가 있고,
- 클라이언트는 “주문 내역 + 상품 정보 + 배송 상태 + 쿠폰 정보” 등 여러 서비스 조합 데이터를 한 번에 필요로 하는 상황
- GraphQL Gateway/BFF로 Aggregation 하면 클라이언트 구현과 API 설계가 크게 단순해짐
도입이 과한(또는 부적절한) 케이스
- 단순한 Admin/백오피스용 API
- 화면도 단순하고, REST로도 충분히 표현 가능
- 클라이언트 종류도 거의 1개 (내부 웹 어드민)
- GraphQL 스키마/리졸버/N+1/권한/캐싱 등 고려할 게 오히려 더 많아짐
- 파일 업로드/다운로드, 스트리밍이 메인인 서비스
- 동영상 스트리밍, 대용량 파일 처리 등
- 어차피 S3 presigned URL 등 별도 메커니즘이 필요
- GraphQL은 메타데이터 교환용으로만 쓰고, 실제 데이터는 다른 경로로 가는 경우가 많음
- 이런 서비스는 REST + gRPC 조합이 더 자연스러운 경우도 많다.
- 단일 도메인, CRUD 위주, 복잡한 조합이 없는 서비스
- 예: 사내 단일 도메인 서비스 (단순 출퇴근 기록, 단순 재고 관리 시스템 등)
- “그냥
GET/POST/PUT/DELETE정도면 끝나는” 서비스 - GraphQL 도입은 팀에 학습 비용 + 운영 복잡도만 늘릴 위험
- 팀의 숙련도가 낮고, 기본적인 REST도 아직 정착이 안 된 경우
- REST 설계, 도메인 설계, 캐싱, 권한, 모니터링 등도 아직 혼란스러운 상태라면
- GraphQL까지 같이 들이붓는 것은 팀 복잡도 폭발의 지름길
- 차라리 REST부터 잘 설계하는 것이 장기적으로 유리
언제 쓰고, 언제 미루나요?
| 상황/특징 | GraphQL 도입 판단 |
|---|---|
| 다수의 클라이언트 + 복잡한 데이터 조합 | 적극 고려 |
| 모바일/저대역폭 환경에서 Over-fetching 심각 | 적극 고려 |
| BFF/Gateway에서 여러 서비스 Aggregation 필요 | 적극 고려 |
| 단순 CRUD + 내부 관리자용 화면 위주 | 보류/불필요 가능 |
| 파일/스트리밍 중심 서비스 | 부분 도입 or REST/gRPC 우선 |
| 팀 GraphQL 경험 전무, REST도 미성숙 | 우선 REST 정비 |
결론
- GraphQL은 API를 위한 타입 기반 쿼리 언어이며,
- 페이스북이 모바일 환경에서 REST의 한계(Over/Under-fetching, 버전 관리 등) 를 해결하기 위해 만들었습니다.
- 클라이언트가 필요한 데이터 구조를 직접 정의하는 방식으로,
- 단일 엔드포인트에서 다양한 데이터 조합을 한 번에 가져올 수 있습니다.
- 스키마/타입 시스템을 기반으로:
- 자동 문서화, 자동완성, 정적 분석, 타입 생성 등 (Developer Experience)가 뛰어납니다.
- 대신:
- 서버 구현 복잡도, 성능 최적화(N+1), 권한/보안, 캐싱 전략 등을 신경 써야 합니다.
