Spring GraphQL (8/8)

이전 편: [Spring GraphQL] 7. Resolver 작성과 GraphiQL 실습

이 문서가 시리즈의 마지막 편입니다.

시리즈 마지막 편이다. 3편까지 기본 CRUD가 동작하는 GraphQL API를 만들었다. 이번 편에서는 세 가지를 추가한다. N+1 성능 문제 해결, WebSocket 기반 실시간 구독, 그리고 전역 예외 처리다.

왜 이 세 가지가 필요한가

지금 상태의 API에는 세 가지 문제가 숨어 있다.

  • N+1 : books 쿼리에서 author를 함께 요청하면 책 수만큼 추가 SQL이 발생한다
  • 실시간 알림 부재 : 새 책이 등록되어도 다른 클라이언트는 다시 조회하기 전까지 모른다
  • 에러 응답 부재 : 존재하지 않는 ID를 조회하면 RuntimeException이 그대로 클라이언트에 노출된다

하나씩 해결한다.

단계별로 만들기

단계 1 — 엔티티 Lombok 어노테이션 수정

@BatchMapping을 사용하려면 Book 객체가 HashMap의 키로 안전하게 동작해야 한다. 현재 @Getter @Setter 외에 @EqualsAndHashCode@ToString 설정이 필요하다.

Book.java를 수정한다.

@Entity
@Table(name = "books")
@Getter @Setter @NoArgsConstructor @AllArgsConstructor @Builder
@ToString(exclude = "author")
@EqualsAndHashCode(of = "id")
public class Book {
    // 필드는 그대로 유지
}

Author.java도 수정한다.

@Entity
@Table(name = "authors")
@Getter @Setter @NoArgsConstructor @AllArgsConstructor @Builder
@ToString(exclude = "books")
@EqualsAndHashCode(of = "id")
public class Author {
    // 필드는 그대로 유지
}

두 가지를 바꿨다.

  • @ToString(exclude = ...) : 양방향 참조를 따라가면 toString()이 무한 순환에 빠진다. 상대쪽 필드를 제외한다.
  • @EqualsAndHashCode(of = "id") : id만으로 동등성을 판단한다. 모든 필드를 비교하면 LAZY 로딩된 프록시 객체에서 문제가 생긴다.

단계 2 — @BatchMapping으로 N+1 해결

QueryResolver@BatchMapping 메서드를 추가한다. 먼저 AuthorServicefindAllById 메서드가 필요하다.

// AuthorService.java에 추가
public List<Author> findAllById(List<Long> ids) {
    return authorRepository.findAllById(ids);
}

이제 QueryResolver에 배치 매핑을 추가한다.

@BatchMapping(typeName = "Book", field = "author")
public Map<Book, Author> author(List<Book> books) {
    log.info("도서 {}권에 대한 작가 정보를 일괄 조회!", books.size());

    // 1. 모든 책에서 authorId를 추출하고 중복을 제거한다
    List<Long> authorIds = books.stream()
            .map(book -> book.getAuthor().getId())
            .distinct()
            .collect(Collectors.toList());

    // 2. IN 쿼리 하나로 모든 작가를 조회한다
    List<Author> authors = authorService.findAllById(authorIds);

    // 3. 조회 결과를 ID → Author Map으로 변환한다
    Map<Long, Author> authorMap = authors.stream()
            .collect(Collectors.toMap(Author::getId, author -> author));

    // 4. 각 Book에 해당하는 Author를 매핑한 Map을 반환한다
    return books.stream()
            .collect(Collectors.toMap(
                    book -> book,
                    book -> authorMap.get(book.getAuthor().getId())
            ));
}

이 메서드의 실행 흐름을 시각화하면 이렇다.

