フロントエンドから「この画面にはこのフィールドだけ欲しい」という要求、よくありますよね。REST APIだと不要なフィールドまで返すオーバーフェッチや、複数エンドポイントを叩き直すアンダーフェッチが起きがちです。そこでGraphQLの出番です。

Spring Boot 3.xではSpring for GraphQLが正式サポートされ、アノテーションベースでシンプルに実装できるようになりました。スキーマ定義からResolver実装、N+1対処、Security連携まで一通り見ていきましょう。

GraphQLとREST APIの違い

まず簡単に整理しておきましょう。

観点REST APIGraphQL
エンドポイントリソースごとに複数単一(/graphql)
レスポンスサーバー側で固定クライアントが指定
オーバーフェッチ起きやすい起きにくい
スキーマ定義OpenAPIで別途定義SDLが標準仕様

GraphQLが向くのはBFF(Backend for Frontend)や、ネストが深いデータを一度に取得したいケースです。シンプルなCRUDやファイルアップロードが中心のAPIはREST APIの方が楽です。REST APIの実装については Spring BootでREST APIを作るチュートリアル を、OpenAPIを使ったドキュメント自動生成については Spring BootでOpenAPI(Swagger UI)を使ったREST APIドキュメントを自動生成する方法 も参照してください。

プロジェクトのセットアップ

build.gradle に依存関係を追加します。

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

application.properties の設定です。

spring.graphql.graphiql.enabled=true
spring.graphql.graphiql.path=/graphiql

GraphiQLは 開発環境専用 のツールです。本番では spring.graphql.graphiql.enabled=false(デフォルト)のままにするか、Spring Profileで制御してください。

スキーマファイルは src/main/resources/graphql/ 以下に .graphqls 拡張子で置きます。このディレクトリがSpring for GraphQLの自動スキャン対象です。

スキーマ定義(SDL)の書き方

schema.graphqls にBookとAuthorを定義してみます。

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

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

type Query {
  books: [Book!]!
  book(id: ID!): Book
}

type Mutation {
  createBook(input: CreateBookInput!): Book!
}

input CreateBookInput {
  title: String!
  authorId: ID!
}

! はnon-nullを意味します。author フィールドに ! がないのは、対応するauthorが見つからないケースを許容するためです。

@QueryMappingでQueryを実装する

Spring for GraphQLでは @Controller クラスに @QueryMapping を付けたメソッドを定義するだけでResolverになります。@RestController ではなく @Controller を使う点に注意してください。@RestController はレスポンスボディを直接返す設計のため、GraphQLの戻り値処理と競合します。

@Controller
public class BookController {

    private final BookService bookService;

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

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

    @QueryMapping
    public Book book(@Argument Long id) {
        return bookService.findById(id);
    }
}

メソッド名がスキーマのQueryフィールド名と一致しない場合、起動時に IllegalStateException が発生します。SDLとメソッド名の一致は必ず確認しましょう。

ここで一度 http://localhost:8080/graphiql にアクセスして動作確認しておきましょう。認証が必要な場合はHeaders欄に Authorization: Bearer <token> を追加します。

@MutationMappingでMutationを実装する

データ作成はMutationで実装します。InputTypeはJavaのrecordで表現するとシンプルです。

public record CreateBookInput(String title, Long authorId) {}

// BookControllerに追加
@MutationMapping
public Book createBook(@Argument CreateBookInput input) {
    return bookService.create(input);
}

@SchemaMappingでネストされた型を解決する

Book.author フィールドのように、親型から子フィールドを解決するには @SchemaMapping を使います。Javaの Book クラスには authorId フィールドが必要です。

public record Book(Long id, String title, Long authorId) {}
@SchemaMapping(typeName = "Book", field = "author")
public Author author(Book book) {
    return authorService.findById(book.authorId());
}

ただし、books クエリで10件のBookを返すと、author の解決が10回走り、最初の1クエリと合わせて 合計11クエリ が発行されます。これがN+1問題です。

N+1問題を@BatchMappingで解消する

@BatchMapping を使うと、IDリストをまとめて処理できます。

@BatchMapping(typeName = "Book", field = "author")
public Map<Book, Author> author(List<Book> books) {
    List<Long> ids = books.stream().map(Book::authorId).toList();
    Map<Long, Author> authorMap = authorService.findAllByIds(ids)
        .stream()
        .collect(Collectors.toMap(Author::getId, a -> a));

    // Collectors.toMap は null 値を許容しないため HashMap で手動収集する
    Map<Book, Author> result = new HashMap<>();
    books.forEach(b -> result.put(b, authorMap.get(b.authorId())));
    return result;
}

Collectors.toMap を使うと、authorMap.get(...)null を返した場合(対応するAuthorが存在しないBook)に NullPointerException がスローされます。スキーマ上 author はnullableなのでこのケースは起こりえます。HashMap への手動putなら null 値を安全に扱えます。

また、Book をマップキーとして使うには equals() / hashCode() の正しい実装が必要です。record を使えば自動生成されます。JPAエンティティの場合は id ベースの実装を推奨します。

JPA側のパフォーマンス改善については Spring Data JPAのパフォーマンス最適化 も参考にしてください。

Spring Securityとの統合

GraphQLエンドポイントを保護するには、SecurityConfigで /graphql パスを認証必須にします。

@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    http
        .authorizeHttpRequests(auth -> auth
            .requestMatchers("/graphiql", "/graphiql/**").permitAll()
            .requestMatchers("/graphql").authenticated()
            .anyRequest().authenticated()
        )
        .csrf(csrf -> csrf.ignoringRequestMatchers("/graphql"));
    return http.build();
}

/graphql に対してCSRFを無効化しているのは、GraphQLクライアントはCookieではなく Authorization ヘッダーで認証するのが一般的で、ブラウザ経由のCSRFリスクが低いためです。ただし、ブラウザから直接 /graphql を呼ぶ構成の場合は別途検討が必要です。

Resolverで認証済みユーザーを取得するには SecurityContextHolder.getContext().getAuthentication() を使います。@PreAuthorize をResolverメソッドに付けることも可能です。JWT認証との統合については Spring Boot JWT認証の実装 を参照してください。

エラーハンドリング

GraphQLのエラーは errors フィールドにまとめて返ってきます。カスタムエラーを返したい場合は DataFetcherExceptionResolverAdapter を実装します。Spring Boot 3.2以降では @GraphQlExceptionHandler アノテーションがより簡潔に書けます。

@Component
public class CustomExceptionResolver extends DataFetcherExceptionResolverAdapter {

    @Override
    protected GraphQLError resolveToSingleError(Throwable ex, DataFetchingEnvironment env) {
        if (ex instanceof BookNotFoundException) {
            return GraphqlErrorBuilder.newError(env)
                .message(ex.getMessage())
                .errorType(ErrorType.NOT_FOUND)
                .build();
        }
        return null;
    }
}

REST APIでの例外ハンドリングについては Spring Bootの例外ハンドリング も参考にしてください。

まとめ

Spring for GraphQLはアノテーションベースで直感的に実装できます。Query・Mutationは @QueryMapping / @MutationMapping、ネスト解決は @SchemaMapping、N+1対策は @BatchMapping に置き換えるだけです。

GraphQLが本領を発揮するのは、フロントエンドの画面ごとに必要なデータが異なるBFFや、複数リソースを一度に取得したいケースです。シンプルなCRUDであればREST APIの方が実装・運用ともに楽なので、要件に応じて使い分けていきましょう。