이번 시리즈에서는 도서 관리 GraphQL API를 처음부터 만든다. 첫 편에서는 Spring Boot 프로젝트를 생성하고, GraphQL 스키마를 작성한다.

예시 프로젝트 소개

만들 프로젝트는 BookStore GraphQL API다. 책과 저자를 관리하는 단순한 CRUD API지만, GraphQL의 핵심 기능을 전부 다룬다.

기술 스택

  • Spring Boot 3.x, Java 17
  • Spring GraphQL + GraphiQL
  • Spring Data JPA + H2 (인메모리 DB)
  • Lombok

핵심 기능

  • 도서/저자 조회, 검색, 생성, 수정, 삭제 (Query + Mutation)
  • N+1 문제 해결 (@BatchMapping)
  • 실시간 알림 (Subscription + WebSocket)
  • 전역 예외 처리

패키지 구조

com.codeit.graphql
├── config/          ← 초기 데이터 설정
├── controller/      ← REST 컨트롤러 (필요시)
├── entity/          ← JPA 엔티티 (Book, Author)
├── exception/       ← 커스텀 예외, 전역 핸들러
├── input/           ← GraphQL Input 타입 클래스
├── repository/      ← JPA Repository
├── resolver/        ← GraphQL Resolver (Query, Mutation, Subscription)
└── service/         ← 비즈니스 로직

단계별로 만들기

단계 1 — Spring Boot 프로젝트 생성

Spring Initializr에서 프로젝트를 생성한다. 핵심 의존성은 네 가지다.

build.gradle의 dependencies 블록을 이렇게 구성한다.

plugins {
    id 'java'
    id 'org.springframework.boot' version '3.5.11'
    id 'io.spring.dependency-management' version '1.1.7'
}

group = 'com.codeit'
version = '0.0.1-SNAPSHOT'

java {
    toolchain {
        languageVersion = JavaLanguageVersion.of(17)
    }
}

configurations {
    compileOnly {
        extendsFrom annotationProcessor
    }
}

repositories {
    mavenCentral()
}

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
    implementation 'org.springframework.boot:spring-boot-starter-graphql'
    implementation 'org.springframework.boot:spring-boot-starter-web'

    compileOnly 'org.projectlombok:lombok'
    annotationProcessor 'org.projectlombok:lombok'

    runtimeOnly 'com.h2database:h2'

    testImplementation 'org.springframework.boot:spring-boot-starter-test'
    testImplementation 'org.springframework:spring-webflux'
    testImplementation 'org.springframework.graphql:spring-graphql-test'
    testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}

spring-boot-starter-graphql이 GraphQL 관련 라이브러리 전체를 가져온다. spring-boot-starter-web은 HTTP 서버, spring-boot-starter-data-jpa는 JPA, h2는 인메모리 데이터베이스다.

단계 2 — application.yml 설정

src/main/resources/application.yml에 서버, DB, GraphQL 설정을 넣는다.

server:
  port: 8080

spring:
  datasource:
    url: jdbc:h2:mem:bookdb
    driver-class-name: org.h2.Driver
    username: sa
    password:

  h2:
    console:
      enabled: true
      path: /h2-console

  jpa:
    hibernate:
      ddl-auto: create
    show-sql: true
    properties:
      hibernate:
        format_sql: true

  graphql:
    graphiql:
      enabled: true
    schema:
      locations: classpath:graphql/
    cors:
      allowed-origins: "*"
      allowed-methods: "*"

logging:
  level:
    com.codeit.graphql: DEBUG
    org.springframework.graphql: DEBUG

설정별로 짚어보면 이렇다.

  • jdbc:h2:mem:bookdb : 메모리에서 동작하는 H2 DB. 서버 종료 시 데이터가 사라진다.
  • ddl-auto: create : 엔티티 기반으로 테이블을 자동 생성한다. 개발용 설정이다.
  • graphiql.enabled: true : 브라우저에서 GraphQL 쿼리를 테스트할 수 있는 UI를 활성화한다.
  • schema.locations : .graphqls 스키마 파일을 찾는 경로다.
  • cors.allowed-origins: "*" : 모든 출처에서의 요청을 허용한다. 개발 환경 전용이다.
