Spring Boot GraphQL 입문: 스키마부터 N+1 해결까지

이번 포스팅에서는 Spring Boot GraphQL 입문에 대해서 정리하고자 한다. REST API를 오래 다뤄온 개발자라면 GraphQL이라는 단어를 여러 번 들었지만 정작 프로젝트에 넣어본 적은 없는 경우가 많다. 스키마를 따로 관리해야 한다는 점, 컨트롤러를 짜는 방식이 다르다는 점, 그리고 조금만 잘못 짜면 쿼리가 폭증한다는 소문 때문에 시작 자체를 미루게 된다. 다행히 Spring for GraphQL이 2.0으로 올라오면서 애노테이션 기반 컨트롤러 모델이 상당히 다듬어졌고, Spring Boot의 자동 설정이 전송 계층과 스키마 로딩을 대신 처리해주기 때문에 실제로 첫 쿼리를 받아보기까지 걸리는 시간은 짧다. 이 글은 Book과 Author라는 단순한 도메인 하나를 끝까지 끌고 가면서 의존성 설정, 스키마 작성, 쿼리와 뮤테이션 처리, N+1 문제 해결, 테스트, 그리고 프로덕션에 올리기 전 반드시 확인해야 할 보안 설정까지 순서대로 다룬다. Java 21 이상과 Spring Boot 4.x를 기준으로 작성했다. Spring Boot 4.x 계열의 전반적인 변화가 궁금하다면 Spring Boot 4.1 신기능 정리를 먼저 훑어보는 것도 좋다.

REST로 충분한데 왜 GraphQL인가

GraphQL은 클라이언트가 필요한 필드를 직접 지정해서 응답 형태를 결정하는 쿼리 언어다. 서버는 엔드포인트 하나(POST /graphql)만 열어두고, 어떤 데이터를 어떤 모양으로 받을지는 요청 문서가 결정한다. REST에서 반복적으로 겪는 오버페칭과 언더페칭이 여기서 해소된다.

오버페칭은 필요 없는 필드까지 받아오는 상황이다. 모바일 목록 화면에서 책 제목만 필요한데 API는 페이지 수, 출판사, 저자 프로필까지 통째로 내려준다. 언더페칭은 반대로 한 화면을 그리기 위해 API를 세 번 네 번 호출해야 하는 상황이다. 책 목록을 받고, 각 책의 저자를 받고, 저자의 소속을 다시 받는 식이다. GraphQL에서는 이 세 번의 왕복이 요청 하나로 접힌다.

또 하나 중요한 지점은 스키마가 곧 계약이라는 사실이다. REST에서 응답 스펙은 문서나 관례에 의존하지만, GraphQL에서는 타입 정의 자체가 실행 가능한 계약이다. 필드가 nullable인지 아닌지까지 스키마에 명시되고, Spring for GraphQL 2.0은 이 nullability와 @Controller 메서드의 시그니처를 비교하는 schema mapping inspection을 강화해서 불일치를 기동 시점에 잡아낸다.

항목RESTGraphQL
엔드포인트 수리소스마다 개별 URL단일 엔드포인트(POST /graphql)
응답 형태 결정 주체서버클라이언트(쿼리 문서)
오버페칭자주 발생필드 선택으로 회피
캐싱HTTP 캐시를 그대로 활용POST 기반이라 별도 전략 필요
에러 표현HTTP 상태 코드실행 에러는 200 + errors 배열 (문서 파싱·검증 실패는 4xx, graphql-response+json 기준)
학습 난이도낮음스키마·리졸버·DataLoader 개념 필요

캐싱 항목은 GraphQL 도입을 검토할 때 가장 먼저 부딪히는 트레이드오프다. REST에서 공짜로 얻던 CDN 캐시와 ETag를 GraphQL에서는 그대로 쓸 수 없다. 읽기 트래픽이 압도적이고 응답 형태가 고정된 API라면 REST가 여전히 합리적인 선택이다. 이런 판단을 프레임워크 레벨로 옮기면 어떤 기준이 되는지는 Spring Boot vs Quarkus 비교에 정리해두었다.

Spring Boot GraphQL, 스타터 두 개면 시작된다

Spring Boot GraphQL을 시작하려면 spring-boot-starter-graphql 하나와 전송 계층 스타터 최소 한 개가 필요하다. 스타터만으로는 요청을 받을 통로가 없기 때문이다. 서블릿 기반 HTTP를 쓴다면 spring-boot-starter-web을 함께 넣는 조합이 가장 일반적이다.

// build.gradle
dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-graphql'
    implementation 'org.springframework.boot:spring-boot-starter-web' // HTTP 전송 계층 (Spring MVC)

    testImplementation 'org.springframework.boot:spring-boot-starter-test'
    testImplementation 'org.springframework.graphql:spring-graphql-test' // GraphQlTester 제공
}
Groovy

