N+1 문제

  • 어떤 엔티티 A를 조회할 때 A와 연관된 엔티티 B가 다대일 혹은 일대다 관계로 매핑되어 있는 경우 발생
  • ex) 블로그 게시글과 댓글이 있는 경우 게시글을 조회한 후 각 게시글마다 댓글을 조회하기 위한 추가 쿼리 발생
  1. 엔티티 A를 조회하는 1개의 쿼리 실행
  2. A에 연관된 B를 LAZY 로 설정해뒀다면
    1. 각 A에 대해 B를 따로따로 조회하기 때문에 A 수만큼 추가 쿼리 발생
  3. 결과적으로 총 N+1개 쿼리 실행
// 예: 하나의 팀(Team)에 여러 명의 회원(Member)이 소속되어 있는 경우
 
List<Member> members = em.createQuery("SELECT m FROM Member m", Member.class)
                         .getResultList();
 
for (Member member : members) {
    System.out.println(member.getTeam().getName()); // 여기서 추가 쿼리 발생
}
  • SELECT m FROM Member m 1번 쿼리
  • 각 member.getTeam() 호출시마다 N번 쿼리

문제점

  • 쿼리가 불필요하게 많이 실행 성능 저하 발생
  • 데이터가 많아질수록 문제가 심각해짐

findAll 메서드의 글로벌 패치 전략 별 N + 1 문제 상황

@Entity
public class Member {
    @Id
    private Long id;
    private String name;
 
    @ManyToOne(fetch = FetchType.LAZY)  // 연관관계: Member → Team
    private Team team;
}

FetchType.LAZY (지연로딩, 기본값)

  • @ManyToOne(fetch = FetchType.LAZY) team 필드는 프록시 객체로 조회
  • 실제 사용 시점에 team 조회를 위한 추가 쿼리 발생
  • N+1 문제 발생
List<Member> members = memberRepository.findAll();
 
for (Member member : members) {
    System.out.println(member.getTeam().getName()); // 여기서 N번 쿼리
}

FetchType.EAGER (즉시 로딩)

  • @ManyToOne(fetch = FetchType.EAGER) findAll() 시점에 team도 함께 가져오도록 SQL이 자동으로 조인
  • ❗ EAGER는 항상 JOIN을 쓰는게 아니라 각각의 SELECT 쿼리를 날릴 수 있어 예측 불가능한 성능문제가 발생하기도 함
-- Member를 하나씩 조회하고, 각각의 Team도 별도로 조회하는 경우
SELECT * FROM member;
SELECT * FROM team WHERE id = ?; -- 여러 번 발생 가능

해결방법

Fetch Join

단순 조회 + 데이터 크지 않음, 전체 데이터 한번에 조회

List<Member> members = em.createQuery(
    "SELECT m FROM Member m JOIN FETCH m.team", Member.class)
    .getResultList();
  • 한번의 쿼리로 Member와 연관된 Team까지 한번에 가져옴
  • 연관관계에 있는 엔티티를 한번에 즉시 로딩하는 구문
  • 데이터가 중복 조회될 수 있음 distinct 함께 사용
  • 중복 row 문제 때문에 페이징 불가

EntityGraph

관계가 복잡함(다수의 연관 관계 포함)

@EntityGraph(attributePaths = {"team"})
List<Member> findAll();
  • 자동으로 fetch join처럼 동작

@BatchSize 또는 글로벌 batch

데이터 양 많음, 페이징 필요시

@BatchSize(size = 100)
@ManyToOne(fetch = FetchType.LAZY)
private Team team;
spring:
  jpa:
    properties:
      hibernate:
        default_batch_fetch_size: 100
  • 다수의 LAZY 로딩을 IN 쿼리 하나로 묶어 조회
  • 쿼리 수는 줄지만 여전히 여러번 발생할 수 있음

DTO로 최적화 쿼리 작성

  • 성능이 가장 중요할 때

정리

전략N + 1 문제 발생 여부비고
FetchType.LAZY✅ 발생기본 설정, 가장 흔함
FetchType.EAGER⚠️ 조건부 발생동작이 예측 어렵고, 컬렉션에서 비효율적
JOIN FETCH❌ 발생 안 함명시적 쿼리 작성 필요
@EntityGraph❌ 발생 안 함유지보수에 유리

Ref.