GraphQL을 처음 만들고 "잘 되네!" 하고 넘어가면 높은 확률로 N+1 문제를 안고 있다. 쿼리 하나에 SQL이 수십 개 날아가는 상황이다. 이번 편에서는 왜 이 문제가 발생하고, @BatchMapping으로 어떻게 해결하는지 파고든다.
N+1 문제란
N+1 문제는 JPA에서도 자주 만나는 성능 이슈다. 부모 레코드 1건을 조회하는 쿼리 1개가 실행되고, 그 부모에 연결된 자식 레코드를 가져오기 위해 N개의 추가 쿼리가 실행되는 현상이다.
REST에서는 컨트롤러가 응답 전체를 한 번에 구성하기 때문에, JOIN FETCH나 @EntityGraph로 비교적 쉽게 해결할 수 있다. 하지만 GraphQL에서는 구조적으로 N+1이 발생하기 더 쉽다.
GraphQL에서 N+1이 발생하는 구조
이유는 GraphQL의 필드 리졸빙 방식에 있다. GraphQL은 각 필드를 독립적으로 리졸빙한다. books 쿼리의 결과로 Book 10권이 돌아왔을 때, 각 Book의 author 필드는 별도의 리졸빙 과정을 거친다.
query {
books {
title
author {
name
}
}
}
이 쿼리를 실행하면 어떤 일이 일어나는지 보자.
분홍 영역을 보면, 1번 쿼리로 책 10권을 가져온 뒤, 각 책의 author를 가져오기 위해 10번의 추가 쿼리가 발생한다. 총 11번의 SQL이 실행되는 것이다. 책이 100권이면 101번, 1000권이면 1001번이 된다.
REST에서는 findAllWithAuthor() 같은 JOIN FETCH 메서드를 만들면 끝이다. 하지만 GraphQL에서는 클라이언트가 author 필드를 요청할 수도 있고 안 할 수도 있다. 항상 JOIN FETCH를 걸 수는 없는 구조다.
@BatchMapping이 해결하는 방법
@BatchMapping은 개별 호출을 모아서 한 번에 처리한다. 책 10권의 author를 하나씩 조회하는 대신, 10권의 authorId를 모아서 WHERE id IN (1, 2, 3) 쿼리 하나로 해결한다.
위의 N+1 다이어그램과 비교하면 차이가 뚜렷하다. 분홍색 루프가 초록색 단일 쿼리로 바뀌었다.
@BatchMapping 동작 원리
@BatchMapping 메서드는 개별 Book이 아니라 Book 리스트를 받는다. Spring GraphQL이 author가 필요한 모든 Book을 모아서 한 번에 전달하는 것이다.
@BatchMapping(typeName = "Book", field = "author")
public Map<Book, Author> author(List<Book> books) {
log.info("도서 {}권에 대한 작가 정보를 일괄 조회!", books.size());
List<Long> authorIds = books.stream()
.map(book -> book.getAuthor().getId())
.distinct()
.collect(Collectors.toList());
List<Author> authors = authorService.findAllById(authorIds);
Map<Long, Author> authorMap = authors.stream()
.collect(Collectors.toMap(Author::getId, author -> author));
return books.stream()
.collect(Collectors.toMap(
book -> book,
book -> authorMap.get(book.getAuthor().getId())
));
}
코드가 하는 일을 단계별로 풀어보면 이렇다.
(10권)"] --> B["authorId 추출 + 중복 제거
[1, 2, 3]"] B --> C["findAllById로
작가 일괄 조회"] C --> D["Author를 Map으로 변환
{1: 김영한, 2: 마틴, 3: ...}"] D --> E["Book → Author
매핑 Map 반환"] style A fill:#E8F4F8,stroke:#2196F3,stroke-width:2px,color:#000 style B fill:#FFF3E0,stroke:#FF9800,stroke-width:2px,color:#000 style C fill:#E8F8E8,stroke:#4CAF50,stroke-width:2px,color:#000 style D fill:#FFF3E0,stroke:#FF9800,stroke-width:2px,color:#000 style E fill:#E8F4F8,stroke:#2196F3,stroke-width:2px,color:#000
핵심은 반환 타입이 Map<Book, Author>라는 점이다. Spring GraphQL은 이 Map을 받아서 각 Book에 해당하는 Author를 매칭한다.
typeName = "Book"은 이 배치 매핑이 Book 타입의 필드를 리졸빙한다는 뜻이고, field = "author"는 Book 타입의 author 필드를 담당한다는 뜻이다.
@EqualsAndHashCode를 신경 써야 하는 이유
@BatchMapping의 반환 타입이 Map<Book, Author>이므로, Book 객체가 Map의 키로 사용된다. HashMap의 키로 제대로 동작하려면 equals()와 hashCode()가 올바르게 구현되어 있어야 한다.
Lombok의 @Data를 사용하면 모든 필드를 기반으로 equals()와 hashCode()가 생성되는데, 여기서 문제가 생긴다.
Book이Author를 참조하고,Author가List<Book>을 참조하는 양방향 관계다@Data의toString()이 양방향 참조를 따라가면 무한 순환에 빠진다hashCode()계산도 마찬가지로 순환한다
그래서 @Data 대신 개별 어노테이션을 조합해야 한다.
@Getter
@Setter
@ToString(exclude = "author")
@EqualsAndHashCode(of = "id")
public class Book {
// ...
}
@ToString(exclude = "author"): toString에서 author 참조를 제외해 순환 방지@EqualsAndHashCode(of = "id"): id만으로 동등성을 판단. Map 키로 안전하게 사용 가능
JPA 엔티티에 @Data를 쓰면 편리하지만, 양방향 관계가 있을 때 StackOverflowError가 발생할 수 있다. 엔티티에서는 @Getter, @Setter, @ToString(exclude = ...), @EqualsAndHashCode(of = "id")를 개별로 지정하는 것이 안전하다.
성능 비교
실제로 얼마나 차이가 나는지 정리하면 이렇다.
| 상황 | @BatchMapping 없음 | @BatchMapping 적용 |
|---|---|---|
| 책 10권 + 작가 조회 | 11 쿼리 (1 + 10) | 2 쿼리 (1 + 1) |
| 책 100권 + 작가 조회 | 101 쿼리 | 2 쿼리 |
| 책 1000권 + 작가 조회 | 1001 쿼리 | 2 쿼리 |
데이터가 늘어날수록 차이는 극적으로 벌어진다. N+1은 개발 단계에서는 데이터가 적어 눈에 안 띄다가, 운영 환경에서 데이터가 쌓이면 갑자기 터지는 시한폭탄이다.
자주 하는 실수
입력으로 받은 List<Book>의 모든 Book이 반환 Map의 키에 포함되어야 한다. 빠진 Book이 있으면 해당 Book의 author가 null로 응답된다. distinct()로 authorId 중복을 제거하는 건 맞지만, Map 변환 시 모든 Book을 포함하는지 확인해야 한다.
[!DANGER] @BatchMapping 없이 @SchemaMapping으로 해결하려는 시도
@SchemaMapping은 개별 Book마다 호출되는 필드 리졸버다. author 필드에 @SchemaMapping을 사용하면 N+1 문제가 그대로 남는다. 목록 조회에서 관계 필드를 리졸빙할 때는 반드시 @BatchMapping을 사용해야 한다.