sequenceDiagram autonumber participant GQL as Spring GraphQL participant BM as @BatchMapping participant AS as AuthorService participant DB rect rgb(232, 248, 232) Note over GQL, DB: 배치 처리 흐름 GQL->>BM: Book 10권의 author 필드 리졸빙 요청 BM->>BM: authorId 추출 + 중복 제거 → [1, 2] BM->>AS: findAllById([1, 2]) AS->>DB: SELECT * FROM authors WHERE id IN (1, 2) DB-->>AS: Author 2명 반환 AS-->>BM: Author 리스트 BM->>BM: Map<Long, Author> 변환 BM-->>GQL: Map<Book, Author> 반환 end

핵심은 3번 단계다. 10권의 책에 대해 10번 쿼리를 날리는 대신, WHERE id IN (1, 2) 쿼리 하나로 해결한다.

단계 3 — WebSocket Subscription 구현

먼저 의존성을 추가한다. build.gradle에 WebSocket 스타터를 넣는다.

implementation 'org.springframework.boot:spring-boot-starter-websocket'

application.yml에 WebSocket 경로를 설정한다.

spring:
  graphql:
    websocket:
      path: /graphql

Subscription Resolver를 만든다.

package com.codeit.graphql.resolver;

import com.codeit.graphql.entity.Book;
import lombok.extern.slf4j.Slf4j;
import org.springframework.graphql.data.method.annotation.SubscriptionMapping;
import org.springframework.stereotype.Controller;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Sinks;

@Slf4j
@Controller
public class BookSubscriptionResolver {

    private final Sinks.Many<Book> bookSink =
            Sinks.many().multicast().onBackpressureBuffer();

    @SubscriptionMapping
    public Flux<Book> bookAdded() {
        log.info("GraphQL Subscription: bookAdded 구독 시작");
        return bookSink.asFlux();
    }

    public void publishBookAdded(Book book) {
        log.info("도서 추가 이벤트 발행: {}", book.getTitle());
        bookSink.tryEmitNext(book);
    }
}

Sinks.many().multicast().onBackpressureBuffer()는 여러 구독자에게 동시에 이벤트를 보내는 브로드캐스트 방식이다. tryEmitNext()로 이벤트를 발행하면, bookAdded()를 구독 중인 모든 클라이언트에게 전달된다.

이제 MutationResolver에서 책을 생성할 때 이벤트를 발행하도록 연결한다.

@Controller
@RequiredArgsConstructor
@Slf4j
public class MutationResolver {

    private final BookService bookService;
    private final AuthorService authorService;
    private final BookSubscriptionResolver subscriptionResolver;

    @MutationMapping
    public Book createBook(@Argument CreateBookInput input) {
        log.info("GraphQL Mutation: createBook({})", input);
        Book book = bookService.createBook(input);
        subscriptionResolver.publishBookAdded(book);  // 이벤트 발행
        return book;
    }

    // 나머지 메서드는 그대로 유지
}

createBook 호출 → 책 저장 → 이벤트 발행 → 구독자에게 푸시. 이 흐름으로 실시간 알림이 동작한다.

flowchart TD A["createBook Mutation 호출"] --> B["BookService.createBook()"] B --> C["DB에 책 저장"] C --> D["subscriptionResolver.publishBookAdded()"] D --> E["Sinks.tryEmitNext(book)"] E --> F["Flux → 모든 구독자에게 전달"] style A fill:#FFF3E0,stroke:#FF9800,stroke-width:2px,color:#000 style B fill:#E8F8E8,stroke:#4CAF50,stroke-width:2px,color:#000 style C fill:#E8F4F8,stroke:#2196F3,stroke-width:2px,color:#000 style D fill:#FFF3E0,stroke:#FF9800,stroke-width:2px,color:#000 style E fill:#F3E5F5,stroke:#9C27B0,stroke-width:2px,color:#000 style F fill:#E8F8E8,stroke:#4CAF50,stroke-width:2px,color:#000

단계 4 — 커스텀 예외 클래스 생성

도메인 전용 예외를 만든다. 기존 RuntimeException을 더 의미 있는 예외로 교체한다.

package com.codeit.graphql.exception;

