Spring GraphQL面接: リゾルバー、DataLoader、N+1問題の解決策

この完全ガイドでSpring GraphQL面接の準備を進めます。リゾルバー、DataLoader、N+1問題への対処、ミューテーション、技術的質問のためのベストプラクティスを解説します。

リゾルバーとDataLoaderを使用したSpring GraphQLの技術面接

Spring for GraphQLは、Spring BootアプリケーションでのGraphQL統合を簡素化します。この技術はモダンなAPIにとって不可欠なものとなり、このトピックに関する面接質問はますます一般的になっています。本ガイドでは、リゾルバー、DataLoader、N+1問題、高度なパターンといった核心的な概念を網羅します。

面接の重点

採用担当者は特にN+1問題の理解とDataLoaderの使用法を確認します。これら2つのトピックはSpring GraphQL面接質問の60%を占めています。

Spring for GraphQLとは何ですか?

Spring for GraphQLはGraphQL Java Springの公式後継であり、バージョン2.7以降Springフレームワークにネイティブに統合されています。この統合により、Springアノテーションのサポート、Spring Securityとの統合、WebFluxまたはMVCの透過的な使用といった複数の利点が得られます。

基本構成にはspring-boot-starter-graphql依存関係とGraphQLスキーマのみが必要です。

build.gradle.ktsjava
dependencies {
    // Spring MVCを使用するSpring GraphQLスターター
    implementation("org.springframework.boot:spring-boot-starter-graphql")
    implementation("org.springframework.boot:spring-boot-starter-web")

    // GraphQLテスト用
    testImplementation("org.springframework.graphql:spring-graphql-test")
}

GraphQLスキーマはsrc/main/resources/graphql/.graphqls拡張子で配置されます。

graphql
# schema.graphqls
type Query {
    # 識別子で記事を取得
    article(id: ID!): Article

    # ページ分けされた記事リスト
    articles(page: Int = 0, size: Int = 10): [Article!]!
}

type Article {
    id: ID!
    title: String!
    content: String!
    # 著者との関係
    author: Author!
    # コメントのリスト
    comments: [Comment!]!
    createdAt: String!
}

type Author {
    id: ID!
    name: String!
    email: String!
    # この著者によって書かれた記事
    articles: [Article!]!
}

type Comment {
    id: ID!
    content: String!
    author: Author!
    createdAt: String!
}

このアーキテクチャは型とその関係を宣言します。GraphQLはドキュメントを自動生成し、サーバー側でクエリを検証します。

Spring GraphQLリゾルバーはどのように機能しますか?

リゾルバーはGraphQL実行の中核を構成します。各スキーマフィールドは専用のリゾルバーを持つことができます。Springはルートクエリには@QueryMappingアノテーションを、関係には@SchemaMappingを使用します。

ArticleController.javajava
@Controller
public class ArticleController {

    private final ArticleRepository articleRepository;
    private final AuthorRepository authorRepository;

    public ArticleController(ArticleRepository articleRepository,
                            AuthorRepository authorRepository) {
        this.articleRepository = articleRepository;
        this.authorRepository = authorRepository;
    }

    // Query.article(id)のリゾルバー
    @QueryMapping
    public Article article(@Argument Long id) {
        return articleRepository.findById(id)
            .orElseThrow(() -> new ArticleNotFoundException(id));
    }

    // Query.articles(page, size)のリゾルバー
    @QueryMapping
    public List<Article> articles(@Argument int page,
                                  @Argument int size) {
        Pageable pageable = PageRequest.of(page, size);
        return articleRepository.findAll(pageable).getContent();
    }

    // Article.authorのリゾルバー - 各記事に対して呼び出されます
    @SchemaMapping(typeName = "Article", field = "author")
    public Author author(Article article) {
        return authorRepository.findById(article.getAuthorId())
            .orElseThrow(() -> new AuthorNotFoundException(article.getAuthorId()));
    }
}