운영 환경에서는 CORS를 반드시 제한해야 한다

allowed-origins: "*"는 모든 도메인에서 API를 호출할 수 있다는 뜻이다. 개발 단계에서는 편리하지만, 운영 환경에서는 허용할 도메인을 명시적으로 지정해야 한다.

단계 3 — GraphQL 스키마 작성

src/main/resources/graphql/schema.graphqls 파일을 생성한다. 이 파일이 API의 전체 명세가 된다.

먼저 오브젝트 타입부터 정의한다.

type Book {
    id: ID!
    title: String!
    isbn: String!
    publishedYear: Int!
    price: Float!
    author: Author!
    createAt: String!
}

type Author {
    id: ID!
    name: String!
    email: String!
    books: [Book!]!
}

Book과 Author가 서로를 참조하는 양방향 구조다. author: Author!는 책이 반드시 저자를 가진다는 뜻이고, books: [Book!]!는 저자가 여러 권의 책을 가질 수 있다는 뜻이다.

다음으로 Input 타입을 정의한다. 클라이언트가 데이터를 보낼 때 사용하는 구조다.

input CreateBookInput {
    title: String!
    isbn: String!
    publishedYear: Int!
    price: Float!
    authorId: ID!
}

input UpdateBookInput {
    title: String
    isbn: String
    publishedYear: Int
    price: Float
}

input CreateAuthorInput {
    name: String!
    email: String!
}

input UpdateAuthorInput {
    name: String
    email: String
}

CreateBookInput의 모든 필드는 !(필수)이지만, UpdateBookInput의 필드는 전부 선택이다. 수정할 때는 바꿀 필드만 보내면 되기 때문이다.

마지막으로 세 가지 루트 타입을 정의한다.

type Query {
    books: [Book!]!
    book(id: ID!): Book
    searchBooks(title: String!): [Book!]!
    authors: [Author!]!
    author(id: ID!): Author
    booksByAuthor(authorId: ID!): [Book!]!
}

type Mutation {
    createBook(input: CreateBookInput!): Book!
    updateBook(id: ID!, input: UpdateBookInput!): Book!
    deleteBook(id: ID!): Boolean!
    createAuthor(input: CreateAuthorInput!): Author!
    updateAuthor(id: ID!, input: UpdateAuthorInput!): Author!
    deleteAuthor(id: ID!): Boolean!
}

type Subscription {
    bookAdded: Book!
}

Query는 6개의 조회 연산, Mutation은 6개의 변경 연산, Subscription은 1개의 실시간 구독을 정의한다.

코드 뜯어보기

schema.graphqls — 타입 관계

Bookauthor: Author!Authorbooks: [Book!]!가 양방향 관계를 형성한다. 클라이언트는 이 관계를 따라 원하는 깊이까지 탐색할 수 있다.

# 이런 쿼리가 가능하다
{
  books {
    title
    author {
      name
      books {    # 저자의 다른 책까지 탐색
        title
      }
    }
  }
}

이 유연함은 강력하지만, 깊이 제한 없이 방치하면 서버에 부하를 줄 수 있다. 개념 1편에서 다룬 내용이다.

Query와 Mutation의 차이

book(id: ID!): Book의 반환 타입은 Book이다. !가 없으므로 null을 반환할 수 있다. 해당 ID의 책이 없을 때를 대비한 설계다.

반면 createBook(input: CreateBookInput!): Book!의 반환 타입은 Book!이다. 생성에 성공하면 반드시 Book 객체를 반환한다는 보장이다.

전체 요청 흐름

스키마를 정의한 뒤, 클라이언트의 요청이 어떤 경로를 타는지 보면 다음 편에서 구현할 내용이 명확해진다.

