본문 바로가기

Spring/Data

N+1 문제와 해결

JPA의 성능 문제 중 70~80% 가 N+1 문제라고 한다. N+1 문제를 해결할 수 있는 방법은 크게 3가지 (Fetch Join, @EntityGraph, @BatchSize) 정도가 있다. @EntityGraph, @BatchSize는 JPA 옵션 같은 느낌이고 사실 Fetch Join만 잘 알아도 나머지를 이해하는데 큰 어려움이 없다. 하지만 Fetch Join을 사용하기 위해서는 여러 주의사항을 알고 있어야 하기 때문에 이를 정리하고자 한다. 

N+1 문제

N+1 문제는 연관관계가 매핑된 엔티티를 조회할 때 발생하는 문제로 처음 조회된 쿼리 결과를 바탕으로 N번 이상의 쿼리가 더 실행되는 문제를 말한다. 결과만 본다면 Fetch Join을 사용한 쿼리와 동일한 데이터를 갖고 있어 정상으로 보일 수 있지만, N번의 쿼리로 인해 성능 문제가 발생될 수 있으므로 반드시 해결해야 한다. (만약 FetchType.LAZY 옵션을  주더라도 프록시에 의해 뒤늦게 문제가 발생되는 것이지 문제가 해결되는 것이 아니니 방치해두면 안 된다.)

도메인

위의 간략히 그린 도메인 모델을 토대로 테스트를 진행해보려고 한다. 

Contest : 대회 엔티티

Promoter : 주최자 엔티티

Team : 팀 엔티티

ContestTeam : 대회의 팀 순위를 관리하는 엔티티 (이하 대회팀 엔티티)

Participant : 참가자 엔티티

 

1. 한 명의 주최자는 하나의 대회를 열 수 있다. (주최자와 대회는 1:1 연관관계를 가진다.)

2. 대회를 참가하기 위해서는 팀 단위로 참가할 수 있으며 참가 팀의 수를 제한하지 않는다. 

3. 대회가 시작되면 모든 팀의 순위를 매긴다. (대회와 팀 간의 순위를 관리하는 엔티티를 만들고 대회와 1:N, 팀과 1:N 연관관계를 가진다.)

4. 팀마다 참가할 수 있는 인원은 2명 이상이다. (팀과 참가자는 1:N 연관관계를 가진다.)

엔티티

@Entity
public class Contest {

    @Id @GeneratedValue
    @Column(name = "contest_id")
    private Long id;

    @Column(name = "contest_name")
    private String ContestName;

    @OneToOne(cascade = CascadeType.ALL, fetch = FetchType.LAZY)
    @JoinColumn(name = "promoter_id")
    Promoter promoter;

    @OneToMany(mappedBy = "contest", cascade = CascadeType.ALL)
    List<ContestTeam> contestTeams = new ArrayList<>();
}


@Entity
public class ContestTeam {

    @Id @GeneratedValue
    @Column(name = "Contest_Team_id")
    private Long id;

    private Integer rank;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "contest_id")
    Contest contest;

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


@Entity
public class Participant {
    @Id
    @GeneratedValue
    @Column(name = "member_id")
    private Long id;

    @Column(name = "username")
    private String username;

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


@Entity
public class Promoter {

    @Id
    @GeneratedValue
    @Column(name = "promoter_id")
    private Long id;

    @Column(name = "promoter_name")
    private String promoterName;

    @OneToOne(mappedBy = "promoter", fetch = FetchType.LAZY)
    private Contest contest;
}


@Entity
public class Team {

    @Id
    @GeneratedValue
    @Column(name = "team_id")
    private Long id;

    @Column(name = "team_name")
    private String teamName;