전송 계층 선택지는 네 가지다. spring-boot-starter-web은 서블릿 기반 HTTP, spring-boot-starter-websocket은 서블릿 환경에서의 WebSocket, spring-boot-starter-webflux는 리액티브 HTTP와 WebSocket, spring-boot-starter-rsocket은 TCP와 WebSocket 위의 RSocket을 담당한다. 구독(subscription)을 쓸 계획이 없다면 web 하나로 충분하다. 자세한 조합은 Spring Boot 레퍼런스 문서에 정리되어 있다.

다음은 프로퍼티다. Spring Boot GraphQL의 기본값 중 개발 단계에서 바로 손대야 하는 항목이 몇 개 있다.

# src/main/resources/application.yml
spring:
  graphql:
    schema:
      locations: "classpath:graphql/**/"   # 기본값. 여러 모듈을 쓰면 classpath*:graphql/**/
      file-extensions: ".graphqls,.gqls"   # 기본값
      printer:
        enabled: true                      # /graphql/schema 로 스키마 텍스트 노출 (기본 false)
    http:
      path: /graphql                       # 기본값. POST 로 요청을 받는다
    graphiql:
      enabled: true                        # 기본 false. 켜면 /graphiql 에서 브라우저 IDE 사용
YAML

스키마 파일의 기본 위치는 src/main/resources/graphql 하위이고 확장자는 .graphqls 또는 .gqls다. 멀티 모듈 프로젝트에서 각 모듈이 자기 스키마 조각을 들고 있다면 locations를 classpath*:graphql/**/ 로 바꿔야 의존성 JAR 안의 스키마까지 탐지된다. printer와 graphiql은 개발 편의를 위한 옵션이므로 프로덕션 프로파일에서는 반드시 꺼야 한다. spring-boot-devtools가 클래스패스에 있으면 GraphiQL은 개발 중 자동으로 활성화된다.

스키마부터 그리는 이유

Spring Boot GraphQL은 schema-first 방식이다. 코드에서 스키마를 생성하는 것이 아니라, 스키마 파일을 먼저 작성하고 그 정의에 컨트롤러 메서드를 붙이는 순서로 동작한다. 이 순서가 강제되기 때문에 API 설계가 구현보다 앞서게 되고, 프런트엔드와의 협의가 코드 작성 전에 끝난다.

# src/main/resources/graphql/schema.graphqls
type Query {
    bookById(id: ID!): Book
    books: [Book!]!
}

type Mutation {
    addBook(input: BookInput!): Book!
}

type Book {
    id: ID!
    title: String!
    pages: Int!
    author: Author!
}

type Author {
    id: ID!
    name: String!
}

input BookInput {
    title: String!
    pages: Int!
    authorId: ID!
}
GraphQL

느낌표 하나가 큰 차이를 만든다. Book은 nullable이고 [Book!]!은 리스트 자체도 원소도 null이 될 수 없다는 뜻이다. bookById는 존재하지 않는 id를 받을 수 있으므로 nullable로 두었고, books는 결과가 없으면 빈 배열을 반환하므로 non-null로 선언했다. Spring for GraphQL 2.0은 JSpecify 애노테이션을 public API 전반에 적용하면서 이 스키마 nullability와 컨트롤러 메서드의 반환 타입을 대조하는 검사를 강화했다.

입력 타입은 type이 아니라 input 키워드로 선언한다. GraphQL은 출력 타입과 입력 타입을 문법 수준에서 분리하기 때문에 Book을 뮤테이션 인자로 그대로 쓸 수 없다. 처음에는 타입을 두 벌 만드는 게 번거롭게 느껴진다. 하지만 응답 모델과 요청 모델이 한 클래스에 뒤섞이는 REST의 흔한 문제가 여기서는 애초에 생기지 않는다.

애노테이션 하나가 DataFetcher를 대신한다

Spring Boot GraphQL에서 쿼리 하나를 처리하는 데 필요한 것은 @Controller 클래스와 @QueryMapping 메서드다. @QueryMapping은 typeName을 Query로 미리 지정해둔 메타 애노테이션이고, 필드명은 메서드 이름에서 자동으로 유추된다. 별도의 리졸버 등록이나 DataFetcher 구현은 필요 없다.

package com.example.bookstore;

import org.springframework.graphql.data.method.annotation.Argument;
import org.springframework.graphql.data.method.annotation.QueryMapping;
import org.springframework.stereotype.Controller;

import java.util.List;

@Controller
public class BookController {

    private final BookRepository bookRepository;

    public BookController(BookRepository bookRepository) {
        this.bookRepository = bookRepository;
    }

    // 메서드명 bookById 가 곧 Query.bookById 필드에 매핑된다
    @QueryMapping
    public Book bookById(@Argument String id) {
        return bookRepository.findById(id).orElse(null); // 스키마상 Book 은 nullable
    }

