Spring GraphQL 면접: Resolver, DataLoader 및 N+1 문제 해결책
이 완전한 가이드로 Spring GraphQL 면접을 준비합니다. Resolver, DataLoader, N+1 문제 처리, mutation 및 기술 질문을 위한 모범 사례를 다룹니다.

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 스키마만 필요합니다.
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/에 위치합니다.
# 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을 사용합니다.
@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<Article> 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 쿼리
query {
articles(size: 50) {
id
title
author {
name
}
}
}이전 resolver를 사용하면 이 쿼리는 다음을 실행합니다:
- 50개 글에 대한 1개 쿼리
- 각 저자를 개별적으로 로드하기 위한 50개 쿼리
-- 쿼리 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를 수집하고 단일 쿼리를 실행합니다.
@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를 통해 이루어집니다.
@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를 사용합니다:
@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로 최적화된 Resolver
@SchemaMapping(typeName = "Article", field = "author")
public CompletableFuture<Author> author(
Article article,
DataLoader<Long, Author> authorDataLoader) {
// DataLoader는 호출을 자동으로 배치 처리합니다
return authorDataLoader.load(article.getAuthorId());
}
}이 접근 방식으로 50개 글에 대한 동일한 쿼리는 단 2개의 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는 때때로 멀티 테넌트 애플리케이션을 위한 테넌트 식별자나 보안 필터와 같은 추가 컨텍스트가 필요합니다.
@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 interceptor를 통해 이루어집니다:
@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의 차이점은 무엇입니까?
이 고전적인 면접 질문은 resolver 계층에 대한 이해를 검증합니다.
| Annotation | 사용 | 동등 |
|------------|------|------|
| @QueryMapping | Query 타입의 루트 필드 | @SchemaMapping(typeName = "Query") |
| @MutationMapping | Mutation 타입의 루트 필드 | @SchemaMapping(typeName = "Mutation") |
| @SubscriptionMapping | 실시간 구독 | @SchemaMapping(typeName = "Subscription") |
| @SchemaMapping | 모든 타입의 모든 필드 | 일반적인 형식 |
@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과 통합됩니다.
# 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를 사용합니다:
@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;
}
}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 구성을 피합니다.
@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 Resolver를 어떻게 테스트합니까?
Spring GraphQL은 표현력 있고 읽기 쉬운 테스트를 작성하기 위해 GraphQlTester를 제공합니다.
@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을 통해 클라이언트에 실시간 데이터를 푸시할 수 있게 합니다.
# schema.graphqls
type Subscription {
articleCreated: Article!
commentAdded(articleId: ID!): Comment!
}@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 구성에는 추가 의존성이 필요합니다:
dependencies {
implementation("org.springframework.boot:spring-boot-starter-websocket")
}@Configuration
public class WebSocketConfig {
@Bean
public ServerEndpointExporter serverEndpointExporter() {
return new ServerEndpointExporter();
}
}GraphQL 보안의 모범 사례는 무엇입니까?
GraphQL 보안에는 서비스 거부 공격과 무단 액세스에 대한 여러 보호 계층이 필요합니다.
@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();
}
}@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();
}
}복잡한 쿼리에 대한 보호:
# application.yml
spring:
graphql:
schema:
introspection:
# 프로덕션에서 introspection 비활성화
enabled: false@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 생태계에 우아하게 통합합니다. Resolver, DataLoader 및 N+1 문제 관리에 대한 숙달은 면접에서 기대되는 지식의 기반을 구성합니다.
Spring GraphQL 면접 체크리스트:
- ✅
@QueryMapping,@MutationMapping및@SchemaMappingannotation의 역할 설명 - ✅ N+1 문제와 GraphQL 성능에 미치는 영향 설명
- ✅
@BatchMapping또는 BatchLoaderRegistry로 DataLoader 구현 - ✅ Bean Validation으로 mutation 입력 검증
- ✅
@PreAuthorize와 Spring Security로 resolver 보호 - ✅ GraphQlTester로 resolver 테스트
- ✅ 실시간 기능을 위한 구독 구성
- ✅ 너무 복잡한 쿼리로부터 API 보호
태그
공유
관련 기사

Spring Boot 면접: 트랜잭션 전파 설명
Spring Boot 트랜잭션 전파 마스터하기: REQUIRED, REQUIRES_NEW, NESTED 등. 코드 예제와 일반적인 함정을 포함한 12가지 면접 질문.

2026년 Spring Data JPA N+1 문제 해결법: Fetch Join과 EntityGraph
Spring Data JPA의 N+1 문제를 탐지하고 해결하기 위한 완벽 가이드입니다. Fetch join, @EntityGraph, batch fetching, 쿼리 성능 전략을 다룹니다.

Spring Boot 면접 질문 30선: 자바 개발자를 위한 완벽 가이드
오토 컨피그레이션, 스타터, Spring Data JPA, 보안, 테스트를 망라한 30문항으로 Spring Boot 면접을 준비하십시오.