Wawancara Spring GraphQL: Resolver, DataLoader, dan Solusi Masalah N+1

Persiapkan diri untuk wawancara Spring GraphQL dengan panduan lengkap ini. Resolver, DataLoader, penanganan masalah N+1, mutation, dan praktik terbaik untuk pertanyaan teknis.

Wawancara teknis Spring GraphQL dengan resolver dan DataLoader

Spring for GraphQL menyederhanakan integrasi GraphQL dalam aplikasi Spring Boot. Teknologi ini telah menjadi sangat penting untuk API modern, dan pertanyaan wawancara tentang topik ini semakin sering muncul. Panduan ini membahas konsep inti: resolver, DataLoader, masalah N+1, dan pola lanjutan.

Fokus Wawancara

Perekrut secara khusus menguji pemahaman tentang masalah N+1 dan penggunaan DataLoader. Kedua topik ini mewakili 60% pertanyaan dalam wawancara Spring GraphQL.

Apa itu Spring for GraphQL?

Spring for GraphQL adalah penerus resmi dari GraphQL Java Spring, terintegrasi secara native ke dalam framework Spring sejak versi 2.7. Integrasi ini memberikan beberapa keunggulan: dukungan anotasi Spring, integrasi dengan Spring Security, dan penggunaan WebFlux atau MVC yang transparan.

Konfigurasi dasar hanya memerlukan dependensi spring-boot-starter-graphql dan skema GraphQL.

build.gradle.ktsjava
dependencies {
    // Spring GraphQL starter dengan Spring MVC
    implementation("org.springframework.boot:spring-boot-starter-graphql")
    implementation("org.springframework.boot:spring-boot-starter-web")

    // Untuk pengujian GraphQL
    testImplementation("org.springframework.graphql:spring-graphql-test")
}

Skema GraphQL berada di src/main/resources/graphql/ dengan ekstensi .graphqls.

graphql
# schema.graphqls
type Query {
    # Mengambil artikel berdasarkan identifikatornya
    article(id: ID!): Article

    # Daftar artikel berhalaman
    articles(page: Int = 0, size: Int = 10): [Article!]!
}

type Article {
    id: ID!
    title: String!
    content: String!
    # Relasi dengan penulis
    author: Author!
    # Daftar komentar
    comments: [Comment!]!
    createdAt: String!
}

type Author {
    id: ID!
    name: String!
    email: String!
    # Artikel yang ditulis oleh penulis ini
    articles: [Article!]!
}

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

Arsitektur ini mendeklarasikan tipe dan relasinya. GraphQL secara otomatis menghasilkan dokumentasi dan memvalidasi query di sisi server.

Bagaimana Cara Kerja Resolver Spring GraphQL?

Resolver merupakan inti dari eksekusi GraphQL. Setiap field skema dapat memiliki resolver khusus. Spring menggunakan anotasi @QueryMapping untuk query root dan @SchemaMapping untuk relasi.

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;
    }

    // Resolver untuk Query.article(id)
    @QueryMapping
    public Article article(@Argument Long id) {
        return articleRepository.findById(id)
            .orElseThrow(() -> new ArticleNotFoundException(id));
    }

    // Resolver untuk 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();
    }

    // Resolver untuk Article.author - dipanggil untuk setiap artikel
    @SchemaMapping(typeName = "Article", field = "author")
    public Author author(Article article) {
        return authorRepository.findById(article.getAuthorId())
            .orElseThrow(() -> new AuthorNotFoundException(article.getAuthorId()));
    }
}

Resolver author dijalankan untuk setiap artikel yang dikembalikan. Arsitektur fleksibel ini memuat data sesuai permintaan tetapi memperkenalkan masalah N+1.

Jebakan Klasik Wawancara

Kandidat yang mengimplementasikan resolver @SchemaMapping tanpa menyebutkan masalah N+1 menunjukkan pemahaman GraphQL yang tidak lengkap. Perekrut secara sistematis mengharapkan analisis ini.

Apa itu Masalah N+1 di GraphQL?

Masalah N+1 muncul ketika query GraphQL memicu N query tambahan untuk memuat relasi. Pola destruktif ini terjadi secara sistematis dengan resolver dasar.