    @QueryMapping
    public List<Book> books() {
        return bookRepository.findAll();
    }
}
Java

예제에 쓰인 BookRepository와 AuthorRepository는 ListCrudRepository를 상속한다고 가정한다. CrudRepository를 상속하면 findAll과 findAllById가 Iterable를 반환하기 때문에 List 반환도, 뒤에 나올 .stream() 호출도 컴파일되지 않는다.

@Argument는 GraphQL 인자 이름과 메서드 파라미터를 바인딩한다. 이름이 같으면 값을 생략해도 되고, 다르면 @Argument(“id”)처럼 명시하면 된다. 스키마에서 ID! 타입인 인자를 Java에서 String으로 받는 점에 주의한다. ID는 GraphQL의 스칼라 타입일 뿐 별도의 Java 타입이 아니다. 메서드명과 스키마 필드명이 어긋나면 애플리케이션 기동 시점에 schema mapping inspection이 경고를 남기므로 로그를 확인하는 습관이 도움이 된다.

파라미터로 받을 수 있는 것은 @Argument 말고도 많다. @Arguments를 붙여 인자 전체를 Map<String, Object>로 받거나, 인자가 생략된 것인지 명시적으로 null이 들어온 것인지 구분해야 할 때 쓰는 ArgumentValue, 페이지네이션용 Subrange와 ScrollSubrange, 정렬용 Sort, 인증 주체를 받는 Principal과 @AuthenticationPrincipal, 컨텍스트 값을 꺼내는 @ContextValue, 클라이언트가 어떤 필드를 요청했는지 알려주는 DataFetchingFieldSelectionSet까지 지원된다. 반환 타입도 T 외에 Mono, Flux, Kotlin의 suspend 함수와 Flow, Callable, 그리고 에러나 로컬 컨텍스트를 함께 실어 보내는 DataFetcherResult

연관 필드는 @SchemaMapping이 맡는다

Book 타입의 author 필드처럼 부모 객체에서 파생되는 값은 @SchemaMapping으로 처리한다. typeName과 field를 지정할 수 있고, 생략하면 field는 메서드명, typeName은 소스 객체의 단순 클래스명으로 결정된다. 최상위 쿼리와 달리 부모 객체를 파라미터로 받는다는 점이 핵심이다.

package com.example.bookstore;

import org.springframework.graphql.data.method.annotation.SchemaMapping;
import org.springframework.stereotype.Controller;

@Controller
public class BookFieldController {

    private final AuthorRepository authorRepository;

    public BookFieldController(AuthorRepository authorRepository) {
        this.authorRepository = authorRepository;
    }

    // typeName 은 소스 객체 Book 에서, field 는 메서드명 author 에서 유추된다
    @SchemaMapping
    public Author author(Book book) {
        return authorRepository.findById(book.authorId()).orElseThrow();
    }

    // 이름이 다를 때는 명시한다
    @SchemaMapping(typeName = "Book", field = "author")
    public Author resolveAuthor(Book book) {
        return authorRepository.findById(book.authorId()).orElseThrow();
    }
}
Java

두 메서드는 같은 필드를 매핑하므로 실제 코드에서는 하나만 남겨야 한다. 위쪽처럼 관례에 기대는 방식이 간결하지만, 한 컨트롤러가 여러 타입의 필드를 다룰 때는 명시적인 선언이 읽기 편하다. 클래스 레벨에 @SchemaMapping(typeName = “Book”)을 붙여두면 그 클래스의 모든 메서드가 기본 typeName을 물려받는다.

@SchemaMapping은 정상 동작하지만 목록 쿼리에서 성능이 무너진다. books 쿼리가 저자까지 함께 요청하는 순간, 책이 100권이면 author 메서드가 100번 호출되고 그만큼 조회가 발생한다. 아래 N+1 섹션에서 @BatchMapping으로 이 부분을 해결한다. 필드 단위로 리졸버를 분리하는 구조는 유연하지만, 그 유연함의 대가로 호출 횟수를 개발자가 직접 관리해야 한다.

애노테이션매핑 대상파라미터호출 횟수주 용도
@QueryMappingQuery 타입 필드@Argument요청당 1회최상위 조회
@MutationMappingMutation 타입 필드@Argument, @Valid요청당 1회데이터 변경
@SchemaMapping임의 타입의 필드부모 객체부모 객체 수만큼단건 연관 필드
@BatchMapping임의 타입의 필드List<부모 객체>필드당 1회목록의 연관 필드(N+1 회피)

네 애노테이션의 결정적 차이는 호출 횟수 열에 있다. @SchemaMapping만 부모 객체 수에 비례해 호출되고 나머지는 요청당 또는 필드당 한 번으로 끝난다. 목록 쿼리에 노출되는 연관 필드인지 아닌지가 @SchemaMapping과 @BatchMapping을 가르는 기준이 된다.

데이터를 바꾸기 전에 @Valid부터 붙인다

