フロントエンドから「この画面にはこのフィールドだけ欲しい」という要求、よくありますよね。REST APIだと不要なフィールドまで返すオーバーフェッチや、複数エンドポイントを叩き直すアンダーフェッチが起きがちです。そこでGraphQLの出番です。
Spring Boot 3.xではSpring for GraphQLが正式サポートされ、アノテーションベースでシンプルに実装できるようになりました。スキーマ定義からResolver実装、N+1対処、Security連携まで一通り見ていきましょう。
GraphQLとREST APIの違い
まず簡単に整理しておきましょう。
| 観点 | REST API | GraphQL |
|---|---|---|
| エンドポイント | リソースごとに複数 | 単一(/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の方が実装・運用ともに楽なので、要件に応じて使い分けていきましょう。