    @OneToMany(mappedBy = "team")
    private List<Participant> participants = new ArrayList<>();
}

도메인 모델을 바탕으로 각 엔티티 간 연관관계를 만들어 주었다.

설명을 조금 덧붙이자면 대회 엔티티와 연관관계를 가진 주최자, 대회팀 엔티티에 영속성 전이를 하였고 @XtoOne으로 연관관계가 매핑된 엔티티에 대해 모두 지연 로딩을 적용시켰다.

테스트 데이터

총데이터는 이러하다.

2개의 대회와 2명의 주최자가 존재한다.

contestA라는 대회에 teamA, teamB가 참여했고, contestB라는 대회에 teamB와 TeamC가 참여했다.

저장된 총팀은 3팀이며, 총참가자는 6명으로 각 팀당 2명씩 존재한다.

조회 테스트

조회 테스트를 하기 위해서 여러 버전으로 나누고 대회 엔티티를 기준으로 @XToMany로 연관관계가 매핑된 엔티티부터 조회할 예정이다.

처음 버전에서 N+1 문제 발생시켜 이를 해결하고, 최종적으로 대회 엔티티를 기준으로 대회 주최자, 대회 순위, 대회 참가팀, 참가팀 멤버를 전부 조회하며 Fetch Join을 사용함에 있어 주의할 점을 기록해보려고 한다.

버전 1 

첫 번째 버전은 대회 엔티티 기준으로 @XToOne 연관관계가 매핑된 엔티티만 조회 후 조회된 엔티티를 DTO로 변환한다.

도메인 모델을 보게 되면 대회와 @XToOne 연관관계가 매핑된 엔티티는 주최자만 되어있는 것을 알 수 있다.

따라서 버전 1 은 대회 엔티티와 주최자 엔티티만 조회한다.

 

버전 1 테스트 코드

//엔티티의 조회한 결과를 담는 DTO
//대회 엔티티와 주최자 엔티티의 값만을 담기 위해 만들어졌다.
@Data
public class SimpleContestDto {
    private Long contestId;
    private String contestName;
    private String promoterName;

    public SimpleContestDto(Contest contest) {
        this.contestId = contest.getId();
        this.contestName = contest.getContestName();
        this.promoterName = contest.getPromoter().getPromoterName(); //지연 로딩 발생
    }
}

//대회 엔티티와 @XToOne 으로 연관관계 매핑된 엔티티만 조회
@RequiredArgsConstructor
@Repository
public class ContestRepository {

    private final EntityManager em;


    public List<Contest> findContestV1() {
        List<Contest> result = em.createQuery(
                        "select c " +
                        "from Contest c " +
                        "join c.promoter p", Contest.class
        ).getResultList();

        return result;
    }
}

//버전1 테스트 쿼리
@SpringBootTest
class ContestRepositoryTest {

    @Autowired
    ContestRepository contestRepository;