데이터 변경은 @MutationMapping으로 처리한다. typeName이 Mutation으로 프리셋된 메타 애노테이션이라는 점만 다를 뿐 사용법은 @QueryMapping과 같다. 입력 객체 검증은 @Argument에 @Valid를 붙이면 Bean Validation이 그대로 적용된다.

package com.example.bookstore;

import jakarta.validation.Valid;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import org.springframework.graphql.data.method.annotation.Argument;
import org.springframework.graphql.data.method.annotation.MutationMapping;
import org.springframework.stereotype.Controller;

@Controller
public class BookMutationController {

    private final BookService bookService;

    public BookMutationController(BookService bookService) {
        this.bookService = bookService;
    }

    @MutationMapping
    public Book addBook(@Argument @Valid BookInput input) { // 검증 실패 시 ConstraintViolationException
        return bookService.create(input);
    }

    public record BookInput(
            @NotBlank String title,
            @Min(1) int pages,
            @NotBlank String authorId
    ) {}
}
Java

@Valid가 붙은 인자의 검증이 실패하면 ConstraintViolationException이 발생한다. 이 예외를 그대로 두면 클라이언트는 내부 메시지가 노출된 서버 에러를 받게 되므로, 아래 에러 처리 섹션에서 다루는 @GraphQlExceptionHandler로 변환해주는 편이 낫다. record를 입력 타입으로 쓸 때는 스키마의 input BookInput 필드명과 record 컴포넌트명이 일치해야 바인딩된다.

이름 규칙이 어긋나는 경우도 있다. 스키마는 snake_case인데 Java는 camelCase인 상황이 대표적이다. Spring for GraphQL 2.0은 GraphQlArgumentBinder에 커스텀 이름 해석 기능을 추가해서 snake_case를 camelCase로 매핑하는 규칙을 등록할 수 있게 했다. 기존 API 스키마를 그대로 유지한 채 Java 쪽 명명 규칙을 지켜야 하는 상황에서 유용하다.

부분 수정(partial update) 뮤테이션에서는 ArgumentValue가 필요하다. 클라이언트가 필드를 아예 보내지 않은 것과 명시적으로 null을 보낸 것은 의미가 다르기 때문이다. bookInput.isOmitted()로 생략 여부를 먼저 확인하고, 생략이 아니면 bookInput.value()로 실제 값을 꺼내는 방식으로 두 경우를 구분한다.

GraphiQL에서 직접 쿼리 쏴보기

GraphiQL은 브라우저에서 쿼리를 작성하고 실행하는 IDE이고, Spring Boot GraphQL은 이 화면을 자동 설정으로 제공한다. spring.graphql.graphiql.enabled를 true로 두면 /graphiql 경로에서 열리고, spring-boot-devtools가 있으면 개발 중 자동으로 켜진다. 스키마 자동완성과 문서 탐색이 되기 때문에 입문 단계에서는 스키마를 읽는 것보다 GraphiQL에서 직접 눌러보는 쪽이 빠르다.

query GetBookWithAuthor {
  bookById(id: "book-1") {
    id
    title
    pages
    author {
      name
    }
  }
}
Plaintext

이 쿼리는 책 하나를 조회하면서 저자 이름까지 함께 요청한다. author는 @SchemaMapping이 처리하는 필드이므로 별도의 왕복 없이 같은 응답에 담겨 돌아온다. 요청하지 않은 필드는 응답에 포함되지 않는다는 점이 REST와 결정적으로 다른 부분이다.

{
  "data": {
    "bookById": {
      "id": "book-1",
      "title": "Effective Java",
      "pages": 412,
      "author": {
        "name": "Joshua Bloch"
      }
    }
  }
}
JSON

응답 구조를 보면 요청한 필드 트리가 그대로 반사되어 있다. 최상위에 data 키가 있고, 그 아래는 쿼리 문서의 형태를 따른다. 에러가 있으면 같은 레벨에 errors 배열이 추가된다. 클라이언트 입장에서는 응답 형태를 미리 알고 있으므로 파싱 코드를 스키마에서 생성할 수 있다.

GraphiQL을 개발 편의 도구로만 인식하는 것이 중요하다. 이 화면은 스키마 전체를 탐색할 수 있게 해주므로 프로덕션에 노출되면 API 구조가 그대로 공개된다. 기본값이 false인 데는 이유가 있다.

Spring Boot GraphQL 입문자가 반드시 만나는 N+1 문제

N+1은 목록을 조회한 뒤 각 항목의 연관 필드를 개별 조회하면서 쿼리가 폭증하는 현상이다. 책 100권에 대해 author 필드를 요청하면 목록 조회 1번에 저자 조회 100번이 붙는다. Spring for GraphQL은 @BatchMapping으로 이 문제를 해결한다.

package com.example.bookstore;