Pertimbangkan query yang mengambil 50 artikel beserta penulisnya:

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

Dengan resolver sebelumnya, query ini mengeksekusi:

  • 1 query untuk 50 artikel
  • 50 query untuk memuat setiap penulis secara individual
sql
-- Query 1: pengambilan artikel
SELECT * FROM articles LIMIT 50

-- Query 2-51: setiap penulis secara individual
SELECT * FROM authors WHERE id = 1
SELECT * FROM authors WHERE id = 2
SELECT * FROM authors WHERE id = 3
-- ... 47 query tambahan

Penggandaan query ini secara drastis menurunkan kinerja. Endpoint yang merespons dalam 50ms dapat melonjak menjadi 2 detik dengan N+1.

Bagaimana Cara Kerja DataLoader?

DataLoader mengelompokkan query individual menjadi permintaan batch. Alih-alih memuat setiap penulis secara terpisah, DataLoader mengumpulkan semua ID yang diminta dan mengeksekusi satu query.

AuthorBatchLoader.javajava
@Component
public class AuthorBatchLoader {

    private final AuthorRepository authorRepository;

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

    // Metode pemuatan batch
    public Mono<Map<Long, Author>> loadAuthors(Set<Long> authorIds) {
        // Query tunggal untuk semua penulis
        List<Author> authors = authorRepository.findAllById(authorIds);

        // Transformasi ke Map untuk pencarian cepat
        Map<Long, Author> authorMap = authors.stream()
            .collect(Collectors.toMap(Author::getId, Function.identity()));

        return Mono.just(authorMap);
    }
}

Pendaftaran DataLoader dilakukan melalui BatchLoaderRegistry dalam konfigurasi khusus.

GraphQLConfig.javajava
@Configuration
public class GraphQLConfig {

    @Bean
    public BatchLoaderRegistry batchLoaderRegistry(
            AuthorBatchLoader authorBatchLoader) {

        return registry -> {
            // Pendaftaran DataLoader untuk penulis
            registry.forTypePair(Long.class, Author.class)
                .registerMappedBatchLoader((authorIds, env) ->
                    authorBatchLoader.loadAuthors(authorIds));
        };
    }
}

Resolver yang dimodifikasi sekarang menggunakan DataLoader alih-alih akses langsung:

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();
    }

    // Resolver yang dioptimalkan dengan DataLoader
    @SchemaMapping(typeName = "Article", field = "author")
    public CompletableFuture<Author> author(
            Article article,
            DataLoader<Long, Author> authorDataLoader) {

        // DataLoader secara otomatis mengelompokkan panggilan
        return authorDataLoader.load(article.getAuthorId());
    }
}

Dengan pendekatan ini, query yang sama untuk 50 artikel hanya mengeksekusi 2 query SQL:

sql
-- Query 1: pengambilan artikel
SELECT * FROM articles LIMIT 50

-- Query 2: semua penulis sekaligus
SELECT * FROM authors WHERE id IN (1, 2, 3, 4, 5, ..., 50)

Siap menguasai wawancara Spring Boot Anda?

Berlatih dengan simulator interaktif, flashcards, dan tes teknis kami.

Bagaimana Cara Mengimplementasikan DataLoader dengan Konteks?

DataLoader lanjutan terkadang memerlukan konteks tambahan, seperti identifikator tenant untuk aplikasi multi-tenant atau filter keamanan.

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) {

        // Mengambil konteks GraphQL
        GraphQLContext context = env.getContext();
        String tenantId = context.get("tenantId");

        // Query difilter berdasarkan tenant
        List<Author> authors = authorRepository
            .findAllByIdInAndTenantId(authorIds, tenantId);

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

        return Mono.just(authorMap);
    }
}

Konfigurasi konteks dilakukan melalui interceptor WebGraphQL:

TenantInterceptor.javajava
@Component
public class TenantInterceptor implements WebGraphQlInterceptor {

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

        // Mengekstrak tenant dari header
        String tenantId = request.getHeaders()
            .getFirst("X-Tenant-ID");