    @Transactional
    @Test
    public void v1() {
        List<Contest> result = contestRepository.findContestV1();
        List<SimpleContestDto> contestDtos = result.stream()
                .map(c -> new SimpleContestDto(c))
                .collect(Collectors.toList());
    }
}

 

결과

=======================================================================================
    select
        contest0_.contest_id as contest_1_2_,
        contest0_.contest_name as contest_2_2_,
        contest0_.promoter_id as promoter3_2_ 
    from
        contest contest0_ 
    inner join
        promoter promoter1_ 
            on contest0_.promoter_id=promoter1_.promoter_id
=======================================================================================
    select
        promoter0_.promoter_id as promoter1_10_0_,
        promoter0_.promoter_name as promoter2_10_0_ 
    from
        promoter promoter0_ 
    where
        promoter0_.promoter_id=?
=======================================================================================
    select
        promoter0_.promoter_id as promoter1_10_0_,
        promoter0_.promoter_name as promoter2_10_0_ 
    from
        promoter promoter0_ 
    where
        promoter0_.promoter_id=?
=======================================================================================

결과
SimpleContestDto{contestId=28, contestName='contestA', promoterName='promoterA'}
SimpleContestDto{contestId=31, contestName='contestB', promoterName='promoterB'}

결과를 보면 예상과는 달리 총 3번의 쿼리가 실행된 것을 알 수 있다.

쿼리를 보면 INNER JOIN을 걸었지만 위와 같이 여러 번의 쿼리가 더 실행되는 문제를 N+1 문제라고 한다.

 

N+1 동작 순서

1. 첫 번째 쿼리 실행 (SELECT 절을 확인해보니 주최자 엔티티에 대해서는 ID만 조회되었다.)

2. 쿼리 완료 후 DTO로 변환 (변환하는 과정에서 주최자 엔티티의 NAME을 가져온다.)

3. for 문 실행 (총 조회 데이터는 2개이므로 2번 loop)

3. 첫 번째 loop에서 주최자의 NAME 값이 없다는 걸 판단 후 지연 로딩 발생

4. 두 번째 쿼리가 실행되어 주최자의  NAME을 가져온다.

5. 두 번째 loop에서 주최자의 NAME 값이 없다는 걸 판단 후 지연 로딩 발생

6. 세 번째 쿼리가 실행되어 주최자의 NAME을 가져온다.

 

만약 처음 조회된 쿼리에서 주최자 엔티티의 ID가 100개가 있었다면 100번의 name을 가져오기 위해 수많은 쿼리가 실행되었을 것이다. 또한, 결과만 보고 정상으로 판단해서 운영까지 나간다면 성능에 문제가 생길 우려가 있다. 이렇게 N+1 문제는 상당히 위험한 문제다. 

 

JPA는 N+1 문제를 해결할 수 있도록 Fetch Join이란 기능을 제공한다.

사실 Fetch Join 대신 Projection을 이용해 주최자 엔티티의 name을 첫 번째 쿼리에서 가져와 해결하는 방법도 있고 정 안되면 Jdbc Template과 네이티브 쿼리를 작성하는 방법도 있다. 

하지만 이번 시간에는 Fetch Join을 중점으로 사용해서 N+1 문제를 해결해보겠다.

버전 2

이번 버전에서는 버전 1에서 사용된 쿼리에 Fetch Join만 적용해보겠다.

 

버전 2 테스트 코드

//Fetch Join을 적용해서 N+1 문제 해결
public List<Contest> findContestV2() {
    List<Contest> result = em.createQuery(
                    "select c " +
                    "from Contest c " +
                    "join FETCH c.promoter p", Contest.class
    ).getResultList();

    return result;
}

 

결과

=======================================================================================
    select
        contest0_.contest_id as contest_1_2_0_,
        promoter1_.promoter_id as promoter1_10_1_,
        contest0_.contest_name as contest_2_2_0_,
        contest0_.promoter_id as promoter3_2_0_,
        promoter1_.promoter_name as promoter2_10_1_ 
    from
        contest contest0_ 
    inner join
        promoter promoter1_ 
            on contest0_.promoter_id=promoter1_.promoter_id
=======================================================================================

결과
SimpleContestDto{contestId=28, contestName='contestA', promoterName='promoterA'}
SimpleContestDto{contestId=31, contestName='contestB', promoterName='promoterB'}

Fetch Join만 적용했을 뿐인데 N+1 문제가 해결되었다. 쿼리의 SELECT 절을 보면 주최자 엔티티의 NAME도 같이 가져온 걸 알 수 있다. 이것이 Fetch Join의 첫 번째 특징이다. Fetch Join을 적용하면 메인 엔티티 외에 Fetch Join의 대상인 엔티티의 모든 필드 값을 가져온다.

 

하지만 최종 목표는 @XToOne로 매핑된 엔티티만 가져오는 것이 아니라 대회에 참여하는 모든 참가자까지 가져와야 한다.

즉 @OneToMany로 매핑된 엔티티도 가져와야 한다. 다음으로 버전 1, 2에서 왜 @XToOne으로 매핑된 엔티티만 가져왔는지 살펴보자.

컬렉션 조회 주의사항

@OneToMany로 매핑된 엔티티 조회를 컬렉션 조회라고도 한다.

컬렉션 조회 시 크게 세 가지의 주의사항이 있다.

 

1. 컬렉션 조회를 할 경우 데이터가 뻥튀기된다.

만약 One 쪽에는 데이터가 1개, Many 쪽에는 데이터가 3개가 있다면 총 3개의 결과가 나오게 된다. 즉 Many의 개수에 따라 데이터가 증가하게 된다.

 

2. Fetch Join을 적용하면 페이징은 불가능하다.

컬렉션 조회 시 Fetch Join을 적용하면 일대다 조인이 발생하므로 데이터가 예측할 수 없이 증가한다.

일대다 관계에서 일(1)을 기준으로 페이징을 하는 것이 목적이지만 데이터는 다(N)를 기준으로 row 가 생성된다.
대회 엔티티를 기준으로 페이징 하고 싶은데, 다(N)인 대회팀 엔티티를 조인하면 대회팀 엔티티가 기준이 되어버린다.
이 경우 하이버네이트는 경고 로그를 남기고 모든 DB 데이터를 읽어서 메모리에서 페이징을 시도한다. 최악의 경우 장애로 이어질 수 있다. 

 

예시) 데이터가 10만 건이 나와 페이징을 시도할 경우, DB에서 10만 건을 모두 읽어오고, 메모리에 10만 건의 데이터를 다 올린 후 페이징을 시도한다. (OOM이 발생할 수 있다.)

 

