Post

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의 문제점을 보완합니다.

  1. Over-fetching (데이터를 너무 많이 가져옴)
    • 화면은 id, name만 필요하지만, GET /users/1 응답에는 수십 개 필드 포함.
    • 모바일 네트워크 환경에서는 불필요한 데이터 전송 비용 + 느린 응답.
  2. Under-fetching (필요한 데이터를 다 못 가져옴)
    • 한 화면에 사용자 정보 + 최근 글 + 친구 리스트가 필요.
    • REST에서는:
      • GET /users/1
      • GET /users/1/posts
      • GET /users/1/friends
    • 여러 번 API 호출이 필요 -> 왕복 횟수 증가, 복잡한 API 조합 로직이 클라이언트에 생김.
  3. 버전 관리의 어려움
    • v1, v2 엔드포인트를 나누다가 어느 순간 버전 지옥.
    • 과거 클라이언트 호환 때문에 오래된 버전도 계속 유지해야 함.

GraphQL의 목적과 특징을 요약하자면 아래와 같이 정리할 수 있습니다.

  1. 클라이언트 중심 데이터 패칭
    • 클라이언트(웹/모바일)가 화면에 필요한 데이터 구조를 직접 정의.
  2. Over/Under-fetching 해소
    • 딱 요청한 필드만 내려준다.
    • 여러 리소스를 한 번의 요청으로 조합해서 가져온다.
  3. 명확한 스키마 기반 계약(Contract)
    • 타입 시스템으로 서버–클라이언트 간 계약을 명시.
    • 자동 문서화, 자동 완성, 정적 분석 도구 활용 가능.
  4. 유연한 진화
    • 스키마에 필드를 추가하는 방식으로 비파괴적 변화를 쉽게 가져갈 수 있음.
    • 버전 번호(v1, v2…) 대신 필드 수준에서 진화.

REST와 차이점 요약

REST와 차이점을 요약해보겠습니다.

구조적 차이

항목RESTGraphQL
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 서버는 크게 세 가지를 합니다.

  1. 스키마 정의
    • SDL로 타입/쿼리/뮤테이션/서브스크립션 정의.
  2. 리졸버(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),
        },
      }
      
  3. 실행 & 검증
    • 클라이언트 쿼리를 스키마에 맞춰 검증
    • 라우팅/리졸버 호출/결과 합성

서버에서 고려할 것들

  • N+1 문제
    • 예: users 100명 가져온 후, 각 user의 posts를 개별 쿼리로 호출하는 구조.
    • DataLoader 같은 도구로 batch & cache 처리 필요.
  • 권한/보안
    • 필드 레벨 권한 체크.
    • 클라이언트가 원하는 필드를 마음대로 합성할 수 있기 때문에, 접근 제어, rate-limit, depth-limit 등이 중요.
  • 에러 처리
    • GraphQL 응답은 dataerrors를 동시에 포함할 수 있음.

GraphQL 클라이언트(Client) 개념

클라이언트는 “스키마를 활용하는 소비자“입니다.

하는 일들은 아래와 같습니다.

  1. 쿼리/뮤테이션 정의 & 요청
    • GraphQL 쿼리를 문자열 혹은 gql 템플릿으로 정의하고 서버에 전송.
  2. 캐싱 / 상태 관리
    • 응답 데이터를 캐시에 저장해서 다음 렌더에 재사용.
    • 요청을 최소화하고 UI 상태와 동기화.
  3. 정적 분석 & 타입 생성
    • 스키마 + 쿼리로부터 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-graphqlGraphQL 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
    }
  }
}
  • GetUserWithPostsoperation 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, popularPostsalias
  • 실제 스키마의 필드 이름은 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
}
  • $withPoststrue이면 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 -> variables
  • searchUsers(keyword: $keyword, limit: $userLimit) -> arguments
  • userResults, postResults -> alias
  • ...UserListItem, ...PostListItem -> fragments
  • followers @include(if: $includeFollowers) -> directive
  • ... on User, ... on Post -> inline fragments
  • __typename -> meta field

이런 기능들을 조합하면, 클라이언트는 하나의 쿼리로 화면에 필요한 데이터를 매우 유연하게 구성할 수 있습니다.

스키마 / 리졸버 / 미들웨어 / AST 심화

앞에서 개념적으로 스키마와 리졸버를 다뤘지만, 여기서는 “실제로 GraphQL이 어떻게 실행되는지” 관점에서 스키마, 리졸버, 미들웨어, AST를 한 번에 정리합니다.

GraphQL 실행 파이프라인 개요

일반적인 GraphQL 요청의 흐름은 아래와 같습니다.

  1. HTTP 레벨
    • 클라이언트가 /graphql 엔드포인트로 요청 (query, variables, operationName 포함)
  2. 파싱 (Parsing)
    • 쿼리 문자열을 AST(Abstract Syntax Tree) 로 변환
  3. 검증 (Validation)
    • AST가 스키마와 맞는지(존재하는 타입/필드인지, 타입이 맞는지 등) 검증
  4. 실행 (Execution)
    • 루트 타입(Query, Mutation, Subscription)부터 시작해서,
    • 각 필드에 해당하는 리졸버 함수를 호출하면서 트리를 따라 내려감
  5. 결과 합성 (Result Building)
    • 각 필드 리졸버가 리턴한 값을 모아서, 요청 쿼리 구조에 맞는 JSON을 구성
  6. 에러 처리 / 미들웨어 / 플러그인
    • 실행 중 발생한 에러를 수집하고, 미들웨어/플러그인에서 로깅, 마스킹, 포맷팅 등 수행