import org.springframework.graphql.data.method.annotation.BatchMapping;
import org.springframework.stereotype.Controller;

import java.util.List;
import java.util.Map;
import java.util.function.Function;
import java.util.stream.Collectors;

@Controller
public class BookBatchController {

    private final AuthorRepository authorRepository;

    public BookBatchController(AuthorRepository authorRepository) {
        this.authorRepository = authorRepository;
    }

    // Book 목록을 한 번에 받아 Author 를 일괄 조회한다. 조회는 총 2번으로 끝난다
    @BatchMapping
    public Map<Book, Author> author(List<Book> books) {
        List<String> authorIds = books.stream().map(Book::authorId).toList();
        Map<String, Author> authorsById = authorRepository.findAllById(authorIds).stream()
                .collect(Collectors.toMap(Author::id, Function.identity()));

        return books.stream()
                .collect(Collectors.toMap(Function.identity(), b -> authorsById.get(b.authorId())));
    }
}
Java

@BatchMapping은 DataLoader를 등록하고 호출하는 보일러플레이트를 대신 처리한다. 필드명은 메서드명에서, typeName은 List 요소 타입인 Book의 단순 클래스명에서 유추된다. 반환 타입은 Map<K, V> 외에 Mono<Map<K, V>>, 소스 순서와 동일한 순서를 보장하는 Flux와 Collection, 그리고 Callable 변형을 지원한다. 파라미터로는 List와 함께 Principal, @ContextValue, GraphQLContext, BatchLoaderEnvironment를 받을 수 있다.

이 예제에는 함정이 하나 숨어 있다. Collectors.toMap은 값 매퍼의 결과에 Objects.requireNonNull을 걸기 때문에 value가 null이면 NPE를 던진다. authorsById.get(b.authorId())는 저자가 조회되지 않으면 null을 반환하므로, 정합성이 깨진 행 하나에 배치 전체가 터진다. 스키마가 author: Author!라 논리적으로는 저자가 항상 있어야 하지만 그 가정이 깨지는 경우를 대비한다면 filter로 걸러내거나 HashMap에 직접 put하는 편이 안전하다.

Flux나 Collection를 반환할 때는 소스 리스트와 순서가 정확히 일치해야 한다는 제약이 붙는다. 순서가 어긋나면 엉뚱한 저자가 붙은 응답이 조용히 나가기 때문에, 입문 단계에서는 키를 명시하는 Map<K, V> 반환을 쓰는 편이 안전하다. 키로 쓰이는 Book이 equals와 hashCode를 제대로 구현하고 있어야 한다는 점도 함께 확인해야 한다. record를 쓰면 이 부분은 자동으로 해결된다.

Spring Boot GraphQL을 도입하고 나서 성능이 나빠졌다는 이야기의 대부분이 이 지점에서 나온다. REST에서는 쿼리 횟수가 엔드포인트 구현에 고정되어 있지만, GraphQL에서는 클라이언트가 어떤 필드를 요청하느냐에 따라 달라진다. 연관 필드를 리졸버로 분리했다면 @BatchMapping 적용 여부를 함께 검토하는 것이 기본이다.

에러는 GraphQL 방식으로 돌려준다

GraphQL은 실행 단계의 에러를 HTTP 상태 코드가 아니라 응답 본문의 errors 배열로 전달한다. 필드 일부만 실패하고 나머지는 정상 반환되는 경우가 있기 때문이다. Spring Boot GraphQL은 @GraphQlExceptionHandler로 예외를 GraphQLError로 변환하는 통로를 제공한다.

여기서 한 가지 구분이 필요하다. 모든 응답이 200으로 오는 것은 아니다. 클라이언트가 보낸 GraphQL 문서를 파싱할 수 없거나 엔진이 스키마 검증에서 유효하지 않다고 판단하면 4xx가 내려온다. 단 이는 응답 콘텐츠 타입이 application/graphql-response+json일 때의 동작이고, 클라이언트가 레거시 application/json을 명시적으로 요청하면 같은 상황에서도 200이 온다. 선호를 밝히지 않은 클라이언트에는 graphql-response+json이 기본으로 선택되므로 4xx를 표준 동작으로 보면 된다. 검증을 통과한 뒤에야 상태 코드가 200으로 고정되고, 그 이후 발생한 에러가 errors 배열에 담긴다.

package com.example.bookstore;

import org.springframework.graphql.data.method.annotation.GraphQlExceptionHandler;
import org.springframework.graphql.execution.ErrorType;
import org.springframework.validation.BindException;
import org.springframework.web.bind.annotation.ControllerAdvice;

import graphql.GraphQLError;
import graphql.GraphqlErrorBuilder;

@ControllerAdvice // 전역 적용. 컨트롤러 내부에 두면 해당 컨트롤러에만 적용된다
public class GraphQlExceptionAdvice {

