N+1 문제는 JPA를 실무에서 사용할 때 가장 자주 마주치는 성능 이슈다. 원인과 해결책을 정확히 이해하지 못하면 운영 환경에서 예상치 못한 쿼리 폭탄을 맞게 된다. 이번 포스트에서는 N+1 문제의 발생 원인부터 다양한 해결 방법과 극한 시나리오까지 상세히 정리한다.

Step 1: N+1 문제란?

1번의 쿼리를 실행했더니 결과 행의 수(N)만큼 추가 쿼리가 실행되는 현상이다.

예를 들어 팀 목록을 조회했더니 각 팀의 멤버를 가져오기 위해 팀 수만큼 추가 SELECT가 발생하는 것이다.

[기대하는 쿼리 흐름]

SELECT * FROM Team;          <- 1번 쿼리
SELECT * FROM Member WHERE team_id IN (1, 2, 3, ...); <- 1번 쿼리
                                                          총 2번

[실제 N+1 발생 시]

SELECT * FROM Team;                      <- 1번 (N=5라 가정)
SELECT * FROM Member WHERE team_id = 1;  <- 추가 1번
SELECT * FROM Member WHERE team_id = 2;  <- 추가 1번
SELECT * FROM Member WHERE team_id = 3;  <- 추가 1번
SELECT * FROM Member WHERE team_id = 4;  <- 추가 1번
SELECT * FROM Member WHERE team_id = 5;  <- 추가 1번
                                            총 1 + N = 6번

팀이 100개면 101번, 1000개면 1001번의 쿼리가 실행된다. 각 쿼리는 DB 커넥션을 사용하고 네트워크 왕복이 발생하기 때문에 서비스 응답 시간이 급격히 느려지고 DB에 부하가 걸린다.

엔티티 구조 (이 포스트 전체에서 사용)

@Entity
public class Team {
    @Id @GeneratedValue
    private Long id;
    private String name;

    @OneToMany(mappedBy = "team", fetch = FetchType.LAZY)
    private List<Member> members = new ArrayList<>();
}

@Entity
public class Member {
    @Id @GeneratedValue
    private Long id;
    private String username;
    private int age;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "team_id")
    private Team team;
}

Step 2: 즉시 로딩(EAGER)에서의 N+1

FetchType.EAGER로 설정하면 연관 엔티티를 항상 즉시 조회한다.

@ManyToOne(fetch = FetchType.EAGER) // EAGER 설정
@JoinColumn(name = "team_id")
private Team team;
// JPQL로 Member 전체 조회
List<Member> members = em.createQuery(
        "select m from Member m", Member.class)
        .getResultList();

실행되는 SQL 로그

-- 1. Member 전체 조회 (JPQL 그대로 번역)
Hibernate:
    select member0_.id, member0_.username, member0_.age, member0_.team_id
    from Member member0_

-- 2. EAGER이므로 각 Member의 Team을 즉시 로딩 (N번 실행)
Hibernate:
    select team0_.id, team0_.name
    from Team team0_
    where team0_.id=?

Hibernate:
    select team0_.id, team0_.name
    from Team team0_
    where team0_.id=?

-- ... Member 수(N)만큼 반복

왜 JOIN으로 한 번에 가져오지 않는가?

JPQL은 작성한 쿼리를 그대로 SQL로 번역한다. "select m from Member m" 에는 JOIN이 없으므로 Member만 조회한다. 그런데 EAGER 설정 때문에 조회 후 연관 엔티티를 즉시 가져오기 위해 Team을 추가로 각각 조회하는 것이다.

em.find()는 JPA가 내부적으로 JOIN을 활용하여 한 번에 가져오지만, JPQL은 다르게 동작한다.


Step 3: 지연 로딩(LAZY)에서의 N+1

지연 로딩으로 설정해도 N+1 문제가 발생할 수 있다.

@ManyToOne(fetch = FetchType.LAZY) // LAZY 설정
@JoinColumn(name = "team_id")
private Team team;
List<Member> members = em.createQuery(
        "select m from Member m", Member.class)
        .getResultList();
// 이 시점에는 쿼리 1번만 실행됨 (Team은 프록시)