3. Fetch Join 컬렉션 조회당 한 번만 적용할  있다.

1번 특징에서 컬렉션 조회를 할 경우 데이터가 뻥튀기된다고 했다. 만약 두 개 이상의 컬렉션을 한 번에 조회를 한다면 부정합하게 조회될 수 있다.

 

이런 경우 한 번에 조회할 수 없다.

1. 엔티티 A와 @OneToMany로 매핑된 엔티티B와 C가 있는 경우

2. 엔티티A와 @OneToMany로 매핑된 엔티티 B가 있고, 엔티티 B와 @OneToMany로 매핑된 엔티티 C가 있는 경우

 

그렇다면 이제 위의 주의사항을 해결할 수 있는 테스트를 해보겠다.

버전 3

버전 3에서는 @OneToMany로 매핑된  대회팀 엔티티, 대화팀 엔티티와 @ManyToOne으로 매핑된 팀 엔티티까지 조회를 해보도록 하겠다.

 

DTO 변경

//기존 SimpleContestDto와 다른 DTO
//대회팀 엔티티도 담을 수 있도록 설계되었다.
class ContestDto {
    private Long contestId;
    private String contestName;
    private String promoterName;

    private List<ContestTeamDto> contestTeamDtos;

    public ContestDto(Contest contest) {
        this.contestId = contest.getId();
        this.contestName = contest.getContestName();
        this.promoterName = contest.getPromoter().getPromoterName();
        this.contestTeamDtos = contest.getContestTeams().stream()
                .map(c -> new ContestTeamDto(c))
                .collect(Collectors.toList());
    }
}

//ContestTeam을 가져오는 DTO
class ContestTeamDto {
    private Long contestTeamId;
    private Integer rank;
    private String teamName;

    public ContestTeamDto(ContestTeam contestTeam) {
        this.contestTeamId = contestTeam.getId();
        this.rank = contestTeam.getRank();
        this.teamName = contestTeam.getTeam().getTeamName();
    }
}

이전 버전에서는 DTO로 변환하기 위해 SimpleContestDto를 사용했었다.

하지만 버전 3부터는 ContestTeam 엔티티와 Team 엔티티도 조회해야 하기 때문에 대회 엔티티, 주최자 엔티티만 담을 수 있는 SimpleContestDto를 사용하지 않았다.

ContestDto를 새로 만들어주었고 ContestTeam의 값을 담기 위해 ContestTeamDto도 함께 만들어주었다.

 

테스트 쿼리

public List<Contest> findContestV3() {
    List<Contest> result = em.createQuery(
            "select distinct c " +
                    "from Contest c " +
                    "join FETCH c.promoter p " +
                    "join FETCH c.contestTeams ct " +
                    "join FETCH ct.team t", Contest.class
    ).getResultList();

    return result;
}

버전 3 쿼리를 보면 모든 엔티티에 Fetch Join을 적용해주었고 SELECT 절에 distinct가 추가된 걸 알 수 있다. 

distinct를 설명하자면, 데이터베이스에서 사용하는 distinct와 같은 점은 중복된 데이터를 제거해주는 기능을 한다는 점이다.

하지만 데이터베이스에서 사용하는 distinct의 경우 2 row 이상 모든 필드의 값이 동일해야지만 지워준다.

JPA에서 사용하는 distinct는 주 테이블(root)의 식별자(id)의 중복을 판단하고 지워준다.

 

결과