public class BookNotFoundException extends RuntimeException {
    public BookNotFoundException(Long id) {
        super("도서를 찾을 수 없습니다: ID=" + id);
    }
}

BookService에서 이 예외를 사용하도록 수정한다.

// BookService.java — getBookById 메서드 수정
public Book getBookById(Long id) {
    return bookRepository.findById(id)
            .orElseThrow(() -> new BookNotFoundException(id));
}

단계 5 — 전역 예외 핸들러 구현

@ControllerAdvice@GraphQlExceptionHandler로 예외를 잡아 구조화된 에러 응답으로 변환한다.

package com.codeit.graphql.exception;

import graphql.ErrorType;
import graphql.GraphQLError;
import org.springframework.graphql.data.method.annotation.GraphQlExceptionHandler;
import org.springframework.web.bind.annotation.ControllerAdvice;

import java.util.Map;

@ControllerAdvice
public class GraphQLExceptionHandler {

    @GraphQlExceptionHandler
    public GraphQLError handleNotFoundException(BookNotFoundException e) {
        return GraphQLError.newError()
                .message(e.getMessage())
                .errorType(ErrorType.NOT_FOUND)
                .extensions(Map.of(
                        "errorCode", "ERR-BOOK-001",
                        "httpStatus", "404"
                ))
                .build();
    }
}

존재하지 않는 ID로 조회하면 이런 응답이 온다.

{
  "data": {
    "book": null
  },
  "errors": [
    {
      "message": "도서를 찾을 수 없습니다: ID=999",
      "extensions": {
        "errorCode": "ERR-BOOK-001",
        "httpStatus": "404",
        "classification": "NOT_FOUND"
      }
    }
  ]
}

classificationErrorType에서 자동으로 추가된다. 클라이언트는 extensions.errorCode를 보고 프로그래밍적으로 에러를 처리할 수 있다.

코드 뜯어보기

@BatchMapping — Stream API 활용

List<Long> authorIds = books.stream()
        .map(book -> book.getAuthor().getId())
        .distinct()
        .collect(Collectors.toList());

map()으로 Book에서 authorId를 추출하고, distinct()로 중복을 제거한다. 책 10권의 작가가 3명이면, IN 쿼리에 3개의 ID만 들어간다.

Map<Long, Author> authorMap = authors.stream()
        .collect(Collectors.toMap(Author::getId, author -> author));

조회 결과를 Map<Long, Author>로 변환한다. 이후 각 Book의 authorId로 O(1) 조회가 가능해진다.

Sinks — multicast vs unicast

Sinks.many().multicast()는 여러 구독자에게 같은 이벤트를 보낸다. unicast()를 쓰면 단 하나의 구독자에게만 전달된다. 실시간 알림처럼 여러 클라이언트가 동시에 구독하는 상황에서는 반드시 multicast()를 써야 한다.

GraphQLError — extensions의 용도

.extensions(Map.of(
    "errorCode", "ERR-BOOK-001",
    "httpStatus", "404"
))

extensions는 GraphQL 에러 스펙에서 자유롭게 확장할 수 있는 영역이다. 에러 코드, HTTP 상태, 필드 검증 결과 등 클라이언트에 전달하고 싶은 메타데이터를 담는다.

시리즈 전체 흐름

4편에 걸쳐 구현한 전체 시스템의 흐름을 하나의 다이어그램으로 정리한다.

sequenceDiagram autonumber participant C1 as 구독자 participant C2 as 요청자 participant GQL as Spring GraphQL participant QR as QueryResolver participant MR as MutationResolver participant SUB as SubscriptionResolver participant S as Service participant DB rect rgb(232, 248, 232) Note over C1, SUB: Subscription 구독 C1->>GQL: subscription { bookAdded { title } } GQL->>SUB: Flux 스트림 연결 end rect rgb(240, 248, 255) Note over C2, DB: Query 실행 (BatchMapping 포함) C2->>GQL: { books { title, author { name } } } GQL->>QR: books() QR->>S: getAllBooks() S->>DB: SELECT * FROM books GQL->>QR: @BatchMapping author(books) QR->>S: findAllById([1, 2]) S->>DB: SELECT * FROM authors WHERE id IN (1,2) DB-->>C2: 결과 JSON end rect rgb(255, 243, 224) Note over C2, DB: Mutation 실행 + 이벤트 발행 C2->>GQL: mutation { createBook(...) } GQL->>MR: createBook(input) MR->>S: createBook(input) S->>DB: INSERT INTO books MR->>SUB: publishBookAdded(book) end rect rgb(232, 248, 232) Note over C1, SUB: 실시간 알림 수신 SUB--)C1: { bookAdded: { title: "새 책" } } end