이 과정의 핵심 요소가 스키마, 리졸버, AST, 그리고 이를 둘러싼 미들웨어/플러그인입니다.

스키마 (Schema) 심화

이미 기본적인 스키마 정의(Schema Definition Language)를 봤지만, 실행 관점에서 보면 스키마는 다음 역할을 합니다.

  1. 타입 시스템
    • 어떤 타입(User, Post, Query, Mutation 등)이 있는지 정의
    • 각 타입이 어떤 필드를 가지며, 필드 타입과 인자 타입이 무엇인지 정의
  2. 엔트리 포인트
    • schema { query: Query, mutation: Mutation, subscription: Subscription }
    • 실제 실행 시 루트가 되는 타입들
  3. 검증 기준
    • 쿼리 AST를 검증할 때 “이 필드가 스키마에 존재하는가?”, “인자의 타입이 맞는가?” 등 기준 제공
  4. 도구/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 } }의 경우
    • postsfollowers 리졸버는 서로 독립이므로 동시에 실행될 수 있습니다.
  • 이를 잘 활용하면 병렬 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에서는 다음과 같은 레벨에서 미들웨어/인터셉터를 둘 수 있습니다.

  1. 웹 레벨
    • HandlerInterceptor 로 HTTP 요청/응답 공통 처리 (로깅, 인증, 트레이싱 등)
  2. 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/:id
  • GET /api/users/:id/posts
  • GET /api/posts/:id
  • POST /api/users
  • POST /api/posts

모바일 앱에서 “사용자 정보 + 최근 글 5개”를 보여주고 싶다면:

  1. GET /api/users/1
  2. GET /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에서는 me Query 하나로 모두 가져오도록 설계할 수 있습니다.
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 쿼리 방지
    • 예: users 100명 + 각 posts 호출 -> DB 101번 쿼리
    • DataLoader, batch API 등으로 묶어서 처리
  • 필드 레벨 권한 제어
    • 예: User.email은 본인만 노출
  • 쿼리 복잡도 제한
    • 너무 깊은 nested 쿼리 방지, rate limit, depth limit, 비용 계산 등
  • 로깅/모니터링
    • 어떤 쿼리가 가장 많이 호출되는지, 응답 시간은 어떤지 등

마이그레이션 예시: REST -> GraphQL

기존 REST:

  • GET /users/:id
  • GET /users/:id/posts
  • GET /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 문제의 해답은 아닙니다. 도입하기 전에 “진짜 이 복잡도를 감당할 만큼 이점이 있는가?”를 보는 게 중요합니다.

적합한 케이스 (도입 추천)

  1. 클라이언트가 다양하고 복잡한 데이터 조합이 필요한 경우
    • 웹, iOS, Android, 3rd-party 파트너 등 여러 클라이언트가 있는 B2C 서비스
    • 각 클라이언트가 화면마다 필요한 필드가 조금씩 다름
    • 예: SNS, 전자상거래 플랫폼, B2C 대형 서비스
  2. 모바일 / 저대역폭 환경에서 Over-fetching 문제가 심각한 경우
    • 화면 하나에 여러 리소스를 그려야 하는데, REST 조합으로 API 호출이 너무 많음
    • GraphQL로 한 번의 요청에 필요한 데이터만 딱 가져오는 구조가 큰 이점
  3. 클라이언트/서버 모두 타입 기반 개발을 하고 싶은 경우
    • TypeScript + GraphQL Codegen
    • Kotlin + GraphQL Schema -> DTO 생성
    • “스키마 = 단일 소스 오브 트루스(SSOT)”로 사용하고 싶은 조직
  4. BFF 레이어에서 여러 마이크로서비스 데이터를 모아서 내려줘야 하는 경우
    • 10개 이상의 마이크로서비스가 있고,
    • 클라이언트는 “주문 내역 + 상품 정보 + 배송 상태 + 쿠폰 정보” 등 여러 서비스 조합 데이터를 한 번에 필요로 하는 상황
    • GraphQL Gateway/BFF로 Aggregation 하면 클라이언트 구현과 API 설계가 크게 단순해짐

도입이 과한(또는 부적절한) 케이스

  1. 단순한 Admin/백오피스용 API
    • 화면도 단순하고, REST로도 충분히 표현 가능
    • 클라이언트 종류도 거의 1개 (내부 웹 어드민)
    • GraphQL 스키마/리졸버/N+1/권한/캐싱 등 고려할 게 오히려 더 많아짐
  2. 파일 업로드/다운로드, 스트리밍이 메인인 서비스
    • 동영상 스트리밍, 대용량 파일 처리 등
    • 어차피 S3 presigned URL 등 별도 메커니즘이 필요
    • GraphQL은 메타데이터 교환용으로만 쓰고, 실제 데이터는 다른 경로로 가는 경우가 많음
    • 이런 서비스는 REST + gRPC 조합이 더 자연스러운 경우도 많다.
  3. 단일 도메인, CRUD 위주, 복잡한 조합이 없는 서비스
    • 예: 사내 단일 도메인 서비스 (단순 출퇴근 기록, 단순 재고 관리 시스템 등)
    • “그냥 GET/POST/PUT/DELETE 정도면 끝나는” 서비스
    • GraphQL 도입은 팀에 학습 비용 + 운영 복잡도만 늘릴 위험
  4. 팀의 숙련도가 낮고, 기본적인 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), 권한/보안, 캐싱 전략 등을 신경 써야 합니다.
This post is licensed under CC BY 4.0 by the author.