    @GraphQlExceptionHandler
    public GraphQLError handle(GraphqlErrorBuilder<?> errorBuilder, BindException ex) {
        return errorBuilder
                .errorType(ErrorType.BAD_REQUEST) // 클라이언트 잘못임을 명시
                .message(ex.getMessage())
                .build();
    }
}
Java

핸들러 메서드는 GraphqlErrorBuilder와 처리할 예외를 파라미터로 받는다. 빌더에 errorType과 message를 채워 GraphQLError를 만들어 반환하면 그 내용이 응답의 errors 배열에 실린다. 반환 타입으로는 GraphQLError 외에 Collection, void, Object, Mono를 쓸 수 있다. 컨트롤러 안에 직접 선언하면 해당 컨트롤러 범위에만, @ControllerAdvice 클래스에 두면 전역에 적용된다.

ErrorType은 BAD_REQUEST, UNAUTHORIZED, FORBIDDEN, NOT_FOUND, INTERNAL_ERROR 다섯 가지 상수로 정의되어 있고 Spring for GraphQL 1.0부터 유지되고 있다. errorType을 지정하지 않으면 대부분의 예외가 INTERNAL_ERROR로 분류되고 클라이언트는 원인을 알 수 없는 메시지를 받는다. 검증 실패는 BAD_REQUEST로, 조회 실패는 NOT_FOUND로 분류해두면 클라이언트가 재시도 여부를 판단할 수 있다. 내부 예외 메시지가 그대로 나가지 않도록 message를 직접 구성하는 것도 함께 챙겨야 한다.

웹 계층 없이 쿼리만 골라 테스트하기

GraphQL 전용 테스트 슬라이스인 @GraphQlTest는 Spring Boot 2.7부터 제공된다. 다만 Spring Boot 4.0에서 모듈이 재편되면서 패키지가 org.springframework.boot.test.autoconfigure.graphql에서 org.springframework.boot.graphql.test.autoconfigure로 옮겨졌으므로, 3.x 예제를 그대로 가져오면 import에서 막힌다. 웹 계층 없이 GraphQL 요청 실행만 검증하고 애플리케이션 설정의 일부만 로딩하는 애노테이션이며, GraphQlTester가 기본으로 주입되기 때문에 별도 설정 없이 쿼리를 실행하고 응답 경로를 검증할 수 있다.

package com.example.bookstore;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.graphql.test.autoconfigure.GraphQlTest;
import org.springframework.graphql.test.tester.GraphQlTester;
import org.springframework.test.context.bean.override.mockito.MockitoBean;

@GraphQlTest(BookController.class) // 테스트 대상 컨트롤러만 로딩한다
class BookControllerTests {

    @Autowired
    private GraphQlTester graphQlTester; // @GraphQlTest 가 기본으로 구성해준다

    @MockitoBean // @Repository 는 슬라이스에 포함되지 않으므로 대역이 필요하다
    private BookRepository bookRepository;

    @Test
    void bookByIdReturnsAuthor() {
        graphQlTester.document("""
                        query {
                          bookById(id: "book-1") {
                            title
                            author { name }
                          }
                        }
                        """)
                .execute()
                .path("bookById.author.name")   // 응답 JSON 경로로 접근
                .entity(String.class)
                .isEqualTo("Joshua Bloch");
    }

    @Test
    void booksReturnsList() {
        graphQlTester.documentName("books")     // graphql-test/books.graphql 파일을 읽는다
                .execute()
                .path("books")
                .entityList(Book.class)
                .hasSizeGreaterThan(0);
    }
}
Java

@GraphQlTest는 전체 자동 설정을 끄고 GraphQL 테스트에 필요한 빈만 올린다. 로딩 대상은 @Controller, RuntimeWiringConfigurer, @JacksonComponent, Converter와 GenericConverter, DataFetcherExceptionResolver, Instrumentation, GraphQlSourceBuilderCustomizer다. @Component, @Service, @Repository는 로딩되지 않으므로 협력 객체는 @MockitoBean으로 대체한다. 괄호 안에 컨트롤러 클래스를 지정하면 그 컨트롤러만, 비워두면 모든 @Controller 빈이 컨텍스트에 들어간다.

여기에 함정이 하나 있다. @WebMvcTest와 달리 @GraphQlTest는 @ControllerAdvice를 스캔하지 않는다. 앞에서 만든 GraphQlExceptionAdvice 같은 전역 예외 핸들러는 슬라이스에 자동으로 올라오지 않으므로, 에러 응답까지 검증하려면 @Import(GraphQlExceptionAdvice.class)로 직접 올려야 한다. 이걸 모르면 에러 처리 테스트가 통과하는 것처럼 보이다가 실제 환경에서 다른 응답이 나간다.