        // Menambahkan ke konteks GraphQL
        request.configureExecutionInput((input, builder) ->
            builder.graphQLContext(ctx ->
                ctx.put("tenantId", tenantId)
            ).build()
        );

        return chain.next(request);
    }
}

Apa Perbedaan Antara @QueryMapping dan @SchemaMapping?

Pertanyaan wawancara klasik ini memverifikasi pemahaman tentang hierarki resolver.

| Anotasi | Penggunaan | Setara | |---------|------------|--------| | @QueryMapping | Field root tipe Query | @SchemaMapping(typeName = "Query") | | @MutationMapping | Field root tipe Mutation | @SchemaMapping(typeName = "Mutation") | | @SubscriptionMapping | Subscription waktu nyata | @SchemaMapping(typeName = "Subscription") | | @SchemaMapping | Semua field dari tipe apa pun | Bentuk umum |

EquivalenceDemo.javajava
@Controller
public class EquivalenceDemo {

    // Kedua deklarasi ini setara
    @QueryMapping
    public Article article(@Argument Long id) {
        return findArticle(id);
    }

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

    // Untuk field bersarang, hanya @SchemaMapping yang berfungsi
    @SchemaMapping(typeName = "Article", field = "author")
    public Author author(Article article) {
        return findAuthor(article.getAuthorId());
    }

    // Sintaksis alternatif dengan tipe sebagai parameter
    @SchemaMapping
    public Author author(Article article) {
        // Spring menyimpulkan typeName = "Article" dari parameter
        return findAuthor(article.getAuthorId());
    }
}

Bagaimana Mengelola Mutation dengan Validasi?

Mutation GraphQL memodifikasi data. Spring GraphQL terintegrasi dengan Bean Validation untuk memvalidasi input.

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
}

Controller menggunakan @Valid untuk memicu validasi:

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) {
        // Validasi dijalankan secara otomatis
        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 = "Judul wajib diisi")
    @Size(min = 5, max = 200, message = "Judul harus antara 5 dan 200 karakter")
    String title,

    @NotBlank(message = "Konten wajib diisi")
    @Size(min = 100, message = "Konten harus minimal 100 karakter")
    String content,

    @NotNull(message = "Penulis wajib diisi")
    Long authorId
) {}
Penanganan Kesalahan

Kesalahan validasi secara otomatis mengembalikan kesalahan GraphQL terstruktur dengan jalur field yang tidak valid. Spring GraphQL memformat kesalahan ini sesuai dengan spesifikasi GraphQL.

Bagaimana Mengoptimalkan Query dengan @BatchMapping?

