# 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은 Spring Boot 애플리케이션에서 GraphQL 통합을 단순화합니다. 이 기술은 현대 API에 필수적이 되었으며 이 주제에 관한 면접 질문이 점점 더 일반적이 되고 있습니다. 본 가이드는 핵심 개념인 resolver, DataLoader, N+1 문제 및 고급 패턴을 다룹니다. > **면접 중점** > > 채용 담당자는 특히 N+1 문제에 대한 이해와 DataLoader 사용을 평가합니다. 이 두 주제는 Spring GraphQL 면접 질문의 60%를 차지합니다. ## Spring for GraphQL이란 무엇입니까? Spring for GraphQL은 GraphQL Java Spring의 공식 후속작이며 2.7 버전부터 Spring 프레임워크에 네이티브로 통합되어 있습니다. 이 통합은 여러 가지 장점을 제공합니다: Spring annotation 지원, Spring Security와의 통합, WebFlux 또는 MVC의 투명한 사용. 기본 구성에는 `spring-boot-starter-graphql` 의존성과 GraphQL 스키마만 필요합니다. ```java // build.gradle.kts dependencies { // Spring MVC를 사용하는 Spring GraphQL starter 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 스키마는 `.graphqls` 확장자로 `src/main/resources/graphql/`에 위치합니다. ```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 Resolver는 어떻게 작동합니까? Resolver는 GraphQL 실행의 핵심을 구성합니다. 각 스키마 필드는 전용 resolver를 가질 수 있습니다. Spring은 루트 쿼리에 `@QueryMapping` annotation을, 관계에는 `@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; } // Query.article(id)에 대한 Resolver @QueryMapping public Article article(@Argument Long id) { return articleRepository.findById(id) .orElseThrow(() -> new ArticleNotFoundException(id)); } // Query.articles(page, size)에 대한 Resolver @QueryMapping public List
articles(@Argument int page, @Argument int size) { Pageable pageable = PageRequest.of(page, size); return articleRepository.findAll(pageable).getContent(); } // Article.author에 대한 Resolver - 각 글마다 호출됨 @SchemaMapping(typeName = "Article", field = "author") public Author author(Article article) { return authorRepository.findById(article.getAuthorId()) .orElseThrow(() -> new AuthorNotFoundException(article.getAuthorId())); } } ``` `author` resolver는 반환된 각 글에 대해 실행됩니다. 이 유연한 아키텍처는 요청에 따라 데이터를 로드하지만 N+1 문제를 야기합니다. > **고전적인 면접 함정** > > N+1 문제를 언급하지 않고 `@SchemaMapping` resolver를 구현하는 후보자는 불완전한 GraphQL 이해를 보여줍니다. 채용 담당자는 체계적으로 이 분석을 기대합니다. ## GraphQL의 N+1 문제란 무엇입니까? N+1 문제는 GraphQL 쿼리가 관계를 로드하기 위해 N개의 추가 쿼리를 트리거할 때 발생합니다. 이 파괴적인 패턴은 기본 resolver에서 체계적으로 발생합니다. 저자와 함께 50개 글을 가져오는 쿼리를 고려해 봅시다: ```graphql # GraphQL 쿼리 query { articles(size: 50) { id title author { name } } } ``` 이전 resolver를 사용하면 이 쿼리는 다음을 실행합니다: - 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에 응답하는 endpoint가 N+1로 2초까지 늘어날 수 있습니다. ## DataLoader는 어떻게 작동합니까? DataLoader는 개별 쿼리를 배치 요청으로 그룹화합니다. 각 저자를 별도로 로드하는 대신 DataLoader는 요청된 모든 ID를 수집하고 단일 쿼리를 실행합니다. ```java // AuthorBatchLoader.java @Component public class AuthorBatchLoader { private final AuthorRepository authorRepository; public AuthorBatchLoader(AuthorRepository authorRepository) { this.authorRepository = authorRepository; } // 배치 로딩 메서드 public Mono> loadAuthors(Set authorIds) { // 모든 저자에 대한 단일 쿼리 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(); } // DataLoader로 최적화된 Resolver @SchemaMapping(typeName = "Article", field = "author") public CompletableFuture author( Article article, DataLoader 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) ``` ## 컨텍스트가 있는 DataLoader를 어떻게 구현합니까? 고급 DataLoader는 때때로 멀티 테넌트 애플리케이션을 위한 테넌트 식별자나 보안 필터와 같은 추가 컨텍스트가 필요합니다. ```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) { // GraphQL 컨텍스트 가져오기 GraphQLContext context = env.getContext(); String tenantId = context.get("tenantId"); // 테넌트로 필터링된 쿼리 List authors = authorRepository .findAllByIdInAndTenantId(authorIds, tenantId); Map authorMap = authors.stream() .collect(Collectors.toMap(Author::getId, Function.identity())); return Mono.just(authorMap); } } ``` 컨텍스트 구성은 WebGraphQL interceptor를 통해 이루어집니다: ```java // TenantInterceptor.java @Component public class TenantInterceptor implements WebGraphQlInterceptor { @Override public Mono 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의 차이점은 무엇입니까? 이 고전적인 면접 질문은 resolver 계층에 대한 이해를 검증합니다. | Annotation | 사용 | 동등 | |------------|------|------| | `@QueryMapping` | Query 타입의 루트 필드 | `@SchemaMapping(typeName = "Query")` | | `@MutationMapping` | Mutation 타입의 루트 필드 | `@SchemaMapping(typeName = "Mutation")` | | `@SubscriptionMapping` | 실시간 구독 | `@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을 어떻게 관리합니까? GraphQL mutation은 데이터를 수정합니다. 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`를 사용합니다: ```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 사양에 따라 이러한 오류를 형식화합니다. ## @BatchMapping으로 쿼리를 어떻게 최적화합니까? `@BatchMapping` annotation은 컨트롤러에서 직접 DataLoader 생성을 단순화합니다. 이 접근 방식은 명시적인 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()); // 모든 저자에 대한 단일 쿼리 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 구성보다 더 간결한 구문을 제공합니다. ## GraphQL Resolver를 어떻게 테스트합니까? 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(); } } ``` ## 실시간 구독을 어떻게 관리합니까? 구독은 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 구성에는 추가 의존성이 필요합니다: ```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 // 보호된 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(); } } ``` 복잡한 쿼리에 대한 보호: ```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( "쿼리가 너무 복잡합니다: " + complexity + " (max: " + MAX_COMPLEXITY + ")" ); } return super.beginExecution(parameters, state); } } ``` ## 결론 Spring for GraphQL은 GraphQL을 Spring 생태계에 우아하게 통합합니다. Resolver, DataLoader 및 N+1 문제 관리에 대한 숙달은 면접에서 기대되는 지식의 기반을 구성합니다. **Spring GraphQL 면접 체크리스트:** - ✅ `@QueryMapping`, `@MutationMapping` 및 `@SchemaMapping` annotation의 역할 설명 - ✅ N+1 문제와 GraphQL 성능에 미치는 영향 설명 - ✅ `@BatchMapping` 또는 BatchLoaderRegistry로 DataLoader 구현 - ✅ Bean Validation으로 mutation 입력 검증 - ✅ `@PreAuthorize`와 Spring Security로 resolver 보호 - ✅ GraphQlTester로 resolver 테스트 - ✅ 실시간 기능을 위한 구독 구성 - ✅ 너무 복잡한 쿼리로부터 API 보호 --- Source: SharpSkill (https://sharpskill.dev), tech interview preparation for your real stack. HTML version of this page: https://sharpskill.dev/ko/blog/spring-boot/spring-graphql-interview-resolvers-dataloaders-n1