authorリゾルバーは返される各記事に対して実行されます。この柔軟なアーキテクチャはオンデマンドでデータを読み込みますが、N+1問題を引き起こします。

古典的な面接の罠

N+1問題に言及せずに@SchemaMappingリゾルバーを実装する候補者は、不完全なGraphQL理解を示します。採用担当者は系統的にこの分析を期待しています。

GraphQLにおけるN+1問題とは何ですか?

N+1問題は、GraphQLクエリが関係を読み込むためにN個の追加クエリをトリガーするときに発生します。この破壊的なパターンは、基本的なリゾルバーで系統的に発生します。

50件の記事を著者と共に取得するクエリを考えてみます:

graphql
# GraphQLクエリ
query {
    articles(size: 50) {
        id
        title
        author {
            name
        }
    }
}

前述のリゾルバーでは、このクエリは以下を実行します:

  • 50件の記事に対する1クエリ
  • 各著者を個別に読み込む50クエリ
sql
-- クエリ1: 記事の取得
SELECT * FROM articles LIMIT 50

-- クエリ2-51: 各著者を個別に
SELECT * FROM authors WHERE id = 1
SELECT * FROM authors WHERE id = 2
SELECT * FROM authors WHERE id = 3
-- ... 47個の追加クエリ

このクエリの増殖はパフォーマンスを劇的に低下させます。50msで応答するエンドポイントは、N+1で2秒に跳ね上がる可能性があります。

DataLoaderはどのように機能しますか?

DataLoaderは個別のクエリをバッチリクエストにグループ化します。各著者を別々に読み込む代わりに、DataLoaderは要求されたすべてのIDを収集し、単一のクエリを実行します。

AuthorBatchLoader.javajava
@Component
public class AuthorBatchLoader {

    private final AuthorRepository authorRepository;

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

    // バッチ読み込みメソッド
    public Mono<Map<Long, Author>> loadAuthors(Set<Long> authorIds) {
        // すべての著者に対する単一クエリ
        List<Author> authors = authorRepository.findAllById(authorIds);

        // 高速検索のためのMapへの変換
        Map<Long, Author> authorMap = authors.stream()
            .collect(Collectors.toMap(Author::getId, Function.identity()));

        return Mono.just(authorMap);
    }
}

DataLoaderの登録は専用構成内のBatchLoaderRegistryを介して行われます。

GraphQLConfig.javajava
@Configuration
public class GraphQLConfig {

    @Bean
    public BatchLoaderRegistry batchLoaderRegistry(
            AuthorBatchLoader authorBatchLoader) {

        return registry -> {
            // 著者用DataLoaderの登録
            registry.forTypePair(Long.class, Author.class)
                .registerMappedBatchLoader((authorIds, env) ->
                    authorBatchLoader.loadAuthors(authorIds));
        };
    }
}

変更されたリゾルバーは、直接アクセスの代わりにDataLoaderを使用するようになります:

ArticleController.javajava
@Controller
public class ArticleController {

    private final ArticleRepository articleRepository;

    public ArticleController(ArticleRepository articleRepository) {
        this.articleRepository = articleRepository;
    }

    @QueryMapping
    public List<Article> articles(@Argument int page,
                                  @Argument int size) {
        Pageable pageable = PageRequest.of(page, size);
        return articleRepository.findAll(pageable).getContent();
    }

    // DataLoaderで最適化されたリゾルバー
    @SchemaMapping(typeName = "Article", field = "author")
    public CompletableFuture<Author> author(
            Article article,
            DataLoader<Long, Author> authorDataLoader) {

        // DataLoaderは呼び出しを自動的にバッチ化します
        return authorDataLoader.load(article.getAuthorId());
    }
}

このアプローチでは、50件の記事に対する同じクエリは2つのSQLクエリのみを実行します:

sql
-- クエリ1: 記事の取得
SELECT * FROM articles LIMIT 50