for (Member member : members) {
    System.out.println(member.getTeam().getName()); // 여기서 N번 실행!
}

실행되는 SQL 로그

-- 1. Member 전체 조회
Hibernate:
    select member0_.id, member0_.username, member0_.age, member0_.team_id
    from Member member0_

-- 2. 루프에서 team.getName() 호출 시마다 각각 조회
Hibernate:
    select team0_.id, team0_.name from Team team0_ where team0_.id=?
-- binding parameter [1] as [LONG] - [1]

Hibernate:
    select team0_.id, team0_.name from Team team0_ where team0_.id=?
-- binding parameter [1] as [LONG] - [2]

-- ... N번 반복

LAZY라서 최초 조회 시에는 문제가 없어 보이지만, 실제로 연관 엔티티에 접근하는 시점에 N번의 쿼리가 발생한다. 개발 환경에서는 데이터가 적어 눈에 띄지 않다가 운영 환경에서 폭발하는 경우가 많다.


Step 4: 해결 방법 1 — Fetch Join (JPQL JOIN FETCH)

동작 원리

JPQL에 JOIN FETCH를 사용하면 연관 엔티티를 한 번의 쿼리로 함께 조회한다.

List<Member> members = em.createQuery(
        "select m from Member m join fetch m.team", Member.class)
        .getResultList();

for (Member member : members) {
    System.out.println(member.getTeam().getName()); // 추가 쿼리 없음
}

실행되는 SQL 로그

-- 단 1번의 쿼리로 Member + Team 모두 조회
Hibernate:
    select
        member0_.id,
        member0_.username,
        member0_.age,
        member0_.team_id,
        team1_.id,
        team1_.name
    from Member member0_
        inner join Team team1_ on member0_.team_id=team1_.id

컬렉션 Fetch Join (Team -> members 방향)

List<Team> teams = em.createQuery(
        "select t from Team t join fetch t.members", Team.class)
        .getResultList();
Hibernate:
    select
        team0_.id, team0_.name,
        members1_.id, members1_.username, members1_.team_id
    from Team team0_
        inner join Member members1_ on team0_.id=members1_.team_id

Fetch Join 한계: 컬렉션 페치 조인 시 페이징 불가

컬렉션(OneToMany)에 Fetch Join을 사용하면서 동시에 페이징을 적용하면 심각한 문제가 발생한다.

// 위험! 메모리에서 페이징 처리됨
List<Team> teams = em.createQuery(
        "select t from Team t join fetch t.members", Team.class)
        .setFirstResult(0)
        .setMaxResults(10)
        .getResultList();

Hibernate 경고 로그

WARN  o.h.h.internal.ast.QueryTranslatorImpl - HHH90003004:
firstResult/maxResults specified with collection fetch;
applying in memory!
[문제 발생 이유]

Team A -- Member 1
       -- Member 2
Team B -- Member 3

JOIN 결과 (카테시안 곱 발생):
+--------+----------+
| Team A | Member 1 |
| Team A | Member 2 |
| Team B | Member 3 |
+--------+----------+

DB 레벨에서 LIMIT 10 적용 시:
-> "팀 기준 10개"가 아닌 "JOIN 결과 행 기준 10개"가 잘림
-> Team 기준 페이징 불가, 결과가 의도와 다르게 나옴

Hibernate의 선택: 전체 데이터를 메모리에 올린 후 페이징
-> 데이터가 많으면 OutOfMemoryError 위험

해결책: 컬렉션 페이징 + Fetch Join 조합은 피하고, 대신 @BatchSize나 별도 쿼리로 분리한다.


Step 5: 해결 방법 2 — @EntityGraph

Spring Data JPA에서 JPQL 없이 메서드 이름만으로 Fetch Join 효과를 낼 수 있다.

attributePaths 설정

public interface MemberRepository extends JpaRepository<Member, Long> {

    @EntityGraph(attributePaths = {"team"}) // team을 함께 로딩
    List<Member> findAll();

    @EntityGraph(attributePaths = {"team"})
    @Query("select m from Member m")
    List<Member> findAllWithTeam();
}

실행되는 SQL 로그

