# สัมภาษณ์ Spring GraphQL: Resolver, DataLoader และวิธีแก้ปัญหา N+1 > เตรียมตัวสำหรับการสัมภาษณ์ Spring GraphQL ด้วยคู่มือที่ครบถ้วนนี้ Resolver, DataLoader, การจัดการปัญหา N+1, mutation และแนวปฏิบัติที่ดีที่สุดสำหรับคำถามทางเทคนิค - Published: 2026-03-25 - Updated: 2026-05-04 - Author: SharpSkill - Tags: spring graphql, dataloaders, n+1 problem, interview, graphql java - Reading time: 14 min --- Spring for GraphQL ทำให้การรวม GraphQL ในแอปพลิเคชัน Spring Boot ง่ายขึ้น เทคโนโลยีนี้กลายเป็นสิ่งจำเป็นสำหรับ API สมัยใหม่ และคำถามสัมภาษณ์เกี่ยวกับหัวข้อนี้พบได้บ่อยมากขึ้น คู่มือนี้ครอบคลุมแนวคิดหลัก: resolver, DataLoader, ปัญหา N+1 และรูปแบบขั้นสูง > **จุดเน้นการสัมภาษณ์** > > ผู้สรรหาทดสอบความเข้าใจเกี่ยวกับปัญหา N+1 และการใช้ DataLoader เป็นพิเศษ สองหัวข้อนี้คิดเป็น 60% ของคำถามในการสัมภาษณ์ Spring GraphQL ## Spring for GraphQL คืออะไร? Spring for GraphQL เป็นผู้สืบทอดอย่างเป็นทางการของ GraphQL Java Spring โดยถูกรวมเข้ากับ Spring framework โดยตรงตั้งแต่เวอร์ชัน 2.7 การรวมนี้นำมาซึ่งข้อดีหลายประการ: การรองรับ annotation ของ Spring, การรวมกับ Spring Security และการใช้ WebFlux หรือ MVC อย่างโปร่งใส การกำหนดค่าพื้นฐานต้องการเพียง dependency `spring-boot-starter-graphql` และ schema GraphQL ```java // build.gradle.kts dependencies { // Spring GraphQL starter พร้อม Spring MVC implementation("org.springframework.boot:spring-boot-starter-graphql") implementation("org.springframework.boot:spring-boot-starter-web") // สำหรับการทดสอบ GraphQL testImplementation("org.springframework.graphql:spring-graphql-test") } ``` Schema 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 จะสร้างเอกสารและตรวจสอบ query ที่ฝั่งเซิร์ฟเวอร์โดยอัตโนมัติ ## Resolver ของ Spring GraphQL ทำงานอย่างไร? Resolver เป็นหัวใจของการดำเนินการ GraphQL แต่ละฟิลด์ schema สามารถมี resolver เฉพาะได้ Spring ใช้ annotation `@QueryMapping` สำหรับ query ราก และ `@SchemaMapping` สำหรับความสัมพันธ์ ```java // ArticleController.java @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 สำหรับ Query.article(id) @QueryMapping public Article article(@Argument Long id) { return articleRepository.findById(id) .orElseThrow(() -> new ArticleNotFoundException(id)); } // Resolver สำหรับ Query.articles(page, size) @QueryMapping public List
articles(@Argument int page, @Argument int size) { Pageable pageable = PageRequest.of(page, size); return articleRepository.findAll(pageable).getContent(); } // Resolver สำหรับ Article.author - ถูกเรียกสำหรับแต่ละบทความ @SchemaMapping(typeName = "Article", field = "author") public Author author(Article article) { return authorRepository.findById(article.getAuthorId()) .orElseThrow(() -> new AuthorNotFoundException(article.getAuthorId())); } } ``` Resolver `author` ถูกดำเนินการสำหรับแต่ละบทความที่ส่งคืน สถาปัตยกรรมที่ยืดหยุ่นนี้โหลดข้อมูลตามต้องการแต่ทำให้เกิดปัญหา N+1 > **กับดักการสัมภาษณ์แบบคลาสสิก** > > ผู้สมัครที่ใช้ resolver `@SchemaMapping` โดยไม่กล่าวถึงปัญหา N+1 แสดงให้เห็นถึงความเข้าใจ GraphQL ที่ไม่สมบูรณ์ ผู้สรรหาคาดหวังการวิเคราะห์นี้อย่างเป็นระบบ ## ปัญหา N+1 ใน GraphQL คืออะไร? ปัญหา N+1 เกิดขึ้นเมื่อ query GraphQL กระตุ้น query เพิ่มเติม N รายการเพื่อโหลดความสัมพันธ์ รูปแบบที่ทำลายล้างนี้เกิดขึ้นอย่างเป็นระบบกับ resolver พื้นฐาน พิจารณา query ที่ดึงบทความ 50 รายการพร้อมผู้เขียน: ```graphql # Query GraphQL query { articles(size: 50) { id title author { name } } } ``` ด้วย resolver ก่อนหน้า query นี้ดำเนินการ: - 1 query สำหรับบทความ 50 รายการ - 50 query เพื่อโหลดผู้เขียนแต่ละคนแยกกัน ```sql -- Query 1: ดึงบทความ SELECT * FROM articles LIMIT 50 -- Query 2-51: ผู้เขียนแต่ละคนแยกกัน SELECT * FROM authors WHERE id = 1 SELECT * FROM authors WHERE id = 2 SELECT * FROM authors WHERE id = 3 -- ... อีก 47 query ``` การเพิ่มจำนวน query นี้ลดประสิทธิภาพอย่างมาก endpoint ที่ตอบสนองใน 50ms อาจกระโดดเป็น 2 วินาทีด้วย N+1 ## DataLoader ทำงานอย่างไร? DataLoader จัดกลุ่ม query แต่ละรายการเป็นคำขอแบบกลุ่ม แทนที่จะโหลดผู้เขียนแต่ละคนแยกกัน DataLoader จะรวบรวม ID ทั้งหมดที่ร้องขอและดำเนินการ query เพียงรายการเดียว ```java // AuthorBatchLoader.java @Component public class AuthorBatchLoader { private final AuthorRepository authorRepository; public AuthorBatchLoader(AuthorRepository authorRepository) { this.authorRepository = authorRepository; } // วิธีการโหลดแบบกลุ่ม public Mono> loadAuthors(Set authorIds) { // Query เดียวสำหรับผู้เขียนทั้งหมด List authors = authorRepository.findAllById(authorIds); // แปลงเป็น Map สำหรับการค้นหาที่รวดเร็ว Map authorMap = authors.stream() .collect(Collectors.toMap(Author::getId, Function.identity())); return Mono.just(authorMap); } } ``` การลงทะเบียน DataLoader ทำผ่าน `BatchLoaderRegistry` ในการกำหนดค่าเฉพาะ ```java // GraphQLConfig.java @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)); }; } } ``` Resolver ที่ปรับเปลี่ยนแล้วใช้ DataLoader แทนการเข้าถึงโดยตรง: ```java // ArticleController.java @Controller public class ArticleController { private final ArticleRepository articleRepository; public ArticleController(ArticleRepository articleRepository) { this.articleRepository = articleRepository; } @QueryMapping public List
articles(@Argument int page, @Argument int size) { Pageable pageable = PageRequest.of(page, size); return articleRepository.findAll(pageable).getContent(); } // Resolver ที่ปรับให้เหมาะสมด้วย DataLoader @SchemaMapping(typeName = "Article", field = "author") public CompletableFuture author( Article article, DataLoader authorDataLoader) { // DataLoader จัดกลุ่มการเรียกโดยอัตโนมัติ return authorDataLoader.load(article.getAuthorId()); } } ``` ด้วยวิธีการนี้ query เดียวกันสำหรับบทความ 50 รายการดำเนินการเพียง 2 query SQL: ```sql -- Query 1: ดึงบทความ SELECT * FROM articles LIMIT 50 -- Query 2: ผู้เขียนทั้งหมดในครั้งเดียว SELECT * FROM authors WHERE id IN (1, 2, 3, 4, 5, ..., 50) ``` ## วิธีการใช้งาน DataLoader พร้อม Context? DataLoader ขั้นสูงบางครั้งต้องการ context เพิ่มเติม เช่น ตัวระบุ tenant สำหรับแอปพลิเคชันหลาย tenant หรือตัวกรองความปลอดภัย ```java // SecuredAuthorBatchLoader.java @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> loadAuthorsForTenant( Set authorIds, BatchLoaderEnvironment env) { // ดึง context GraphQL GraphQLContext context = env.getContext(); String tenantId = context.get("tenantId"); // Query ที่กรองตาม tenant List authors = authorRepository .findAllByIdInAndTenantId(authorIds, tenantId); Map authorMap = authors.stream() .collect(Collectors.toMap(Author::getId, Function.identity())); return Mono.just(authorMap); } } ``` การกำหนดค่า context ทำผ่าน WebGraphQL interceptor: ```java // TenantInterceptor.java @Component public class TenantInterceptor implements WebGraphQlInterceptor { @Override public Mono intercept( WebGraphQlRequest request, Chain chain) { // ดึง tenant จาก headers String tenantId = request.getHeaders() .getFirst("X-Tenant-ID"); // เพิ่มใน context GraphQL request.configureExecutionInput((input, builder) -> builder.graphQLContext(ctx -> ctx.put("tenantId", tenantId) ).build() ); return chain.next(request); } } ``` ## ความแตกต่างระหว่าง @QueryMapping และ @SchemaMapping คืออะไร? คำถามสัมภาษณ์แบบคลาสสิกนี้ตรวจสอบความเข้าใจในลำดับชั้นของ resolver | Annotation | การใช้งาน | เทียบเท่า | |------------|-----------|-----------| | `@QueryMapping` | ฟิลด์รากของประเภท Query | `@SchemaMapping(typeName = "Query")` | | `@MutationMapping` | ฟิลด์รากของประเภท Mutation | `@SchemaMapping(typeName = "Mutation")` | | `@SubscriptionMapping` | Subscription แบบเรียลไทม์ | `@SchemaMapping(typeName = "Subscription")` | | `@SchemaMapping` | ทุกฟิลด์ของทุกประเภท | รูปแบบทั่วไป | ```java // EquivalenceDemo.java @Controller public class EquivalenceDemo { // การประกาศสองแบบนี้เทียบเท่ากัน @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()); } } ``` ## วิธีการจัดการ Mutation พร้อมการตรวจสอบ? Mutation 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 } ``` Controller ใช้ `@Valid` เพื่อกระตุ้นการตรวจสอบ: ```java // ArticleMutationController.java @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; } } ``` ```java // CreateArticleInput.java 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 ## วิธีปรับ Query ให้เหมาะสมด้วย @BatchMapping? Annotation `@BatchMapping` ทำให้การสร้าง DataLoader ในตัว controller ง่ายขึ้นโดยตรง วิธีการนี้หลีกเลี่ยงการกำหนดค่า BatchLoaderRegistry อย่างชัดเจน ```java // OptimizedArticleController.java @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
articles(@Argument int page, @Argument int size) { return articleRepository.findAll(PageRequest.of(page, size)) .getContent(); } // BatchMapping สำหรับผู้เขียน - ประมวลผลบทความทั้งหมดในครั้งเดียว @BatchMapping public Map author(List
articles) { // รวบรวม ID ผู้เขียนที่ไม่ซ้ำกัน Set authorIds = articles.stream() .map(Article::getAuthorId) .collect(Collectors.toSet()); // Query เดียวสำหรับผู้เขียนทั้งหมด Map 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> comments(List
articles) { List articleIds = articles.stream() .map(Article::getId) .toList(); // ดึงความคิดเห็นแบบกลุ่ม List allComments = commentRepository .findByArticleIdIn(articleIds); // จัดกลุ่มตามบทความ Map> 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 ด้วยตนเอง โดยยังคงรับประกันประสิทธิภาพเหมือนกัน ## วิธีทดสอบ Resolver GraphQL? Spring GraphQL ให้ `GraphQlTester` สำหรับการเขียนการทดสอบที่แสดงออกและอ่านง่าย ```java // ArticleControllerTest.java @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(); } } ``` ## วิธีจัดการ Subscription แบบเรียลไทม์? Subscription ช่วยให้สามารถส่งข้อมูลแบบเรียลไทม์ไปยังไคลเอนต์ผ่าน WebSocket ```graphql # schema.graphqls type Subscription { articleCreated: Article! commentAdded(articleId: ID!): Comment! } ``` ```java // SubscriptionController.java @Controller public class SubscriptionController { private final Sinks.Many
articleSink = Sinks.many().multicast().onBackpressureBuffer(); private final Sinks.Many commentSink = Sinks.many().multicast().onBackpressureBuffer(); @SubscriptionMapping public Flux
articleCreated() { return articleSink.asFlux(); } @SubscriptionMapping public Flux 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 ต้องใช้ dependency เพิ่มเติม: ```java // build.gradle.kts dependencies { implementation("org.springframework.boot:spring-boot-starter-websocket") } ``` ```java // WebSocketConfig.java @Configuration public class WebSocketConfig { @Bean public ServerEndpointExporter serverEndpointExporter() { return new ServerEndpointExporter(); } } ``` ## แนวปฏิบัติที่ดีที่สุดด้านความปลอดภัยของ GraphQL คืออะไร? ความปลอดภัยของ GraphQL ต้องมีการป้องกันหลายชั้นต่อการโจมตีปฏิเสธบริการและการเข้าถึงโดยไม่ได้รับอนุญาต ```java // SecurityConfig.java @Configuration @EnableMethodSecurity public class SecurityConfig { @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { return http .authorizeHttpRequests(auth -> auth // Endpoint GraphQL ที่ได้รับการป้องกัน .requestMatchers("/graphql").authenticated() // GraphiQL เข้าถึงได้เฉพาะใน dev เท่านั้น .requestMatchers("/graphiql").permitAll() ) .oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults())) .build(); } } ``` ```java // SecuredArticleController.java @Controller public class SecuredArticleController { private final ArticleRepository articleRepository; public SecuredArticleController(ArticleRepository articleRepository) { this.articleRepository = articleRepository; } // จำกัดการเข้าถึงสำหรับผู้ใช้ที่ตรวจสอบสิทธิ์แล้ว @QueryMapping @PreAuthorize("isAuthenticated()") public List
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(); } } ``` การป้องกันต่อ query ที่ซับซ้อน: ```yaml # application.yml spring: graphql: schema: introspection: # ปิดใช้งาน introspection ในโปรดักชัน enabled: false ``` ```java // QueryComplexityInstrumentation.java @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 beginExecution( InstrumentationExecutionParameters parameters, InstrumentationState state) { int complexity = calculateComplexity(parameters.getDocument()); if (complexity > MAX_COMPLEXITY) { throw new GraphQLException( "Query ซับซ้อนเกินไป: " + complexity + " (max: " + MAX_COMPLEXITY + ")" ); } return super.beginExecution(parameters, state); } } ``` ## บทสรุป Spring for GraphQL ให้การรวม GraphQL ที่หรูหราเข้ากับระบบนิเวศ Spring การเชี่ยวชาญ resolver, DataLoader และการจัดการปัญหา N+1 เป็นรากฐานของความรู้ที่คาดหวังในการสัมภาษณ์ **รายการตรวจสอบการสัมภาษณ์ Spring GraphQL:** - ✅ อธิบายบทบาทของ annotation `@QueryMapping`, `@MutationMapping` และ `@SchemaMapping` - ✅ อธิบายปัญหา N+1 และผลกระทบต่อประสิทธิภาพของ GraphQL - ✅ ใช้งาน DataLoader ด้วย `@BatchMapping` หรือ BatchLoaderRegistry - ✅ ตรวจสอบอินพุต mutation ด้วย Bean Validation - ✅ รักษาความปลอดภัยของ resolver ด้วย `@PreAuthorize` และ Spring Security - ✅ ทดสอบ resolver ด้วย GraphQlTester - ✅ กำหนดค่า subscription สำหรับฟังก์ชันแบบเรียลไทม์ - ✅ ปกป้อง API จาก query ที่ซับซ้อนเกินไป --- Source: SharpSkill (https://sharpskill.dev), tech interview preparation for your real stack. HTML version of this page: https://sharpskill.dev/th/blog/spring-boot/spring-graphql-interview-resolvers-dataloaders-n1