document는 쿼리 문자열을 직접 넣는 방식이고, documentName은 graphql-test 디렉터리 아래의 .graphql 또는 .gql 파일명을 참조하는 방식이다. 쿼리가 길어지면 파일로 분리하는 쪽이 관리하기 편하다. path로 응답 경로를 지정한 뒤 entity나 entityList로 타입을 지정해 검증한다. 변수를 넘길 때는 variable()을 체이닝하고, 응답 데이터가 없는 뮤테이션 검증에는 executeAndVerify()를 쓴다.

에러 검증도 별도 API로 제공된다. errors().filter().verify()로 특정 에러를 걸러내고, errors().expect().verify()로 기대하는 에러가 있는지 확인하며, errors().satisfy()로 임의의 단언을 붙일 수 있다. 구독 테스트는 executeSubscription().toFlux(“greetings”, String.class)로 스트림을 받아 Reactor의 StepVerifier로 검증한다.

구현체는 전송 방식에 따라 갈린다.

구현체전송 계층기반사용 시점
HttpGraphQlTesterHTTPWebTestClientHTTP 엔드포인트 통합 검증
WebSocketGraphQlTesterWebSocketWebSocketClient구독(subscription) 검증
RSocketGraphQlTesterRSocketRSocketRequesterRSocket 전송 검증
WebGraphQlTester없음인터셉터 체인 포함인터셉터까지 검증
ExecutionGraphQlServiceTester없음서버 사이드 직접 실행전송 계층 제외한 최소 검증

아래 두 개는 전송 계층을 거치지 않으므로 실행 속도가 빠르고, 위 세 개는 실제 프로토콜을 태우는 만큼 통합 수준이 높다. 슬라이스가 아니라 애플리케이션 전체 설정을 올린 채 HTTP로 검증하고 싶다면 @SpringBootTest에 @AutoConfigureHttpGraphQlTester를 조합한다. 공통 설정은 빌더에서 documentSource(), errorFilter(), responseTimeout(Duration.ofSeconds(5))으로 잡아둘 수 있다. 각 구현체의 차이는 Spring for GraphQL 공식 문서에 정리되어 있다.

입문자가 자주 밟는 지뢰

Spring Boot GraphQL을 프로덕션에 올리기 전 반드시 확인해야 할 항목이 세 가지 있다. introspection 노출, 쿼리 깊이와 복잡도 제한 부재, 그리고 구버전 CVE다. 셋 다 로컬 개발에서는 아무 증상이 없다가 외부에 열리는 순간 문제가 된다.

introspection은 스키마 전체를 질의할 수 있게 해주는 GraphQL 표준 기능이고, spring.graphql.schema.introspection.enabled의 기본값은 true다. 켜져 있으면 누구든 타입, 필드, 인자를 전부 조회할 수 있다. 내부 전용 API라면 프로덕션 프로파일에서 꺼두는 편이 낫다. 같은 맥락에서 spring.graphql.schema.printer.enabled(기본 false)와 spring.graphql.graphiql.enabled(기본 false)도 프로덕션에서 켜져 있지 않은지 확인해야 한다.

두 번째는 쿼리 깊이와 복잡도다. GraphQL은 클라이언트가 쿼리 형태를 결정하므로, 순환 참조가 있는 스키마에서 book { author { books { author { … } } } } 같은 중첩을 무한히 늘릴 수 있다. 아무 제한이 없으면 요청 하나로 서버를 마비시키는 것이 가능하다. graphql-java가 제공하는 instrumentation으로 최대 깊이와 복잡도 상한을 걸어두는 것이 외부 공개 API의 기본 조건이다.

세 번째는 버전이다. Spring for GraphQL은 2026년 6월 10일 2.0.4와 1.4.6을 릴리스하면서 High 등급 CVE 세 건을 수정했다. CVE-2026-41699는 Unsafe Deserialization, CVE-2026-41700은 Cross-Site WebSocket Hijacking, CVE-2026-41856은 Annotation Detection 취약점이다. 세 건 모두 High 등급이므로 이보다 낮은 버전을 쓰고 있다면 업그레이드가 우선이다. 릴리스 내역은 spring-graphql GitHub 저장소에서 확인할 수 있다.

버전 히스토리 한눈에 보기

Spring for GraphQL 2.0은 2025년 11월 18일 정식 릴리스되었고 Spring Framework 7.0과 graphql-java 25.0을 요구한다. Kotlin은 2.2.0, Jackson은 3.x가 기본이며 2.x로 폴백할 수 있다. graphql-java 25.0에서 코어 엔진의 request cancellation이 완전히 지원되면서 클라이언트가 끊은 요청의 처리를 중단할 수 있게 되었다. 이후 2.0.3(2026년 4월 21일)에서는 SSE keep-alive 메시지가 빈 SSE 주석 방식으로 바뀌었고 GraphQlTester.EntityList에 singleElement()가 추가되었다.

FAQ

Spring Boot GraphQL을 쓰려면 REST를 완전히 걷어내야 하나?

