Spring GraphQL 쿼리·구독 심화

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 생태계의 강점을 그대로 활용할 수 있는 것이 핵심이다.

위로 스크롤
WordPress Appliance - Powered by TurnKey Linux