초록 → 파랑 → 주황 → 초록 순서로, 구독 연결, 조회(배치 로딩 포함), 생성(이벤트 발행), 실시간 알림 수신이 모두 하나의 시스템에서 동작한다.

이번 편 최종 전체 코드

Book.java (수정)

@Entity
@Table(name = "books")
@Getter @Setter @NoArgsConstructor @AllArgsConstructor @Builder
@ToString(exclude = "author")
@EqualsAndHashCode(of = "id")
public class Book {

    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false)
    private String title;

    @Column(nullable = false, unique = true)
    private String isbn;

    @Column(nullable = false)
    private Integer publishedYear;

    @Column(nullable = false)
    private Double price;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "author_id", nullable = false)
    private Author author;

    @CreationTimestamp
    @Column(nullable = false, updatable = false)
    private LocalDateTime createdAt;
}

Author.java (수정)

@Entity
@Table(name = "authors")
@Getter @Setter @NoArgsConstructor @AllArgsConstructor @Builder
@ToString(exclude = "books")
@EqualsAndHashCode(of = "id")
public class Author {

    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false)
    private String name;

    @Column(nullable = false, unique = true)
    private String email;

    @OneToMany(mappedBy = "author", cascade = CascadeType.ALL, orphanRemoval = true)
    @Builder.Default
    private List<Book> books = new ArrayList<>();

    public void addBook(Book book) {
        books.add(book);
        book.setAuthor(this);
    }

    public void removeBook(Book book) {
        books.remove(book);
        book.setAuthor(null);
    }
}

QueryResolver.java (BatchMapping 추가)

@Controller
@RequiredArgsConstructor
@Slf4j
public class QueryResolver {
    private final BookService bookService;
    private final AuthorService authorService;

    @QueryMapping
    public List<Book> books() { return bookService.getAllBooks(); }

    @QueryMapping
    public Book book(@Argument Long id) { return bookService.getBookById(id); }

    @QueryMapping
    public List<Book> searchBooks(@Argument String title) { return bookService.searchBooks(title); }

    @QueryMapping
    public List<Author> authors() { return authorService.getAllAuthorsWithBooks(); }

    @QueryMapping
    public Author author(@Argument Long id) { return authorService.getAuthorById(id); }

    @QueryMapping
    public List<Book> booksByAuthor(@Argument Long authorId) { return bookService.getBooksByAuthor(authorId); }

    @BatchMapping(typeName = "Book", field = "author")
    public Map<Book, Author> author(List<Book> books) {
        List<Long> authorIds = books.stream()
                .map(book -> book.getAuthor().getId())
                .distinct().collect(Collectors.toList());

        Map<Long, Author> authorMap = authorService.findAllById(authorIds).stream()
                .collect(Collectors.toMap(Author::getId, a -> a));

        return books.stream()
                .collect(Collectors.toMap(b -> b, b -> authorMap.get(b.getAuthor().getId())));
    }
}

BookSubscriptionResolver.java

@Slf4j
@Controller
public class BookSubscriptionResolver {

    private final Sinks.Many<Book> bookSink =
            Sinks.many().multicast().onBackpressureBuffer();

    @SubscriptionMapping
    public Flux<Book> bookAdded() {
        log.info("GraphQL Subscription: bookAdded 구독 시작");
        return bookSink.asFlux();
    }