-- クエリ2: すべての著者を一度に
SELECT * FROM authors WHERE id IN (1, 2, 3, 4, 5, ..., 50)

Spring Bootの面接対策はできていますか?

インタラクティブなシミュレーター、flashcards、技術テストで練習しましょう。

コンテキスト付きDataLoaderをどのように実装しますか?

高度なDataLoaderは、マルチテナントアプリケーションのテナント識別子やセキュリティフィルターなど、追加のコンテキストを必要とする場合があります。

SecuredAuthorBatchLoader.javajava
@Component
public class SecuredAuthorBatchLoader {

    private final AuthorRepository authorRepository;
    private final SecurityContextHolder securityContext;

    public SecuredAuthorBatchLoader(AuthorRepository authorRepository,
                                    SecurityContextHolder securityContext) {
        this.authorRepository = authorRepository;
        this.securityContext = securityContext;
    }

    public Mono<Map<Long, Author>> loadAuthorsForTenant(
            Set<Long> authorIds,
            BatchLoaderEnvironment env) {

        // GraphQLコンテキストの取得
        GraphQLContext context = env.getContext();
        String tenantId = context.get("tenantId");

        // テナントでフィルタリングされたクエリ
        List<Author> authors = authorRepository
            .findAllByIdInAndTenantId(authorIds, tenantId);

        Map<Long, Author> authorMap = authors.stream()
            .collect(Collectors.toMap(Author::getId, Function.identity()));

        return Mono.just(authorMap);
    }
}

コンテキスト構成はWebGraphQLインターセプターを介して行われます:

TenantInterceptor.javajava
@Component
public class TenantInterceptor implements WebGraphQlInterceptor {

    @Override
    public Mono<WebGraphQlResponse> intercept(
            WebGraphQlRequest request,
            Chain chain) {

        // ヘッダーからテナントを抽出
        String tenantId = request.getHeaders()
            .getFirst("X-Tenant-ID");

        // GraphQLコンテキストに追加
        request.configureExecutionInput((input, builder) ->
            builder.graphQLContext(ctx ->
                ctx.put("tenantId", tenantId)
            ).build()
        );

        return chain.next(request);
    }
}

@QueryMappingと@SchemaMappingの違いは何ですか?

この古典的な面接質問は、リゾルバー階層の理解を確認します。

| アノテーション | 用途 | 同等 | |----------------|------|------| | @QueryMapping | Query型のルートフィールド | @SchemaMapping(typeName = "Query") | | @MutationMapping | Mutation型のルートフィールド | @SchemaMapping(typeName = "Mutation") | | @SubscriptionMapping | リアルタイムサブスクリプション | @SchemaMapping(typeName = "Subscription") | | @SchemaMapping | 任意の型のすべてのフィールド | 汎用形式 |

EquivalenceDemo.javajava
@Controller
public class EquivalenceDemo {

    // これら2つの宣言は同等です
    @QueryMapping
    public Article article(@Argument Long id) {
        return findArticle(id);
    }

    @SchemaMapping(typeName = "Query", field = "article")
    public Article articleEquivalent(@Argument Long id) {
        return findArticle(id);
    }

    // ネストされたフィールドには@SchemaMappingのみが機能します
    @SchemaMapping(typeName = "Article", field = "author")
    public Author author(Article article) {
        return findAuthor(article.getAuthorId());
    }

    // 型をパラメータとして使用する代替構文
    @SchemaMapping
    public Author author(Article article) {
        // SpringはパラメータからtypeName = "Article"を推論します
        return findAuthor(article.getAuthorId());
    }
}

検証付きミューテーションをどのように管理しますか?

GraphQLミューテーションはデータを変更します。Spring GraphQLは入力を検証するためにBean Validationと統合します。