-- LEFT OUTER JOIN으로 실행됨 (Fetch Join은 INNER JOIN)
Hibernate:
    select
        member0_.id, member0_.username, member0_.team_id,
        team1_.id, team1_.name
    from Member member0_
        left outer join Team team1_ on member0_.team_id=team1_.id

Named EntityGraph

엔티티에 직접 정의해두고 여러 곳에서 재사용할 수 있다.

@Entity
@NamedEntityGraph(
    name = "Member.withTeam",
    attributeNodes = @NamedAttributeNode("team")
)
public class Member {
    // ...
}

// Repository에서 이름으로 참조
public interface MemberRepository extends JpaRepository<Member, Long> {
    @EntityGraph("Member.withTeam")
    List<Member> findByUsername(String username);
}

@EntityGraph vs Fetch Join 차이점

구분 Fetch Join @EntityGraph
JOIN 방식 INNER JOIN LEFT OUTER JOIN
사용 방식 JPQL 직접 작성 어노테이션
동적 조건 자유롭게 작성 제한적
가독성 JPQL 작성 필요 선언적, 직관적

Step 6: 해결 방법 3 — @BatchSize

IN 절 최적화 동작 원리

@BatchSize는 지연 로딩 시 프록시를 초기화할 때 개별 SELECT 대신 IN 절을 사용해 한 번에 여러 행을 조회하는 방식이다.

@Entity
public class Team {
    @Id @GeneratedValue
    private Long id;
    private String name;

    @BatchSize(size = 100) // 최대 100개씩 IN 절로 조회
    @OneToMany(mappedBy = "team", fetch = FetchType.LAZY)
    private List<Member> members = new ArrayList<>();
}
List<Team> teams = teamRepository.findAll(); // SELECT * FROM Team

for (Team team : teams) {
    team.getMembers().size(); // 첫 접근 시 배치 조회 발동
}

실행되는 SQL 로그 (N+1 없음)

-- 1. Team 전체 조회
Hibernate:
    select team0_.id, team0_.name from Team team0_

-- 2. 첫 번째 members 접근 시 -> 최대 100개 team_id를 IN으로 한 번에 조회
Hibernate:
    select members0_.team_id, members0_.id, members0_.username
    from Member members0_
    where members0_.team_id in (?, ?, ?, ?, ?, ...)
-- binding parameter [1] as [LONG] - [1]
-- binding parameter [2] as [LONG] - [2]
-- ...
[@BatchSize 동작 흐름]

team1.getMembers() 최초 접근
    |
    +---> 영속성 컨텍스트 내 프록시 상태인 Team들 수집
    +---> 최대 100개씩 묶어 IN 절 쿼리 실행
    +---> WHERE team_id IN (1, 2, 3, ..., 100)
    +---> 결과를 각 Team의 members에 채움

N+1 -> N/BatchSize + 1 번으로 감소
(팀 1000개, batchSize=100 -> 최대 11번 쿼리)

글로벌 설정 (권장)

모든 연관관계에 일일이 @BatchSize를 붙이는 대신 전역 설정을 사용한다. 실무에서 가장 많이 사용하는 방식이다.

# application.yml
spring:
  jpa:
    properties:
      hibernate:
        default_batch_fetch_size: 1000

또는

<!-- persistence.xml -->
<property name="hibernate.default_batch_fetch_size" value="1000"/>

default_batch_fetch_size의 적정값은 보통 100~1000 사이다. DB와 애플리케이션 서버 사양, IN 절에서 사용되는 파라미터 수 제한을 고려해 설정한다.


Step 7: 해결 방법 4 — @Fetch(FetchMode.SUBSELECT)

IN 절 대신 서브쿼리를 사용해 한 번에 조회하는 방식이다.

@Entity
public class Team {
    @OneToMany(mappedBy = "team", fetch = FetchType.LAZY)
    @Fetch(FetchMode.SUBSELECT) // Hibernate 전용 어노테이션
    private List<Member> members = new ArrayList<>();
}

실행되는 SQL 로그

-- 1. Team 전체 조회
Hibernate:
    select team0_.id, team0_.name from Team team0_