sequenceDiagram autonumber participant C as 클라이언트 participant GQL as Spring GraphQL participant SCH as schema.graphqls participant R as Resolver (다음 편) participant S as Service (다음 편) C->>GQL: POST /graphql (쿼리 문자열) GQL->>SCH: 스키마 검증 Note right of SCH: 존재하는 필드인가?
타입이 맞는가? alt 스키마 검증 실패 SCH-->>C: 에러 응답 (VALIDATION_ERROR) else 스키마 검증 성공 GQL->>R: 매칭된 Resolver 호출 R->>S: 비즈니스 로직 위임 S-->>R: 결과 반환 R-->>C: JSON 응답 end

현재 편에서 스키마까지 만들었다. Resolver와 Service는 다음 편부터 구현한다.

이번 편 최종 전체 코드

build.gradle

plugins {
    id 'java'
    id 'org.springframework.boot' version '3.5.11'
    id 'io.spring.dependency-management' version '1.1.7'
}

group = 'com.codeit'
version = '0.0.1-SNAPSHOT'

java {
    toolchain {
        languageVersion = JavaLanguageVersion.of(17)
    }
}

configurations {
    compileOnly {
        extendsFrom annotationProcessor
    }
}

repositories {
    mavenCentral()
}

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
    implementation 'org.springframework.boot:spring-boot-starter-graphql'
    implementation 'org.springframework.boot:spring-boot-starter-web'
    compileOnly 'org.projectlombok:lombok'
    annotationProcessor 'org.projectlombok:lombok'
    runtimeOnly 'com.h2database:h2'
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
    testImplementation 'org.springframework:spring-webflux'
    testImplementation 'org.springframework.graphql:spring-graphql-test'
    testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}

application.yml

server:
  port: 8080

spring:
  datasource:
    url: jdbc:h2:mem:bookdb
    driver-class-name: org.h2.Driver
    username: sa
    password:
  h2:
    console:
      enabled: true
      path: /h2-console
  jpa:
    hibernate:
      ddl-auto: create
    show-sql: true
    properties:
      hibernate:
        format_sql: true
  graphql:
    graphiql:
      enabled: true
    schema:
      locations: classpath:graphql/
    cors:
      allowed-origins: "*"
      allowed-methods: "*"

logging:
  level:
    com.codeit.graphql: DEBUG
    org.springframework.graphql: DEBUG

schema.graphqls

type Book {
    id: ID!
    title: String!
    isbn: String!
    publishedYear: Int!
    price: Float!
    author: Author!
    createAt: String!
}

type Author {
    id: ID!
    name: String!
    email: String!
    books: [Book!]!
}

input CreateBookInput {
    title: String!
    isbn: String!
    publishedYear: Int!
    price: Float!
    authorId: ID!
}

input UpdateBookInput {
    title: String
    isbn: String
    publishedYear: Int
    price: Float
}

input CreateAuthorInput {
    name: String!
    email: String!
}

input UpdateAuthorInput {
    name: String
    email: String
}

type Query {
    books: [Book!]!
    book(id: ID!): Book
    searchBooks(title: String!): [Book!]!
    authors: [Author!]!
    author(id: ID!): Author
    booksByAuthor(authorId: ID!): [Book!]!
}

type Mutation {
    createBook(input: CreateBookInput!): Book!
    updateBook(id: ID!, input: UpdateBookInput!): Book!
    deleteBook(id: ID!): Boolean!
    createAuthor(input: CreateAuthorInput!): Author!
    updateAuthor(id: ID!, input: UpdateAuthorInput!): Author!
    deleteAuthor(id: ID!): Boolean!
}

type Subscription {
    bookAdded: Book!
}

자주 하는 실수

schema.graphqls 파일 위치를 잘못 잡는 것

스키마 파일은 반드시 src/main/resources/graphql/ 디렉토리 안에 있어야 한다. application.yml에서 schema.locations: classpath:graphql/로 지정했기 때문이다. 루트에 놓거나 resources/ 바로 아래에 놓으면 Spring GraphQL이 스키마를 찾지 못한다.

[!DANGER] 스키마의 필드명과 Java 필드명 불일치

스키마에 publishedYear로 정의했으면 Java 엔티티에도 정확히 publishedYear여야 한다. published_yearyear로 이름을 다르게 지으면 자동 매핑이 실패한다. 카멜케이스를 일관되게 맞춰야 한다.