graphql
# schema.graphqls
type Mutation {
    createArticle(input: CreateArticleInput!): Article!
    updateArticle(id: ID!, input: UpdateArticleInput!): Article!
    deleteArticle(id: ID!): Boolean!
}

input CreateArticleInput {
    title: String!
    content: String!
    authorId: ID!
}

input UpdateArticleInput {
    title: String
    content: String
}

コントローラーは検証をトリガーするために@Validを使用します:

ArticleMutationController.javajava
@Controller
public class ArticleMutationController {

    private final ArticleService articleService;

    public ArticleMutationController(ArticleService articleService) {
        this.articleService = articleService;
    }

    @MutationMapping
    public Article createArticle(@Argument @Valid CreateArticleInput input) {
        // 検証が自動的に実行されます
        return articleService.create(input);
    }

    @MutationMapping
    public Article updateArticle(@Argument Long id,
                                 @Argument @Valid UpdateArticleInput input) {
        return articleService.update(id, input);
    }

    @MutationMapping
    public boolean deleteArticle(@Argument Long id) {
        articleService.delete(id);
        return true;
    }
}
CreateArticleInput.javajava
public record CreateArticleInput(
    @NotBlank(message = "タイトルは必須です")
    @Size(min = 5, max = 200, message = "タイトルは5から200文字の間でなければなりません")
    String title,

    @NotBlank(message = "コンテンツは必須です")
    @Size(min = 100, message = "コンテンツは少なくとも100文字でなければなりません")
    String content,

    @NotNull(message = "著者は必須です")
    Long authorId
) {}
エラー処理

検証エラーは、無効なフィールドのパスを含む構造化されたGraphQLエラーを自動的に返します。Spring GraphQLはこれらのエラーをGraphQL仕様に従ってフォーマットします。

@BatchMappingでクエリをどのように最適化しますか?

@BatchMappingアノテーションは、コントローラー内で直接DataLoaderの作成を簡素化します。このアプローチは明示的なBatchLoaderRegistry構成を回避します。

OptimizedArticleController.javajava
@Controller
public class OptimizedArticleController {

    private final ArticleRepository articleRepository;
    private final AuthorRepository authorRepository;
    private final CommentRepository commentRepository;

    public OptimizedArticleController(ArticleRepository articleRepository,
                                      AuthorRepository authorRepository,
                                      CommentRepository commentRepository) {
        this.articleRepository = articleRepository;
        this.authorRepository = authorRepository;
        this.commentRepository = commentRepository;
    }

    @QueryMapping
    public List<Article> articles(@Argument int page,
                                  @Argument int size) {
        return articleRepository.findAll(PageRequest.of(page, size))
            .getContent();
    }

    // 著者用BatchMapping - すべての記事を一度に処理
    @BatchMapping
    public Map<Article, Author> author(List<Article> articles) {
        // 一意の著者IDを収集
        Set<Long> authorIds = articles.stream()
            .map(Article::getAuthorId)
            .collect(Collectors.toSet());

        // すべての著者に対する単一クエリ
        Map<Long, Author> authorsById = authorRepository
            .findAllById(authorIds)
            .stream()
            .collect(Collectors.toMap(Author::getId, Function.identity()));

        // 記事 -> 著者の関連付け
        return articles.stream()
            .collect(Collectors.toMap(
                Function.identity(),
                article -> authorsById.get(article.getAuthorId())
            ));
    }

    // コメント用BatchMapping
    @BatchMapping
    public Map<Article, List<Comment>> comments(List<Article> articles) {
        List<Long> articleIds = articles.stream()
            .map(Article::getId)
            .toList();

        // コメントのバッチ取得
        List<Comment> allComments = commentRepository
            .findByArticleIdIn(articleIds);

        // 記事ごとにグループ化
        Map<Long, List<Comment>> commentsByArticle = allComments.stream()
            .collect(Collectors.groupingBy(Comment::getArticleId));

        return articles.stream()
            .collect(Collectors.toMap(
                Function.identity(),
                article -> commentsByArticle
                    .getOrDefault(article.getId(), List.of())
            ));
    }
}

