데이터 100건을 조회했을 뿐인데, 실제로는 쿼리가 101번 실행되고 있다면?

JPA를 사용하면서 가장 빈번하게 마주치는 성능 문제가 바로 N+1 문제 입니다. 원인을 모르면 작은 데이터에서는 괜찮다가 운영 환경에서 갑자기 DB에 부하가 몰리는 상황이 발생합니다.

개념 정의

N+1 문제 란 1번의 쿼리로 N개의 엔티티를 조회한 후, 각 엔티티의 연관 관계를 로딩하기 위해 추가로 N번의 쿼리 가 실행되는 현상입니다.

PLAINTEXT
1번 쿼리: SELECT * FROM team              → 10개 팀 조회
+10번 쿼리: SELECT * FROM member WHERE team_id = ?  (팀마다 1번씩)
= 총 11번의 쿼리

왜 발생하는가

JPQL에서의 발생

N+1 문제는 EAGER든 LAZY든 발생할 수 있습니다. 차이는 발생 시점뿐입니다.

JAVA
@Entity
public class Team {
    @OneToMany(mappedBy = "team", fetch = FetchType.LAZY)
    private List<Member> members;
}
JAVA
// JPQL로 팀 목록 조회
List<Team> teams = em.createQuery("SELECT t FROM Team t", Team.class)
    .getResultList();
// → SELECT * FROM team 실행 (1번)

for (Team team : teams) {
    // 각 팀의 members에 접근할 때마다 추가 쿼리 발생
    System.out.println(team.getMembers().size());
    // → SELECT * FROM member WHERE team_id = ? (N번)
}

EAGER로 설정하면 getResultList() 시점에 즉시 N번의 추가 쿼리가 실행됩니다. LAZY로 설정하면 실제 접근 시점에 실행됩니다. 어느 쪽이든 쿼리 수는 같습니다.

왜 JPA가 자동으로 조인하지 않는가

JPQL은 SQL을 추상화한 것이지 자동 최적화를 해주는 것이 아닙니다. SELECT t FROM Team t이라고 작성하면 JPA는 Team만 조회하는 쿼리 를 생성합니다. Team만 조회했기 때문에 각 Team의 members에 접근할 때 추가 쿼리가 발생하고, 따라서 N+1 문제가 생기는 것입니다. 연관 관계를 함께 가져오려면 명시적으로 JOIN FETCH를 지시해야 합니다.

해결법 1: fetch join

가장 많이 사용하는 해결법입니다. JPQL에 JOIN FETCH를 추가하면 연관 엔티티를 한 번의 쿼리로 함께 가져옵니다.

JAVA
@Query("SELECT t FROM Team t JOIN FETCH t.members")
List<Team> findAllWithMembers();

생성되는 SQL은 다음과 같습니다.

SQL
SELECT t.*, m.*
FROM team t
INNER JOIN member m ON t.id = m.team_id

fetch join 주의사항

1. 컬렉션 fetch join과 페이징

JAVA
// 위험! 메모리에서 페이징 처리됨
@Query("SELECT t FROM Team t JOIN FETCH t.members")
Page<Team> findAllWithMembers(Pageable pageable);

컬렉션(1:N)을 fetch join하면서 페이징을 사용하면, Hibernate는 전체 데이터를 메모리에 올린 후 애플리케이션에서 페이징합니다. 데이터가 많으면 OutOfMemoryError가 발생할 수 있습니다.

2. 둘 이상의 컬렉션 fetch join

JAVA
// MultipleBagFetchException 발생!
@Query("SELECT t FROM Team t JOIN FETCH t.members JOIN FETCH t.projects")
List<Team> findAllWithMembersAndProjects();

Hibernate는 두 개 이상의 List 타입 컬렉션을 동시에 fetch join할 수 없습니다. 카테시안 곱이 발생하기 때문입니다.

** 해결 방법:**

  • ListSet으로 변경하면 동작은 하지만, 데이터 중복이 발생할 수 있습니다.
  • 하나만 fetch join하고 나머지는 @BatchSize로 해결합니다.

해결법 2: @EntityGraph

fetch join과 비슷하지만 JPQL을 직접 작성하지 않아도 됩니다.

JAVA
public interface TeamRepository extends JpaRepository<Team, Long> {

    @EntityGraph(attributePaths = {"members"})
    @Query("SELECT t FROM Team t")
    List<Team> findAllWithMembers();

    // 메서드 이름 쿼리에도 적용 가능
    @EntityGraph(attributePaths = {"members"})
    List<Team> findAll();
}

@EntityGraph는 내부적으로 LEFT OUTER JOIN 을 사용합니다. 따라서 fetch join(INNER JOIN)과 결과가 다를 수 있습니다.