=======================================================================================
    select
        distinct contest0_.contest_id as contest_1_2_0_,
        promoter1_.promoter_id as promoter1_10_1_,
        contesttea2_.contest_team_id as contest_1_3_2_,
        team3_.team_id as team_id1_11_3_,
        contest0_.contest_name as contest_2_2_0_,
        contest0_.promoter_id as promoter3_2_0_,
        promoter1_.promoter_name as promoter2_10_1_,
        contesttea2_.contest_id as contest_3_3_2_,
        contesttea2_.rank as rank2_3_2_,
        contesttea2_.team_id as team_id4_3_2_,
        contesttea2_.contest_id as contest_3_3_0__,
        contesttea2_.contest_team_id as contest_1_3_0__,
        team3_.team_name as team_nam2_11_3_ 
    from
        contest contest0_ 
    inner join
        promoter promoter1_ 
            on contest0_.promoter_id=promoter1_.promoter_id 
    inner join
        contest_team contesttea2_ 
            on contest0_.contest_id=contesttea2_.contest_id 
    inner join
        team team3_ 
            on contesttea2_.team_id=team3_.team_id
=======================================================================================

결과
ContestDto{contestId=28, contestName='contestA', promoterName='promoterA', 
	contestTeamDtos=[
    	ContestTeamDto{contestTeamId=26, rank=1, teamName='teamA'}, 
        ContestTeamDto{contestTeamId=27, rank=2, teamName='teamB'}
    ]
}

ContestDto{contestId=31, contestName='contestB', promoterName='promoterB', 
	contestTeamDtos=[
    	ContestTeamDto{contestTeamId=29, rank=1, teamName='teamC'}, 
        ContestTeamDto{contestTeamId=30, rank=2, teamName='teamB'}
    ]
}

이로써 distinct를 적용해서 데이터가 뻥튀기되는 문제도 해결할 수 있게 되었다.

하지만 distinct를 사용해 쿼리를 작성해도 아직 2가지 문제가 남았다.

 

1. 컬렉션 조회를 했기 때문에 눈에 보이지 않지만 실제 쿼리는 데이터 뻥튀기가 일어났다.

distinct를 사용해 JPA가 내부적으로 데이터를 줄여주었지만 실제 쿼리는 데이터가 뻥튀기 되었고 이를 메모리에 올려 작업을 하기 때문에 사실상 성능에 최적화된 쿼리는 아니다.

 

2. 컬렉션 조회를 했기 때문에 페이징이 불가능하다.

주의사항 2번에 적은 내용 때문에 페이징을 해결하지 못했다.

버전 4

이번 버전에서는 @XToOne으로 매핑된 엔티티만 먼저 조회를 하는데 이때 페이징도 같이할 예정이다.

그리고 아래와 같이 hibernate.default_batch_fetch_size 옵션을 적용해서 최적화를 해보겠다.

 

applicatioin.yml

spring:      
  jpa:
    properties:
      hibernate:
          default_batch_fetch_size: 100

위와 같이 설정하면 된다. (개별적으로 적용하기 위해서는 @BatchSize로 적용해주면 된다.)

숫자 100은 in절의 개수를 의미한다. ( 예시 : in (?, ?, ?, .... , ?) )

 

테스트 코드

//setFirstResult(): 시작 데이터, setMaxResults()마지막 데이터
//페이징을 하기위해 @XToOne으로 매핑된 주최자 엔티티만 같이 조회
@RequiredArgsConstructor
@Repository
public class ContestRepository {

    private final EntityManager em;

    public List<Contest> findContestV4(int offset, int limit) {
        List<Contest> result = em.createQuery(
                "select c " +
                "from Contest c " +
                "join FETCH c.promoter p", Contest.class)
                .setFirstResult(offset)
                .setMaxResults(limit)
                .getResultList();

        return result;
    }
}

//페이징은 0번부터 시작된다.
//가져올 데이터는 총 2개로 정한다.
@SpringBootTest
class ContestRepositoryTest {

    @Autowired
    ContestRepository contestRepository;
    
    @Transactional
    @Test
    public void v4() {

        int offset = 0;
        int limit = 2;
        
        List<Contest> result = contestRepository.findContestV4(offset, limit);

        List<ContestDto> contestDtos = result.stream()
                .map(c -> new ContestDto(c))
                .collect(Collectors.toList());
    }
}

 

결과

=======================================================================================
    select
        contest0_.contest_id as contest_1_2_0_,
        promoter1_.promoter_id as promoter1_10_1_,
        contest0_.contest_name as contest_2_2_0_,
        contest0_.promoter_id as promoter3_2_0_,
        promoter1_.promoter_name as promoter2_10_1_ 
    from
        contest contest0_ 
    inner join
        promoter promoter1_ 
            on contest0_.promoter_id=promoter1_.promoter_id limit ?