-- 2. members 접근 시 -> 서브쿼리로 한 번에 모두 조회
Hibernate:
    select members0_.team_id, members0_.id, members0_.username
    from Member members0_
    where members0_.team_id in (
        select team0_.id from Team team0_
    )

@BatchSize와의 차이

구분 @BatchSize @Fetch(SUBSELECT)
쿼리 방식 IN (?, ?, …) IN (SELECT …)
쿼리 횟수 N/size + 1 2번 (항상)
메모리 사용 배치 단위 전체 한 번에
표준 여부 JPA 표준 Hibernate 전용

대용량 데이터에서는 서브쿼리가 부담이 될 수 있으므로 @BatchSize가 더 유연하다.


Step 8: 해결 방법 5 — QueryDSL + DTO 직접 조회

연관 엔티티를 엔티티 그래프로 로딩하는 대신, 필요한 데이터만 DTO로 직접 프로젝션하는 방식이다. 가장 성능이 뛰어난 방법이다.

@QueryProjection // 컴파일 타임에 QMemberTeamDto 생성
public class MemberTeamDto {
    private Long memberId;
    private String username;
    private String teamName;

    public MemberTeamDto(Long memberId, String username, String teamName) {
        this.memberId = memberId;
        this.username = username;
        this.teamName = teamName;
    }
}
// QueryDSL로 JOIN + DTO 직접 조회
JPAQueryFactory queryFactory = new JPAQueryFactory(em);
QMember m = QMember.member;
QTeam t = QTeam.team;

List<MemberTeamDto> result = queryFactory
        .select(new QMemberTeamDto(
                m.id,
                m.username,
                t.name))
        .from(m)
        .join(m.team, t)
        .where(m.age.gt(20))
        .fetch();

실행되는 SQL 로그

-- 단 1번의 쿼리, 필요한 컬럼만 조회
Hibernate:
    select
        member0_.id,
        member0_.username,
        team1_.name
    from Member member0_
        inner join Team team1_ on member0_.team_id=team1_.id
    where
        member0_.age>?

JPQL new 명령어로도 가능

List<MemberTeamDto> result = em.createQuery(
        "select new com.example.dto.MemberTeamDto(m.id, m.username, t.name) " +
        "from Member m join m.team t where m.age > :age",
        MemberTeamDto.class)
        .setParameter("age", 20)
        .getResultList();

단점: 엔티티가 아닌 DTO를 반환하므로 JPA 변경 감지(Dirty Checking)가 동작하지 않는다. 조회 전용으로만 사용해야 한다.


Step 9: 각 해결 방법 비교

방법 성능 편의성 페이징 지원 제약사항
Fetch Join 높음 중간 컬렉션 불가 JPQL 직접 작성
@EntityGraph 높음 높음 컬렉션 불가 LEFT JOIN 고정
@BatchSize 중간~높음 높음 가능 쿼리 2번 이상
SUBSELECT 중간 중간 가능 Hibernate 전용
DTO 직접 조회 최고 낮음 가능 엔티티 아님, 코드량 多

선택 기준

단순 조회 + 페이징 필요?
    -> @BatchSize (글로벌 설정) + LAZY 기본 전략

조인 대상이 1개 + 페이징 불필요?
    -> Fetch Join 또는 @EntityGraph

성능이 최우선 + 조회 전용?
    -> QueryDSL + DTO 직접 조회

복잡한 조건 + 여러 연관관계?
    -> QueryDSL + DTO 또는 @BatchSize 조합

Step 10: 극한 시나리오

10-1. 다단계 연관관계에서의 N+1 (A → B → C)

@Entity
public class Order {
    @OneToMany(mappedBy = "order", fetch = FetchType.LAZY)
    private List<OrderItem> orderItems;
}

@Entity
public class OrderItem {
    @ManyToOne(fetch = FetchType.LAZY)
    private Item item;
}
List<Order> orders = orderRepository.findAll();

for (Order order : orders) {
    for (OrderItem orderItem : order.getOrderItems()) { // N번 쿼리
        System.out.println(orderItem.getItem().getName()); // N*M번 쿼리
    }
}
Order 10개, 각 Order당 OrderItem 5개, 각 OrderItem당 Item 조회 시:

Order 조회:     1번
OrderItem 조회: 10번 (Order당 1번)
Item 조회:      10 * 5 = 50번

총 61번 쿼리 발생!

해결: 중첩 Fetch Join

List<Order> orders = em.createQuery(
        "select distinct o from Order o " +
        "join fetch o.orderItems oi " +
        "join fetch oi.item", Order.class)
        .getResultList();

또는 글로벌 default_batch_fetch_size 설정으로 각 단계에서 배치 조회를 적용한다.

10-2. MultipleBagFetchException

컬렉션(List) Fetch Join을 2개 이상 동시에 사용하면 예외가 발생한다.

@Entity
public class Team {
    @OneToMany(mappedBy = "team")
    private List<Member> members;

    @OneToMany(mappedBy = "team")
    private List<Coach> coaches;
}
// 예외 발생!
List<Team> teams = em.createQuery(
        "select t from Team t " +
        "join fetch t.members " +
        "join fetch t.coaches", Team.class)
        .getResultList();
// org.hibernate.loader.MultipleBagFetchException:
// cannot simultaneously fetch multiple bags: [Team.members, Team.coaches]
[원인: 카테시안 곱]

Team A -- Member 1
       -- Member 2
       -- Coach X
       -- Coach Y

JOIN 결과:
Team A | Member 1 | Coach X
Team A | Member 1 | Coach Y
Team A | Member 2 | Coach X
Team A | Member 2 | Coach Y

-> 중복 데이터 폭발, 데이터 정합성 문제

해결 방법

// 방법 1: List -> Set으로 변경 (순서 없음 허용 시)
@OneToMany(mappedBy = "team")
private Set<Member> members = new HashSet<>(); // Set은 중복 제거 가능

// 방법 2: Fetch Join 1개 + @BatchSize 조합
// members는 Fetch Join, coaches는 @BatchSize로 배치 조회
List<Team> teams = em.createQuery(
        "select t from Team t join fetch t.members", Team.class)
        .getResultList();
// coaches는 @BatchSize로 자동 배치 조회

// 방법 3: 쿼리 분리
List<Team> teams = teamRepository.findAllWithMembers();     // members Fetch Join
List<Team> teams2 = teamRepository.findAllWithCoaches();    // coaches Fetch Join

10-3. 카테시안 곱 문제

컬렉션 Fetch Join을 사용하면 JOIN 결과에 중복 행이 생긴다.

List<Team> teams = em.createQuery(
        "select t from Team t join fetch t.members", Team.class)
        .getResultList();

System.out.println(teams.size()); // 팀 2개인데 5가 나올 수 있음!
// (Member 수만큼 Team이 중복 반환)
-- JOIN 결과
+--------+----------+
| TEAM   | MEMBER   |
+--------+----------+
| Team A | Member 1 |
| Team A | Member 2 |  <- Team A 중복
| Team A | Member 3 |  <- Team A 중복
| Team B | Member 4 |
| Team B | Member 5 |  <- Team B 중복
+--------+----------+

List<Team>  Team A 3, Team B 2 담김!

해결: DISTINCT 사용

// JPQL에서 distinct
List<Team> teams = em.createQuery(
        "select distinct t from Team t join fetch t.members", Team.class)
        .getResultList();
// JPQL의 distinct는 애플리케이션 레벨에서도 중복 제거를 수행
// (SQL의 DISTINCT + 같은 식별자 엔티티 중복 제거)

// Spring Data JPA + @EntityGraph
@EntityGraph(attributePaths = {"members"})
@Query("select distinct t from Team t")
List<Team> findAllWithMembers();

10-4. 페이징 + 페치 조인 조합 시 메모리 이슈

앞서 언급했듯이 컬렉션 Fetch Join + 페이징은 위험하다.

// 위험한 코드: 데이터 전체를 메모리에 올린 후 페이징
List<Team> teams = em.createQuery(
        "select t from Team t join fetch t.members", Team.class)
        .setFirstResult(0)
        .setMaxResults(10)
        .getResultList();
// HHH90003004 경고: applying in memory!
// 팀이 10만 개면 10만 개를 메모리에 올려 자름 -> OOM 위험