해결법 3: @BatchSize

N+1 문제를 완전히 없애지는 않지만, 쿼리 수를 크게 줄여주는 실용적인 방법입니다.

JAVA
@Entity
public class Team {

    @BatchSize(size = 100)
    @OneToMany(mappedBy = "team")
    private List<Member> members;
}

동작 방식은 다음과 같습니다.

PLAINTEXT
1번 쿼리: SELECT * FROM team → 10개 팀 조회
1번 쿼리: SELECT * FROM member WHERE team_id IN (1, 2, 3, ..., 10)
= 총 2번의 쿼리

IN 절로 묶어서 한 번에 조회하므로, 100개의 팀이 있어도 1 + 1 = 2번의 쿼리로 해결됩니다(배치 크기가 100 이상일 때).

글로벌 설정

개별 엔티티마다 설정하는 대신 전역으로 배치 크기를 지정할 수 있습니다.

YAML
spring:
  jpa:
    properties:
      hibernate:
        default_batch_fetch_size: 100

이 설정이 가장 실용적이라고 느꼈습니다. fetch join은 JPQL을 수정해야 하지만, default_batch_fetch_size는 한 줄 설정으로 전체 애플리케이션의 N+1 문제를 완화할 수 있습니다.

해결법 비교

방법쿼리 수페이징 호환복수 컬렉션설정 난이도
fetch join1컬렉션 시 불가제한적JPQL 수정 필요
@EntityGraph1컬렉션 시 불가제한적어노테이션 추가
@BatchSize1 + α호환호환설정 한 줄
DTO 프로젝션1호환호환별도 DTO 필요

실무 전략

실무에서 다음과 같은 조합이 효과적이었습니다.

  1. **글로벌 배치 크기 설정 **: default_batch_fetch_size: 100을 기본으로 적용합니다.
  2. ** 필요한 곳에 fetch join**: 단건 조회나 특정 API에서 연관 데이터가 반드시 필요한 경우 사용합니다.
  3. ** 목록 조회는 DTO 프로젝션 **: 페이징이 필요한 목록 조회에서는 필요한 데이터만 DTO로 직접 조회합니다.
JAVA
// 목록 조회 — DTO 프로젝션으로 N+1 원천 차단
@Query("SELECT new com.example.dto.TeamSummary(t.id, t.name, SIZE(t.members)) " +
       "FROM Team t")
Page<TeamSummary> findTeamSummaries(Pageable pageable);

주의할 점

컬렉션 fetch join과 페이징을 함께 사용하면 메모리에서 페이징된다

컬렉션(1:N)을 fetch join하면서 페이징을 사용하면, Hibernate는 ** 전체 데이터를 메모리에 올린 후** 애플리케이션에서 페이징합니다. 데이터가 많으면 OutOfMemoryError가 발생합니다. 이 경우 @BatchSize와 별도 쿼리로 분리해야 합니다.

EAGER 설정이 N+1 문제를 숨긴다

EAGER로 설정하면 getResultList() 시점에 즉시 추가 쿼리가 실행되므로, 개발자가 ** 쿼리가 언제 나가는지 인지하기 어렵습니다 **. LAZY에서는 접근 시점에 쿼리가 발생하므로 문제를 빨리 발견할 수 있습니다.

MultipleBagFetchException은 List를 Set으로 바꿔도 근본 해결이 아니다

두 개 이상의 컬렉션을 동시에 fetch join하면 카테시안 곱이 발생합니다. ListSet으로 바꾸면 예외는 사라지지만, ** 데이터 중복과 메모리 이슈 **는 남아있습니다. 하나만 fetch join하고 나머지는 @BatchSize로 처리하는 것이 안전합니다.

정리

항목설명
N+1 문제EAGER/LAZY 관계없이 연관 엔티티를 개별 조회할 때 발생
fetch join쿼리 1번으로 해결, 컬렉션 페이징 불가
@EntityGraph어노테이션 기반 fetch join, JPQL 없이 사용 가능
@BatchSizeIN 절로 쿼리 수 축소, 글로벌 설정 가능
DTO Projection필요한 컬럼만 SELECT하여 N+1 원천 차단
MultipleBagFetchException두 개 이상 List 컬렉션 동시 fetch join 시 발생
실무 전략글로벌 배치 크기 + 필요 시 fetch join + 목록 DTO 프로젝션

DTO Projection에 대한 상세한 내용은 DTO Projection — 엔티티 대신 딱 필요한 데이터만 조회하는 전략에서 Interface/Class/Tuple Projection 등 다양한 전략을 다룹니다.

댓글 로딩 중...