그럴 필요는 없다. GraphQL 엔드포인트는 기본적으로 /graphql 경로 하나만 사용하므로 기존 REST 컨트롤러와 같은 애플리케이션에서 공존한다. 파일 업로드나 CDN 캐시가 중요한 엔드포인트는 REST로 두고, 화면 조합이 복잡한 조회만 GraphQL로 옮기는 부분 도입이 현실적인 접근이다.

스키마 파일을 여러 개로 나눠도 되나?

가능하다. src/main/resources/graphql 하위의 .graphqls 또는 .gqls 파일이 모두 로딩되어 하나의 스키마로 합쳐진다. 도메인별로 book.graphqls, author.graphqls처럼 분리하는 방식이 일반적이다. 멀티 모듈 프로젝트라면 spring.graphql.schema.locations를 classpath*:graphql/**/ 로 지정해야 의존성 JAR 안의 스키마까지 탐지된다.

@SchemaMapping과 @BatchMapping은 언제 갈라 쓰나?

단건 조회의 연관 필드에는 @SchemaMapping으로 충분하지만, 목록 쿼리에서 각 항목의 연관 필드를 채워야 한다면 @BatchMapping을 써야 한다. @SchemaMapping은 항목마다 한 번씩 호출되어 N+1을 유발하고, @BatchMapping은 목록을 통째로 받아 한 번에 조회한다. 목록 쿼리가 있는 연관 필드는 @BatchMapping이 기본값이라고 생각하는 편이 안전하다.

GraphQL은 에러를 어떻게 전달하나?

응답 본문의 errors 배열을 확인해야 한다. GraphQL은 필드 단위로 부분 실패가 가능하기 때문에 전체 요청을 하나의 상태 코드로 표현하지 않는다. 다만 항상 200이 오는 것은 아니다. 응답 콘텐츠 타입이 application/graphql-response+json인 경우, 요청 문서 자체가 파싱되지 않거나 스키마 검증에 실패하면 4xx가 내려온다. 검증을 통과한 뒤 발생한 실행 에러만 200 응답의 errors 배열에 담긴다. 레거시 application/json을 명시적으로 요청한 클라이언트는 파싱·검증 실패에도 200을 받는다. 서버 쪽에서는 @GraphQlExceptionHandler로 예외를 GraphQLError로 변환하면서 errorType을 BAD_REQUEST나 NOT_FOUND로 분류해두면, 클라이언트가 errors 배열의 타입을 보고 재시도 여부를 판단할 수 있다.

Spring Boot GraphQL은 어떤 버전부터 쓸 수 있나?

Spring Boot 2.7부터 spring-boot-starter-graphql이 정식 제공된다. 이 글이 기준으로 삼은 Spring for GraphQL 2.0은 2025년 11월 18일 릴리스되었고 Spring Framework 7.0과 graphql-java 25.0을 요구하므로 Spring Boot 4.x 계열에서 사용한다. Spring Boot 3.x를 쓴다면 Spring for GraphQL 1.4.x 라인을 유지하되, 2026년 6월 10일 CVE 세 건이 수정된 1.4.6 이상으로 올려야 한다.

마치며

처음 GraphQL을 붙였을 때 가장 크게 착각했던 부분은 스키마만 잘 그리면 나머지는 프레임워크가 알아서 해준다고 생각한 점이었다. @QueryMapping과 @SchemaMapping을 붙여 화면 하나를 그려내는 데까지는 반나절도 걸리지 않았고, 그래서 더 방심했다. 문제는 목록 화면을 열었을 때 드러났다. 로컬에서는 데이터가 열 건이라 아무렇지 않았는데 스테이징에서 쿼리 로그를 켜보니 저자 조회가 수백 번 나가고 있었다. @BatchMapping의 존재를 그때 알았다.

두 번째로 배운 것은 GraphQL의 자유도가 서버 입장에서는 곧 예측 불가능성이라는 사실이다. REST에서는 어떤 엔드포인트가 얼마나 무거운지 구현을 보면 알 수 있지만, GraphQL에서는 클라이언트가 어떤 필드를 어디까지 파고들지 미리 알 수 없다. 쿼리 깊이 제한을 걸어두지 않은 채로 열어두는 것은 생각보다 위험한 일이다.

지금 다시 Spring Boot GraphQL을 도입한다면 순서를 바꿀 것 같다. 스키마를 그리고 컨트롤러를 붙이기 전에, 어떤 필드가 리졸버로 분리될지부터 표로 정리하고 그중 목록 쿼리에 노출되는 것에는 @BatchMapping을 먼저 준비해두는 식이다. 그리고 프로덕션 프로파일에서 introspection과 GraphiQL을 끄는 설정은 첫 커밋에 넣어두는 편이 낫다. 나중에 하겠다고 미루면 잊는다. 전체 API와 옵션은 Spring for GraphQL 레퍼런스 문서에 정리되어 있으니 도입 전에 한 번 훑어보길 권한다.