올바른 해결: 페이징 시에는 컬렉션 Fetch Join 대신 @BatchSize 사용

// application.yml
spring:
  jpa:
    properties:
      hibernate:
        default_batch_fetch_size: 1000

// Repository
Page<Team> findAll(Pageable pageable);
// -> Team만 페이징 조회 (1번)
// -> members는 배치 조회 (IN 절, 1번)
// 총 2번 쿼리, 메모리 안전

Step 11: 실무 Best Practice

기본 전략: LAZY + 필요 시 Fetch Join

모든 연관관계는 기본적으로 FetchType.LAZY로 설정한다. 성능 이슈가 확인된 쿼리에만 Fetch Join을 적용한다.

// 모든 연관관계에 LAZY 설정
@ManyToOne(fetch = FetchType.LAZY)
@OneToOne(fetch = FetchType.LAZY)
@OneToMany(fetch = FetchType.LAZY) // @OneToMany 기본값이 LAZY이긴 함

글로벌 BatchSize 적용

default_batch_fetch_size를 전역 설정해두면 대부분의 N+1을 자동으로 완화할 수 있다.

spring:
  jpa:
    properties:
      hibernate:
        default_batch_fetch_size: 1000

이 설정 하나로 지연 로딩 시 IN 절 최적화가 자동으로 적용되어 대부분의 N+1 문제가 N/1000 + 1 수준으로 줄어든다.

DTO 프로젝션 전략

화면에 필요한 데이터만 조회할 때는 엔티티 대신 DTO를 직접 반환한다.

// 조회 전용 서비스: DTO 직접 반환
@Transactional(readOnly = true)
public List<MemberTeamDto> searchMembers(MemberSearchCondition condition) {
    return queryFactory
            .select(new QMemberTeamDto(
                    member.id,
                    member.username,
                    member.age,
                    team.name))
            .from(member)
            .leftJoin(member.team, team)
            .where(
                    usernameEq(condition.getUsername()),
                    teamNameEq(condition.getTeamName()),
                    ageGoe(condition.getAgeGoe()),
                    ageLoe(condition.getAgeLoe()))
            .fetch();
}

Spring Data JPA에서의 적용

public interface TeamRepository extends JpaRepository<Team, Long> {

    // 페이징: @BatchSize 글로벌 설정 활용
    Page<Team> findAll(Pageable pageable);

    // 단건 조회 시 Fetch Join: @EntityGraph 활용
    @EntityGraph(attributePaths = {"members"})
    Optional<Team> findWithMembersById(Long id);

    // 복잡한 조건 조회: QueryDSL 사용 (별도 Custom Repository)
    // TeamRepositoryCustom 인터페이스 + TeamRepositoryCustomImpl 구현
}

// Custom Repository 구현
public class TeamRepositoryCustomImpl implements TeamRepositoryCustom {

    private final JPAQueryFactory queryFactory;

    public List<TeamDto> searchTeams(TeamSearchCondition condition) {
        return queryFactory
                .select(new QTeamDto(team.id, team.name, member.count()))
                .from(team)
                .leftJoin(team.members, member)
                .groupBy(team.id)
                .where(teamNameContains(condition.getTeamName()))
                .fetch();
    }
}

요약 의사결정 흐름

연관 데이터가 필요한가?
    |
    +---> 단건 조회 (em.find, findById)?
    |         -> Fetch Join 또는 @EntityGraph
    |
    +---> 목록 조회 (findAll)?
    |         |
    |         +---> 페이징 필요?
    |         |         -> 컬렉션 Fetch Join 금지
    |         |         -> @BatchSize(global) + LAZY 기본 전략
    |         |
    |         +---> 페이징 불필요?
    |                   -> Fetch Join (단, 컬렉션 1개만)
    |                   -> 컬렉션 2개 이상: Set 또는 쿼리 분리
    |
    +---> 조회 전용 + 성능 최우선?
              -> QueryDSL + DTO 직접 조회

참조 - 자바 ORM 표준 JPA 프로그래밍 By 김영한
참조 - 실전! 스프링 부트와 JPA 활용 By 김영한
참조 - 실전! Querydsl By 김영한

카테고리:

업데이트: