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
    }
  }
}

이 쿼리를 실행하면 어떤 일이 일어나는지 보자.

sequenceDiagram autonumber participant C as 클라이언트 participant R as Resolver participant DB rect rgb(255, 230, 230) Note over C, DB: 1 + N 쿼리 발생 C->>R: { books { title, author { name } } } R->>DB: SELECT * FROM books Note right of DB: 1번 쿼리 → 10권 반환 loop 책 10권 각각에 대해 R->>DB: SELECT * FROM authors WHERE id = ? Note right of DB: 각 책마다 작가 조회 end R-->>C: 응답 end

분홍 영역을 보면, 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) 쿼리 하나로 해결한다.

sequenceDiagram autonumber participant C as 클라이언트 participant R as Resolver participant DB rect rgb(232, 248, 232) Note over C, DB: 1 + 1 쿼리로 해결 C->>R: { books { title, author { name } } } R->>DB: SELECT * FROM books Note right of DB: 1번 쿼리 → 10권 반환 R->>DB: SELECT * FROM authors WHERE id IN (1, 2, 3) Note right of DB: 1번 배치 쿼리로 작가 일괄 조회 DB-->>R: 3명의 작가 반환 R-->>C: 응답 end

위의 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())
            ));
}

코드가 하는 일을 단계별로 풀어보면 이렇다.

flowchart TD A["Book 리스트 수신
(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()가 생성되는데, 여기서 문제가 생긴다.

  • BookAuthor를 참조하고, AuthorList<Book>을 참조하는 양방향 관계다
  • @DatatoString()이 양방향 참조를 따라가면 무한 순환에 빠진다
  • hashCode() 계산도 마찬가지로 순환한다

그래서 @Data 대신 개별 어노테이션을 조합해야 한다.

@Getter
@Setter
@ToString(exclude = "author")
@EqualsAndHashCode(of = "id")
public class Book {
    // ...
}
  • @ToString(exclude = "author") : toString에서 author 참조를 제외해 순환 방지
  • @EqualsAndHashCode(of = "id") : id만으로 동등성을 판단. Map 키로 안전하게 사용 가능
@Data와 양방향 관계의 함정

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은 개발 단계에서는 데이터가 적어 눈에 안 띄다가, 운영 환경에서 데이터가 쌓이면 갑자기 터지는 시한폭탄이다.

자주 하는 실수

@BatchMapping 메서드의 반환 Map에서 Book 누락

입력으로 받은 List<Book>의 모든 Book이 반환 Map의 키에 포함되어야 한다. 빠진 Book이 있으면 해당 Book의 author가 null로 응답된다. distinct()로 authorId 중복을 제거하는 건 맞지만, Map 변환 시 모든 Book을 포함하는지 확인해야 한다.

[!DANGER] @BatchMapping 없이 @SchemaMapping으로 해결하려는 시도

@SchemaMapping은 개별 Book마다 호출되는 필드 리졸버다. author 필드에 @SchemaMapping을 사용하면 N+1 문제가 그대로 남는다. 목록 조회에서 관계 필드를 리졸빙할 때는 반드시 @BatchMapping을 사용해야 한다.