[JPA] N+1 문제란 무엇일까?

2025. 12. 2. 21:52·Back-End/Spring

Spring Boot 프로젝트를 진행하다 보면 JPA의 연관관계를 자주 사용하게 된다.

처음에는 @ManyToOne, @OneToMany 같은 연관관계를 단순히 Entity끼리 연결하는 기능으로 사용했지만, 실제 조회 로직을 작성하다 보면 예상보다 많은 SQL이 실행되는 상황을 마주할 수 있다.

이때 자주 언급되는 문제가 바로 N+1 문제다.

N+1 문제란?

N+1 문제는 하나의 조회 쿼리를 실행한 뒤, 조회된 N개의 데이터와 연관된 데이터를 가져오기 위해 추가 쿼리가 N번 더 실행되는 문제를 말한다.

예를 들어 상품 목록을 조회했는데, 각 상품의 판매자 정보를 가져오기 위해 상품 개수만큼 추가 쿼리가 실행되는 상황이다.

상품 목록 조회 쿼리 1번
+ 각 상품의 판매자 조회 쿼리 N번
= 총 N+1번의 쿼리 실행

즉, N+1에서 1은 처음 실행한 조회 쿼리고, N은 조회된 데이터 개수만큼 추가로 실행되는 쿼리를 의미한다.

예시 상황

다음과 같이 상품과 판매자가 연관관계를 가지고 있다고 가정해보자.

@Entity
@Getter
public class Product {

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

    private String name;

    @ManyToOne(fetch = FetchType.LAZY)
    private Member seller;
}

 

상품 목록을 조회하는 코드는 다음과 같다.

List<Product> products = productRepository.findAll();

for (Product product : products) {
    System.out.println(product.getSeller().getName());
}

처음 findAll()을 실행하면 상품 목록을 조회하는 SQL이 한 번 실행된다.

select *
from product;

하지만 반복문 안에서 product.getSeller().getName()을 호출하는 순간, 각 상품의 판매자 정보를 조회하기 위해 추가 쿼리가 실행된다.

select *
from member
where id = ?;

만약 상품이 10개라면 다음과 같은 상황이 된다.

상품 목록 조회 1번
판매자 조회 10번
= 총 11번의 쿼리 실행

이것이 N+1 문제다.

왜 발생할까?

N+1 문제는 JPA가 연관된 데이터를 언제 조회할지 결정하는 방식과 관련이 있다.

특히 LAZY 로딩에서는 연관된 Entity를 처음부터 함께 조회하지 않는다.

@ManyToOne(fetch = FetchType.LAZY)
private Member seller;

이 설정은 상품을 조회할 때 판매자 정보를 바로 가져오지 않고, 실제로 seller에 접근하는 시점에 조회한다.

따라서 상품 목록을 먼저 조회한 뒤, 반복문에서 판매자 정보에 하나씩 접근하면 상품 개수만큼 추가 쿼리가 발생할 수 있다.

여기서 중요한 점은 LAZY 자체가 문제라는 뜻은 아니다.

LAZY는 필요하지 않은 연관 데이터를 불필요하게 조회하지 않도록 도와주는 좋은 전략이다.
다만 목록 조회처럼 여러 Entity의 연관 데이터를 함께 사용해야 하는 상황에서는 N+1 문제가 발생할 수 있다.

해결 방법 1. Fetch Join 사용하기

가장 대표적인 해결 방법은 Fetch Join이다.

Fetch Join은 연관된 Entity를 처음 조회할 때 함께 가져오는 방식이다.

@Query("""
    select p
    from Product p
    join fetch p.seller
""")
List<Product> findAllWithSeller();

이렇게 조회하면 상품과 판매자를 한 번의 쿼리로 함께 가져올 수 있다.

select p.*, m.*
from product p
join member m on p.seller_id = m.id;

기존에는 상품 목록 조회 후 판매자 조회가 N번 추가로 발생했지만, Fetch Join을 사용하면 처음부터 필요한 연관 데이터를 함께 조회할 수 있다.

기존: 상품 조회 1번 + 판매자 조회 N번
개선: 상품과 판매자 함께 조회 1번

해결 방법 2. EntityGraph 사용하기

@EntityGraph를 사용해 특정 조회에서 함께 가져올 연관관계를 지정할 수도 있다.

@EntityGraph(attributePaths = "seller")
List<Product> findAll();

Fetch Join을 직접 JPQL로 작성하지 않아도, 해당 조회에서 seller를 함께 조회하도록 설정할 수 있다.

간단한 조회에서는 EntityGraph를 사용하면 코드가 비교적 깔끔해질 수 있다.

해결 방법 3. Batch Size 설정하기

Batch Size를 설정하면 연관 데이터를 하나씩 조회하지 않고, 여러 개를 묶어서 조회할 수 있다.

spring.jpa.properties.hibernate.default_batch_fetch_size=100

이 설정을 사용하면 다음과 같이 여러 ID를 한 번에 조회한다.

select *
from member
where id in (?, ?, ?, ...);

Batch Size는 N+1 문제를 완전히 한 번의 쿼리로 없애는 방식은 아니지만, 추가 쿼리 수를 줄이는 데 도움이 된다.

특히 컬렉션 연관관계나 페이징 조회에서는 Fetch Join보다 Batch Size가 더 적절한 경우도 있다.

주의할 점

N+1 문제를 해결한다고 해서 모든 조회에 Fetch Join을 사용하는 것이 항상 좋은 것은 아니다.

Fetch Join은 필요한 데이터를 한 번에 가져올 수 있다는 장점이 있지만, 불필요한 연관 데이터까지 함께 조회하면 오히려 쿼리가 무거워질 수 있다.

또한 @OneToMany 같은 컬렉션을 Fetch Join할 때는 데이터 중복이나 페이징 문제가 발생할 수 있다.

따라서 조회 목적에 따라 적절한 방법을 선택해야 한다.

단건 조회 또는 명확한 연관 데이터 조회 → Fetch Join
간단한 연관관계 함께 조회 → EntityGraph
컬렉션 조회, 페이징 고려 → Batch Size
필요한 필드만 조회 → DTO Projection

정리하기

N+1 문제는 JPA에서 처음 조회한 데이터 N개에 대해, 연관 데이터를 가져오기 위한 추가 쿼리가 N번 실행되는 문제다.

처음에는 Entity 연관관계를 자연스럽게 사용하면서 잘 드러나지 않을 수 있지만, 목록 조회와 반복문이 함께 사용될 때 쉽게 발생할 수 있다.

N+1 문제를 줄이기 위해서는 조회 시점에 어떤 연관 데이터가 필요한지 명확히 판단해야 한다.

대표적인 해결 방법으로는 Fetch Join, EntityGraph, Batch Size, DTO Projection 등이 있다.

중요한 것은 모든 상황에 하나의 정답이 있는 것이 아니라, 조회 목적과 데이터 구조에 맞게 선택하는 것이다.

 

N+1 문제는 조회한 N개의 데이터에 대해 연관 데이터 조회 쿼리가 N번 추가로 실행되는 문제이며, 필요한 연관 데이터를 조회 시점에 함께 가져오도록 설계해 해결할 수 있다.

💡요약!!

N+1 문제는 연관관계가 있는 Entity를 조회할 때 처음 조회 쿼리 1번이 실행되고, 조회된 N개의 데이터에 대해 연관 데이터를 가져오는 쿼리가 N번 추가로 실행되는 문제입니다.

  1. 발생 원인
    JPA의 지연 로딩이나 연관관계 조회 과정에서 연관 Entity에 접근하는 시점마다 추가 쿼리가 실행되기 때문에 발생합니다.
  2. 문제점
    데이터 개수가 많아질수록 쿼리 수가 함께 증가하기 때문에 성능 저하가 발생할 수 있습니다.
  3. 해결 방법
    Fetch Join, EntityGraph, Batch Size, DTO Projection 등을 사용해 필요한 데이터를 조회 시점에 적절히 가져오도록 개선할 수 있습니다.
  4. 주의할 점
    모든 조회에 Fetch Join을 사용하는 것이 정답은 아니며, 페이징 여부, 컬렉션 연관관계, 필요한 데이터 범위에 따라 해결 방법을 선택해야 합니다.

'Back-End > Spring' 카테고리의 다른 글

@RequestParam, @ModelAttribute, @RequestBody는 언제 사용할까?  (0) 2025.11.24
[JPA] @ManyToOne, @OneToMany 연관관계 정리  (0) 2025.11.22
[SpringBoot] Entity를 DTO로 변환해야 하는 이유  (0) 2025.11.22
ID값을 UUID v4에서 UUID v7로 변경한 이유  (0) 2025.11.21
UUID만으로 구분하기 어려운 데이터를 @PrePersist로 개선해보기  (0) 2025.11.11
'Back-End/Spring' 카테고리의 다른 글
  • @RequestParam, @ModelAttribute, @RequestBody는 언제 사용할까?
  • [JPA] @ManyToOne, @OneToMany 연관관계 정리
  • [SpringBoot] Entity를 DTO로 변환해야 하는 이유
  • ID값을 UUID v4에서 UUID v7로 변경한 이유
devoks
devoks
느려도 꾸준히
  • devoks
    ok's 개발일지
    devoks
  • 전체
    오늘
    어제
    • 분류 전체보기
      • Front-End
      • Back-End
        • Spring
        • Infra
        • AI
      • Computer Science
        • Cs
      • 언어
        • Java
        • SQL
      • 코테
        • Java
        • MySQL
      • Etc.
  • 블로그 메뉴

    • 홈
  • 링크

    • My GitHub
  • 공지사항

  • 인기 글

  • 태그

    BFS
    역직렬화
    Regex
    codingtest
    IaaS
    BufferedWriter
    springboot
    programmers
    정규표현식
    최대공약수
    java
    PrePersist
    dfs
    Container
    compare
    BufferedReader
    최대공배수
    json
    replace
    CI/CD
    effectivejava
    CS
    stack
    persist
    replaceAll
    PaaS
    docker
    switch
    StringTokenizer
    유클리드호제법
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.4
devoks
[JPA] N+1 문제란 무엇일까?
상단으로

티스토리툴바