4. N+1 해결, Subscription, 예외 처리 ← 현재 문서
시리즈 마지막 편이다. 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 메서드를 추가한다. 먼저 AuthorService에 findAllById 메서드가 필요하다.
// 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())
));
}
이 메서드의 실행 흐름을 시각화하면 이렇다.
핵심은 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 호출 → 책 저장 → 이벤트 발행 → 구독자에게 푸시. 이 흐름으로 실시간 알림이 동작한다.
단계 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"
}
}
]
}
classification은 ErrorType에서 자동으로 추가된다. 클라이언트는 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편에 걸쳐 구현한 전체 시스템의 흐름을 하나의 다이어그램으로 정리한다.
초록 → 파랑 → 주황 → 초록 순서로, 구독 연결, 조회(배치 로딩 포함), 생성(이벤트 발행), 실시간 알림 수신이 모두 하나의 시스템에서 동작한다.
이번 편 최종 전체 코드
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();
}
}
자주 하는 실수
입력 List<Book>의 모든 Book이 반환 Map의 키에 포함되어야 한다. authorMap.get()이 null을 반환하면 NullPointerException이 발생한다. 데이터 정합성이 깨진 경우를 대비해 null 체크를 넣는 것이 안전하다.
[!DANGER] Sinks에서 unicast()를 쓰는 것
unicast()는 하나의 구독자에게만 이벤트를 전달한다. 두 번째 구독자부터는 이벤트를 받지 못한다. 실시간 알림 기능에서는 반드시 multicast()를 사용해야 모든 구독자에게 이벤트가 전달된다.
면접 Q&A
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 필드에 담는 구조를 택한다.