=======================================================================================
    select
        contesttea0_.contest_id as contest_3_3_1_,
        contesttea0_.contest_team_id as contest_1_3_1_,
        contesttea0_.contest_team_id as contest_1_3_0_,
        contesttea0_.contest_id as contest_3_3_0_,
        contesttea0_.rank as rank2_3_0_,
        contesttea0_.team_id as team_id4_3_0_ 
    from
        contest_team contesttea0_ 
    where
        contesttea0_.contest_id in (
            ?, ?
        )
=======================================================================================
    select
        team0_.team_id as team_id1_11_0_,
        team0_.team_name as team_nam2_11_0_ 
    from
        team team0_ 
    where
        team0_.team_id in (
            ?, ?, ?
        )
=======================================================================================

결과
ContestDto{contestId=28, contestName='contestA', promoterName='promoterA', 
	contestTeamDtos=[
    	ContestTeamDto{contestTeamId=26, rank=1, teamName='teamA'}, 
        ContestTeamDto{contestTeamId=27, rank=2, teamName='teamB'}
    ]
}

ContestDto{contestId=31, contestName='contestB', promoterName='promoterB', 
	contestTeamDtos=[
    	ContestTeamDto{contestTeamId=29, rank=1, teamName='teamC'}, 
        ContestTeamDto{contestTeamId=30, rank=2, teamName='teamB'}
    ]
}

결과는 버전 3이랑 동일하게 나왔지만 버전 3과는 다르게 쿼리가 3번 나간걸 알 수 있다.

어떻게 보면 N+1 문제로 볼 수 있지만 위와 같이 쿼리가 여러번 나간건 N+1 문제가 아니다.

 

두 번째, 세 번째 쿼리를 보면 마지막에 IN절이 들어간걸 알 수 있다. hibernate.default_batch_fetch_size 옵션을 사용하면 IN절을 사용하게 되는데 N번 실행하는 쿼리를 IN절을 사용해 1번의 쿼리로 결과를 가져오게 한다.

 

hibernate.default_batch_fetch_size 옵션을 사용하더라도 반드시 한 번에 결과를 가져오는게 아니다.

사용하는 데이터베이스마다 IN절의 맥시멈 개수가 다르기 때문에 데이터베이스의 특성을 잘 알아야한다.

 

또한, size를 100으로 했다고 해서 IN(?) 부터 IN(?*100) 까지 전부 만들어 놓지 않는다.

하이버네이트는 내부에서 나름 최적화해서 preparedstatement 쿼리를 캐싱한다.

size가 100이라고 가정한다면 100 = 설정값, 50 = 100/2, 25 = 50/2, 12 = 25/2, 1~10까지 총 14개의 쿼리를 캐싱한다.

 

옵션

spring.jpa.properties.hibernate.batch_fetch_style: legacy //기본

spring.jpa.properties.hibernate.batch_fetch_style: padded

spring.jpa.properties.hibernate.batch_fetch_style: dynamic //최적화X

 

버전 3과 버전 4의 차이

버전 3은 한 번에 조회하는 장점이 있지만 distinct를 사용해서 중복 데이터를 제거해야하고 페이징을 할 수 없다.

버전 4의 경우 페이징은 가능하지만 1+1+1+.. 으로 쿼리가 실행되는 단점이 있다.

페이징이 필요없는 경우라면 버전 3, 페이징이 필요하다면 버전 4를 사용하면 된다.

 

조회 쿼리 생성 권장 순서 

1. 엔티티조회방식으로우선접근

      1. 페치조인으로 쿼리 수를 최적화 

      2. 컬렉션 최적화 

            1. 페이징 필요 hibernate.default_batch_fetch_size , @BatchSize 로 최적화 

            2. 페이징 필요X 페치 조인 사용
2. 엔티티 조회 방식으로 해결이 안되면 DTO조회 방식 사용
3. DTO 조회 방식으로 해결이 안되면 NativeSQL or 스프링 JdbcTemplate 


참고자료

JPA 강의 로드맵

https://docs.jboss.org/hibernate/orm/4.2/manual/en-US/html/ch20.html#performance-fetching-batch