@BatchMappingは、同じパフォーマンス保証を維持しながら、手動DataLoader構成よりも簡潔な構文を提供します。

GraphQLリゾルバーをどのようにテストしますか?

Spring GraphQLは、表現力豊かで読みやすいテストを書くためにGraphQlTesterを提供します。

ArticleControllerTest.javajava
@SpringBootTest
@AutoConfigureGraphQlTester
class ArticleControllerTest {

    @Autowired
    private GraphQlTester graphQlTester;

    @Test
    void shouldReturnArticleById() {
        // Given: GraphQLドキュメント
        String query = """
            query {
                article(id: 1) {
                    id
                    title
                    author {
                        name
                    }
                }
            }
            """;

        // When & Then
        graphQlTester.document(query)
            .execute()
            .path("article.id").entity(String.class).isEqualTo("1")
            .path("article.title").entity(String.class).isNotEmpty()
            .path("article.author.name").entity(String.class).isNotEmpty();
    }

    @Test
    void shouldCreateArticle() {
        String mutation = """
            mutation {
                createArticle(input: {
                    title: "新しいタイトル",
                    content: "検証制約を満たすのに十分な長さのテストコンテンツ",
                    authorId: 1
                }) {
                    id
                    title
                }
            }
            """;

        graphQlTester.document(mutation)
            .execute()
            .path("createArticle.id").entity(String.class).isNotEmpty()
            .path("createArticle.title").entity(String.class)
                .isEqualTo("新しいタイトル");
    }

    @Test
    void shouldReturnErrorForInvalidInput() {
        String mutation = """
            mutation {
                createArticle(input: {
                    title: "",
                    content: "短い",
                    authorId: 1
                }) {
                    id
                }
            }
            """;

        graphQlTester.document(mutation)
            .execute()
            .errors()
            .expect(error -> error.getMessage()
                .contains("タイトルは必須です"));
    }

    @Test
    void shouldHandleVariables() {
        String query = """
            query GetArticle($id: ID!) {
                article(id: $id) {
                    id
                    title
                }
            }
            """;

        graphQlTester.document(query)
            .variable("id", 42)
            .execute()
            .path("article").valueIsNull();
    }
}

今すぐ練習を始めましょう!

面接シミュレーターと技術テストで知識をテストしましょう。

リアルタイムサブスクリプションをどのように管理しますか?

サブスクリプションは、WebSocketを介してクライアントにリアルタイムデータをプッシュすることを可能にします。

graphql
# schema.graphqls
type Subscription {
    articleCreated: Article!
    commentAdded(articleId: ID!): Comment!
}
SubscriptionController.javajava
@Controller
public class SubscriptionController {

    private final Sinks.Many<Article> articleSink =
        Sinks.many().multicast().onBackpressureBuffer();

    private final Sinks.Many<Comment> commentSink =
        Sinks.many().multicast().onBackpressureBuffer();

    @SubscriptionMapping
    public Flux<Article> articleCreated() {
        return articleSink.asFlux();
    }

    @SubscriptionMapping
    public Flux<Comment> commentAdded(@Argument Long articleId) {
        return commentSink.asFlux()
            .filter(comment -> comment.getArticleId().equals(articleId));
    }

    // 記事作成時に呼び出されるメソッド
    public void publishArticle(Article article) {
        articleSink.tryEmitNext(article);
    }

    // コメント追加時に呼び出されるメソッド
    public void publishComment(Comment comment) {
        commentSink.tryEmitNext(comment);
    }
}

WebSocket構成には追加の依存関係が必要です:

build.gradle.ktsjava
dependencies {
    implementation("org.springframework.boot:spring-boot-starter-websocket")
}
WebSocketConfig.javajava
@Configuration
public class WebSocketConfig {

