Spring for GraphQL이란?
Spring for GraphQL은 Spring Boot 3에서 공식 지원하는 GraphQL 통합 프레임워크다. 기존 graphql-java를 기반으로 어노테이션 기반 컨트롤러, DataLoader 통합, 보안, WebSocket 구독을 Spring 방식으로 제공한다. REST API 대비 오버페칭/언더페칭 문제를 해결하고, 클라이언트가 필요한 데이터만 정확히 요청할 수 있다.
프로젝트 설정
# build.gradle.kts
dependencies {
implementation("org.springframework.boot:spring-boot-starter-graphql")
implementation("org.springframework.boot:spring-boot-starter-web")
// WebSocket 구독용
implementation("org.springframework.boot:spring-boot-starter-websocket")
testImplementation("org.springframework.graphql:spring-graphql-test")
}
# application.yml
spring:
graphql:
graphiql:
enabled: true # GraphiQL IDE 활성화
path: /graphiql
schema:
printer:
enabled: true # 스키마 내성(introspection) 활성화
websocket:
path: /graphql # WebSocket 구독 경로
# src/main/resources/graphql/schema.graphqls
type Query {
user(id: ID!): User
users(page: Int = 0, size: Int = 20): UserConnection!
searchPosts(keyword: String!, limit: Int = 10): [Post!]!
}
type Mutation {
createUser(input: CreateUserInput!): User!
updatePost(id: ID!, input: UpdatePostInput!): Post!
deletePost(id: ID!): Boolean!
}
type Subscription {
postCreated: Post!
orderStatusChanged(orderId: ID!): Order!
}
type User {
id: ID!
name: String!
email: String!
posts(status: PostStatus): [Post!]!
postCount: Int!
}
type Post {
id: ID!
title: String!
content: String
author: User!
tags: [Tag!]!
createdAt: String!
}
type UserConnection {
content: [User!]!
totalElements: Int!
totalPages: Int!
hasNext: Boolean!
}
input CreateUserInput {
name: String!
email: String!
role: Role = USER
}
input UpdatePostInput {
title: String
content: String
published: Boolean
}
enum PostStatus { DRAFT, PUBLISHED, ARCHIVED }
enum Role { USER, ADMIN }
@QueryMapping / @MutationMapping
@Controller
@RequiredArgsConstructor
public class UserGraphQLController {
private final UserService userService;
@QueryMapping
public User user(@Argument Long id) {
return userService.findById(id)
.orElseThrow(() -> new UserNotFoundException(id));
}
@QueryMapping
public UserConnection users(@Argument int page, @Argument int size) {
Page<User> result = userService.findAll(PageRequest.of(page, size));
return new UserConnection(
result.getContent(),
result.getTotalElements(),
result.getTotalPages(),
result.hasNext()
);
}
@MutationMapping
public User createUser(@Argument CreateUserInput input) {
return userService.create(input);
}
}
@SchemaMapping: 필드 리졸버
@Controller
@RequiredArgsConstructor
public class UserFieldResolver {
private final PostRepository postRepository;
// User.posts 필드 리졸빙
@SchemaMapping(typeName = "User", field = "posts")
public List<Post> posts(User user, @Argument PostStatus status) {
if (status != null) {
return postRepository.findByAuthorIdAndStatus(user.getId(), status);
}
return postRepository.findByAuthorId(user.getId());
}
// User.postCount 가상 필드
@SchemaMapping(typeName = "User")
public int postCount(User user) {
return postRepository.countByAuthorId(user.getId());
}
}
@Controller
@RequiredArgsConstructor
public class PostFieldResolver {
private final UserRepository userRepository;
private final TagRepository tagRepository;
// Post.author 필드
@SchemaMapping(typeName = "Post")
public User author(Post post) {
return userRepository.findById(post.getAuthorId()).orElse(null);
}
// Post.tags 필드
@SchemaMapping(typeName = "Post")
public List<Tag> tags(Post post) {
return tagRepository.findByPostId(post.getId());
}
}
@BatchMapping: N+1 문제 해결
필드 리졸버는 각 부모 객체마다 개별 쿼리를 실행하므로 N+1 문제가 발생한다. @BatchMapping은 여러 부모를 한 번에 처리하여 이를 해결한다.
@Controller
@RequiredArgsConstructor
public class BatchFieldResolver {
private final PostRepository postRepository;
private final UserRepository userRepository;
// N+1 해결: 모든 User의 posts를 한 번에 조회
@BatchMapping(typeName = "User", field = "posts")
public Map<User, List<Post>> posts(List<User> users) {
List<Long> userIds = users.stream()
.map(User::getId)
.toList();
List<Post> allPosts = postRepository.findByAuthorIdIn(userIds);
// User별로 그룹핑
Map<Long, List<Post>> postsByUserId = allPosts.stream()
.collect(Collectors.groupingBy(Post::getAuthorId));
return users.stream()
.collect(Collectors.toMap(
user -> user,
user -> postsByUserId.getOrDefault(user.getId(), List.of())
));
}
// Post.author 배치 리졸빙
@BatchMapping(typeName = "Post", field = "author")
public Map<Post, User> authors(List<Post> posts) {
Set<Long> authorIds = posts.stream()
.map(Post::getAuthorId)
.collect(Collectors.toSet());
Map<Long, User> usersById = userRepository.findAllById(authorIds)
.stream()
.collect(Collectors.toMap(User::getId, u -> u));
return posts.stream()
.collect(Collectors.toMap(
post -> post,
post -> usersById.get(post.getAuthorId())
));
}
}
@BatchMapping은 내부적으로 DataLoader를 자동 생성한다. JPA N+1 해결 전략의 @BatchSize와 유사하지만 GraphQL 레이어에서 동작한다.
Subscription: 실시간 데이터
@Controller
@RequiredArgsConstructor
public class PostSubscriptionController {
// Reactor Flux 기반 구독
@SubscriptionMapping
public Flux<Post> postCreated() {
return postEventPublisher.getPostStream();
}
@SubscriptionMapping
public Flux<Order> orderStatusChanged(@Argument Long orderId) {
return orderEventPublisher.getOrderStream()
.filter(order -> order.getId().equals(orderId));
}
}
// 이벤트 발행기
@Component
public class PostEventPublisher {
private final Sinks.Many<Post> sink = Sinks.many().multicast().onBackpressureBuffer();
public void publish(Post post) {
sink.tryEmitNext(post);
}
public Flux<Post> getPostStream() {
return sink.asFlux();
}
}
에러 처리와 보안
// 커스텀 예외 → GraphQL 에러 변환
@Component
public class GraphQLExceptionResolver extends DataFetcherExceptionResolverAdapter {
@Override
protected GraphQLError resolveToSingleError(Throwable ex,
DataFetchingEnvironment env) {
if (ex instanceof UserNotFoundException) {
return GraphqlErrorBuilder.newError(env)
.errorType(ErrorType.NOT_FOUND)
.message(ex.getMessage())
.build();
}
if (ex instanceof AccessDeniedException) {
return GraphqlErrorBuilder.newError(env)
.errorType(ErrorType.FORBIDDEN)
.message("접근 권한이 없습니다")
.build();
}
return null; // 기본 처리로 위임
}
}
// 보안: @PreAuthorize와 통합
@Controller
public class SecuredGraphQLController {
@QueryMapping
@PreAuthorize("hasRole('ADMIN')")
public List<User> allUsers() { ... }
@MutationMapping
@PreAuthorize("isAuthenticated()")
public Post createPost(@Argument CreatePostInput input,
@AuthenticationPrincipal UserDetails user) {
return postService.create(input, user.getUsername());
}
}
테스트
@SpringBootTest
@AutoConfigureGraphQlTester
class UserGraphQLTest {
@Autowired
private GraphQlTester graphQlTester;
@Test
void 사용자_조회() {
graphQlTester.documentName("userQuery") // resources/graphql-test/userQuery.graphql
.variable("id", 1)
.execute()
.path("user.name").entity(String.class).isEqualTo("홍길동")
.path("user.posts[*]").entityList(Post.class).hasSizeGreaterThan(0);
}
@Test
void 사용자_생성() {
graphQlTester.document("""
mutation {
createUser(input: { name: "테스트", email: "test@test.com" }) {
id
name
email
}
}
""")
.execute()
.path("createUser.name").entity(String.class).isEqualTo("테스트")
.path("createUser.id").entity(Long.class).satisfies(id -> assertThat(id).isPositive());
}
}
Spring for GraphQL은 Method Security와 자연스럽게 통합되어, REST와 동일한 보안 모델을 GraphQL에 적용할 수 있다. @BatchMapping으로 N+1을 해결하고, @SubscriptionMapping으로 실시간 기능까지 — Spring 생태계의 강점을 그대로 활용할 수 있는 것이 핵심이다.