    public void publishBookAdded(Book book) {
        log.info("도서 추가 이벤트 발행: {}", book.getTitle());
        bookSink.tryEmitNext(book);
    }
}

MutationResolver.java (Subscription 연결)

@Controller
@RequiredArgsConstructor
@Slf4j
public class MutationResolver {
    private final BookService bookService;
    private final AuthorService authorService;
    private final BookSubscriptionResolver subscriptionResolver;

    @MutationMapping
    public Book createBook(@Argument CreateBookInput input) {
        Book book = bookService.createBook(input);
        subscriptionResolver.publishBookAdded(book);
        return book;
    }

    // updateBook, deleteBook, createAuthor, updateAuthor, deleteAuthor는 동일
}

BookNotFoundException.java

public class BookNotFoundException extends RuntimeException {
    public BookNotFoundException(Long id) {
        super("도서를 찾을 수 없습니다: ID=" + id);
    }
}

GraphQLExceptionHandler.java

@ControllerAdvice
public class GraphQLExceptionHandler {

    @GraphQlExceptionHandler
    public GraphQLError handleNotFoundException(BookNotFoundException e) {
        return GraphQLError.newError()
                .message(e.getMessage())
                .errorType(ErrorType.NOT_FOUND)
                .extensions(Map.of("errorCode", "ERR-BOOK-001", "httpStatus", "404"))
                .build();
    }
}

자주 하는 실수

@BatchMapping 반환 Map에서 Book이 빠지는 것

입력 List<Book>의 모든 Book이 반환 Map의 키에 포함되어야 한다. authorMap.get()이 null을 반환하면 NullPointerException이 발생한다. 데이터 정합성이 깨진 경우를 대비해 null 체크를 넣는 것이 안전하다.

[!DANGER] Sinks에서 unicast()를 쓰는 것

unicast()는 하나의 구독자에게만 이벤트를 전달한다. 두 번째 구독자부터는 이벤트를 받지 못한다. 실시간 알림 기능에서는 반드시 multicast()를 사용해야 모든 구독자에게 이벤트가 전달된다.

면접 Q&A

GraphQL의 N+1 문제가 REST와 다른 점은?

REST에서는 컨트롤러가 응답을 한 번에 구성하기 때문에 JOIN FETCH로 해결이 쉽다. GraphQL에서는 각 필드가 독립적으로 리졸빙되기 때문에 구조적으로 N+1이 발생하기 쉽다. 클라이언트가 관계 필드를 요청할 수도, 안 할 수도 있어서 항상 JOIN FETCH를 거는 것도 적절하지 않다.

[!QUESTION] @BatchMapping은 내부적으로 어떻게 동작하나?

Spring GraphQL이 리졸빙할 Book들을 모아서 @BatchMapping 메서드에 List<Book>으로 전달한다. 내부적으로는 GraphQL Java의 DataLoader 메커니즘을 사용한다. 메서드는 Map<Book, Author>를 반환하고, Spring GraphQL이 각 Book에 해당하는 Author를 매칭한다.

[!QUESTION] (함정) GraphQL Subscription은 폴링과 뭐가 다른가?

폴링은 클라이언트가 주기적으로 서버에 요청을 보내는 방식이다. 데이터가 없어도 요청이 가고, 있어도 다음 주기까지 기다려야 한다. Subscription은 WebSocket으로 양방향 연결을 유지하고, 서버에서 이벤트가 발생하는 즉시 클라이언트에게 푸시한다. 불필요한 요청도 없고 지연도 없다.

[!QUESTION] (함정) GraphQL 에러 응답의 HTTP 상태 코드는 몇인가?

항상 200 OK다. GraphQL은 하나의 요청에서 여러 필드를 조회할 수 있고, 일부만 실패하는 부분 에러가 가능하다. HTTP 상태 코드로는 "일부 성공"을 표현할 수 없기 때문에, 에러 정보는 응답 본문의 errors 필드에 담는 구조를 택한다.