Anotasi @BatchMapping menyederhanakan pembuatan DataLoader langsung di controller. Pendekatan ini menghindari konfigurasi BatchLoaderRegistry yang eksplisit.

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 untuk penulis - memproses semua artikel sekaligus
    @BatchMapping
    public Map<Article, Author> author(List<Article> articles) {
        // Mengumpulkan ID penulis unik
        Set<Long> authorIds = articles.stream()
            .map(Article::getAuthorId)
            .collect(Collectors.toSet());

        // Query tunggal untuk semua penulis
        Map<Long, Author> authorsById = authorRepository
            .findAllById(authorIds)
            .stream()
            .collect(Collectors.toMap(Author::getId, Function.identity()));

        // Asosiasi artikel -> penulis
        return articles.stream()
            .collect(Collectors.toMap(
                Function.identity(),
                article -> authorsById.get(article.getAuthorId())
            ));
    }

    // BatchMapping untuk komentar
    @BatchMapping
    public Map<Article, List<Comment>> comments(List<Article> articles) {
        List<Long> articleIds = articles.stream()
            .map(Article::getId)
            .toList();

        // Pengambilan batch komentar
        List<Comment> allComments = commentRepository
            .findByArticleIdIn(articleIds);

        // Pengelompokan per artikel
        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 menawarkan sintaksis yang lebih ringkas daripada konfigurasi DataLoader manual sambil mempertahankan jaminan kinerja yang sama.

Bagaimana Menguji Resolver GraphQL?

Spring GraphQL menyediakan GraphQlTester untuk menulis pengujian yang ekspresif dan mudah dibaca.

ArticleControllerTest.javajava
@SpringBootTest
@AutoConfigureGraphQlTester
class ArticleControllerTest {

    @Autowired
    private GraphQlTester graphQlTester;

    @Test
    void shouldReturnArticleById() {
        // Given: dokumen 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: "Judul baru",
                    content: "Konten pengujian yang cukup panjang untuk memenuhi batasan validasi",
                    authorId: 1
                }) {
                    id
                    title
                }
            }
            """;

        graphQlTester.document(mutation)
            .execute()
            .path("createArticle.id").entity(String.class).isNotEmpty()
            .path("createArticle.title").entity(String.class)
                .isEqualTo("Judul baru");
    }

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

        graphQlTester.document(mutation)
            .execute()
            .errors()
            .expect(error -> error.getMessage()
                .contains("Judul wajib diisi"));
    }

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

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

Mulai berlatih!

Uji pengetahuan Anda dengan simulator wawancara dan tes teknis kami.

Bagaimana Mengelola Subscription Waktu Nyata?

Subscription memungkinkan pengiriman data waktu nyata ke klien melalui 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));
    }

    // Metode yang dipanggil saat artikel dibuat
    public void publishArticle(Article article) {
        articleSink.tryEmitNext(article);
    }

    // Metode yang dipanggil saat komentar ditambahkan
    public void publishComment(Comment comment) {
        commentSink.tryEmitNext(comment);
    }
}

Konfigurasi WebSocket memerlukan dependensi tambahan:

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();
    }
}

Apa Praktik Terbaik Keamanan GraphQL?

Keamanan GraphQL memerlukan beberapa lapisan perlindungan terhadap serangan denial-of-service dan akses tidak sah.

SecurityConfig.javajava
@Configuration
@EnableMethodSecurity
public class SecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        return http
            .authorizeHttpRequests(auth -> auth
                // Endpoint GraphQL terlindungi
                .requestMatchers("/graphql").authenticated()
                // GraphiQL hanya dapat diakses di dev
                .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;
    }

    // Akses dibatasi untuk pengguna terautentikasi
    @QueryMapping
    @PreAuthorize("isAuthenticated()")
    public List<Article> articles(@Argument int page,
                                  @Argument int size) {
        return articleRepository.findAll(PageRequest.of(page, size))
            .getContent();
    }

    // Hanya administrator yang dapat menghapus
    @MutationMapping
    @PreAuthorize("hasRole('ADMIN')")
    public boolean deleteArticle(@Argument Long id) {
        articleRepository.deleteById(id);
        return true;
    }

    // Pemeriksaan halus: hanya penulis yang dapat memodifikasi
    @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();
    }
}

Perlindungan terhadap query kompleks:

yaml
# application.yml
spring:
  graphql:
    schema:
      introspection:
        # Nonaktifkan introspection di produksi
        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(
                "Query terlalu kompleks: " + complexity + " (max: " + MAX_COMPLEXITY + ")"
            );
        }

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

Kesimpulan

Spring for GraphQL menawarkan integrasi GraphQL yang elegan ke dalam ekosistem Spring. Penguasaan resolver, DataLoader, dan pengelolaan masalah N+1 merupakan dasar pengetahuan yang diharapkan dalam wawancara.

Daftar Periksa Wawancara Spring GraphQL:

  • ✅ Menjelaskan peran anotasi @QueryMapping, @MutationMapping, dan @SchemaMapping
  • ✅ Menjelaskan masalah N+1 dan dampaknya terhadap kinerja GraphQL
  • ✅ Mengimplementasikan DataLoader dengan @BatchMapping atau BatchLoaderRegistry
  • ✅ Memvalidasi input mutation dengan Bean Validation
  • ✅ Mengamankan resolver dengan @PreAuthorize dan Spring Security
  • ✅ Menguji resolver dengan GraphQlTester
  • ✅ Mengkonfigurasi subscription untuk fungsi waktu nyata
  • ✅ Melindungi API dari query yang terlalu kompleks

Tag

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

Bagikan

Artikel terkait