    @Bean
    public ServerEndpointExporter serverEndpointExporter() {
        return new ServerEndpointExporter();
    }
}

GraphQLセキュリティのベストプラクティスは何ですか?

GraphQLセキュリティには、サービス拒否攻撃や不正アクセスに対する複数の保護層が必要です。

SecurityConfig.javajava
@Configuration
@EnableMethodSecurity
public class SecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        return http
            .authorizeHttpRequests(auth -> auth
                // 保護されたGraphQLエンドポイント
                .requestMatchers("/graphql").authenticated()
                // GraphiQLは開発環境でのみアクセス可能
                .requestMatchers("/graphiql").permitAll()
            )
            .oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults()))
            .build();
    }
}
SecuredArticleController.javajava
@Controller
public class SecuredArticleController {

    private final ArticleRepository articleRepository;

    public SecuredArticleController(ArticleRepository articleRepository) {
        this.articleRepository = articleRepository;
    }

    // 認証されたユーザーに制限されたアクセス
    @QueryMapping
    @PreAuthorize("isAuthenticated()")
    public List<Article> articles(@Argument int page,
                                  @Argument int size) {
        return articleRepository.findAll(PageRequest.of(page, size))
            .getContent();
    }

    // 管理者のみが削除可能
    @MutationMapping
    @PreAuthorize("hasRole('ADMIN')")
    public boolean deleteArticle(@Argument Long id) {
        articleRepository.deleteById(id);
        return true;
    }

    // 細かいチェック: 著者のみが変更可能
    @MutationMapping
    @PreAuthorize("@articleSecurity.isAuthor(#id, authentication)")
    public Article updateArticle(@Argument Long id,
                                 @Argument UpdateArticleInput input) {
        return articleRepository.findById(id)
            .map(article -> {
                article.setTitle(input.title());
                article.setContent(input.content());
                return articleRepository.save(article);
            })
            .orElseThrow();
    }
}

複雑なクエリに対する保護:

yaml
# application.yml
spring:
  graphql:
    schema:
      introspection:
        # 本番環境ではイントロスペクションを無効化
        enabled: false
QueryComplexityInstrumentation.javajava
@Component
public class QueryComplexityInstrumentation extends SimplePerformantInstrumentation {

    private static final int MAX_COMPLEXITY = 100;

    @Override
    public InstrumentationState createState(InstrumentationCreateStateParameters parameters) {
        return new ComplexityState();
    }

    @Override
    public InstrumentationContext<ExecutionResult> beginExecution(
            InstrumentationExecutionParameters parameters,
            InstrumentationState state) {

        int complexity = calculateComplexity(parameters.getDocument());

        if (complexity > MAX_COMPLEXITY) {
            throw new GraphQLException(
                "クエリが複雑すぎます: " + complexity + " (max: " + MAX_COMPLEXITY + ")"
            );
        }

        return super.beginExecution(parameters, state);
    }
}

結論

Spring for GraphQLは、GraphQLをSpringエコシステムに優雅に統合します。リゾルバー、DataLoader、N+1問題の管理を習得することは、面接で期待される知識の基盤を構成します。

Spring GraphQL面接チェックリスト:

  • @QueryMapping@MutationMapping@SchemaMappingアノテーションの役割を説明する
  • ✅ N+1問題とそのGraphQLパフォーマンスへの影響を記述する
  • @BatchMappingまたはBatchLoaderRegistryでDataLoaderを実装する
  • ✅ Bean Validationでミューテーション入力を検証する
  • @PreAuthorizeとSpring Securityでリゾルバーを保護する
  • ✅ GraphQlTesterでリゾルバーをテストする
  • ✅ リアルタイム機能のためにサブスクリプションを構成する
  • ✅ APIを過度に複雑なクエリから保護する

タグ

#spring graphql
#dataloaders
#n+1 problem
#interview
#graphql java

共有

関連記事