Skip to main content Link Search Menu Expand Document (external link)
  • JPQL 이란? (의미, 흐름 등)
  • fetch join
    • fetch join 이란
    • fetch join 의 장점
    • fetch join 과 일반 join 의 차이
    • fetch join 의 한계와 해결책
  • 1+N 문제해결
    • 1+N 문제란
    • 어떻게 해결하나 (방법은 세 가지)
      • EAGER 전략(아예 사용하지 말자)
      • fetch join
      • default_batch_fetch_size, @BatchSize
  • Criteria(다른 강의에서 이미 정리했기에 생략)
  • Query Dsl(다른 강의에서 이미 정리했기에 생략)

이번 단원은 대체로 이전에 백기선님 강의에서 다뤘던 개념들이 대부분이어서 정리할 포인트가 많지 않았다. 다만, fetch join 에 대해서 자세히 다루고 있었어서 그 부분을 중점적으로 봤다.

JPQL 이란? (의미, 흐름 등)

Java Persistence Query Language의 줄임말로 아래와 같은 특징을 가진다.

*SQL이 데이터베이스 대상이라면 JPQL은 엔티티 객체를 대상으로 한다.
*JPQL은 결국 SQL 로 변환되어 실행된다.
*JPQL은 SQL을 추상화해서 특정 데이터베이스에 의존하지 않기 때문에 여러 데이터베이스의 방언에 맞게 SQL로 변경된다.

책에서 나온 예제에서는 entityManager 를 통해서 JPQL 을 사용하는 예제가 많았다. 책의 예제를 잘 정리해둔 포스팅 의 링크를 남긴다.

Spring Data JPA 사용시 @Query 에서 자주 사용하게 된다.

fetch join

fetch join 이란

fetch 라는 단어 뜻 이 ‘to go after and bring back (someone or something)’이다. 여태 일을 하며 fetch라는 단어를 많이 접했었는데, 막상 순수하게 단어 뜻을 보니 왜 fetch라고 사용되어 왔었는지 이해가 바로 되었다. (역시 근본적인 이해는 참 중요하다)

책의 표현을 빌리면 ‘연관된 엔티티나 컬렉션을 한번에 조회하는 기능’이다.

fetch join 의 장점

fetch 전략을 LAZY로 해두었다고 해도 fetch join 으로 조회를 할 경우 해당 엔티티를 프록시 객체가 아닌 실제 엔티티로 할당한다. 실제 엔티티로 할당되기 때문에 그 엔티티를 반드시 사용할 경우라면 굳이 한번 더 쿼리를 수행할 필요가 없이(LAZY하게 가져올 필요 없이) 최초 한번으로 다 가져올 수 있기에 성능상 유리하다.

EAGER 전략으로 얻는 이점보다 부작용 및 우려가 크기 때문에 모든 글로벌 fetch 전략을 LAZY로 설정해두고 EAGER 하게 가져와야할 경우에 fetch join 을 사용해주자.

public interface MemberRepository extends JpaRepository<Member, Long> {

    @Query(value = "select m from Member as m join fetch m.team where m.team.name = :teamName")
    List<Member> findAllByTeamNameFetchJoin(@Param(value = "teamName") String teamName);

    List<Member> findAll();

}
    @Override
    @Transactional
    public void run(ApplicationArguments args) throws Exception {
        List<Member> members = memberRepository.findAll();
        System.out.println(members.get(0).getTeam().getName());
        System.out.println(members.get(0).getTeam().getClass().getName());
    }
Hibernate: 
    select
        member0_.id as id1_0_,
        member0_.name as name2_0_,
        member0_.team_id as team_id3_0_ 
    from
        member member0_

Hibernate: 
    select
        team0_.id as id1_1_0_,
        team0_.name as name2_1_0_ 
    from
        team team0_ 
    where
        team0_.id=?

com.fistkim.springjpawhiteshipstudy.Team$HibernateProxy$4OTJPoST

LAZY 를 기본 fetch 전략으로 선택했기 때문에 쿼리가 두번 요청된 것을 볼 수 있고, Team 이 프록시 객체가 할당 된 것을 알 수 있다. 반면 fetch join을 사용하면 아래와 같이 Entity 가 나온다.

    @Override
    @Transactional
    public void run(ApplicationArguments args) throws Exception {
        List<Member> members = memberRepository.findAllByTeamNameFetchJoin("NewTeam");
        System.out.println(members.get(0).getTeam().getName());
        System.out.println(members.get(0).getTeam().getClass().getName());
    }
Hibernate: 
    select
        member0_.id as id1_0_0_,
        team1_.id as id1_1_1_,
        member0_.name as name2_0_0_,
        member0_.team_id as team_id3_0_0_,
        team1_.name as name2_1_1_ 
    from
        member member0_ 
    inner join
        team team1_ 
            on member0_.team_id=team1_.id 
    where
        team1_.name=?

com.fistkim.springjpawhiteshipstudy.Team

inner join 으로 한 번의 쿼리로 Team 까지 가져왔고, Team 객체 역시 프록시 객체가 아니고 엔티티임을 확인할 수 있다. fetch 라는 말 그대로 가서 엔티티를 가져온 것이다.

책에서는 아래와 같이 entityManager를 이용해서 가져오는 예제를 보여주고 있다. spring data jpa 를 이용해서 fetch join을 사용한 것과 동일한 쿼리가 수행된다.

    @Override
    @Transactional
    public void run(ApplicationArguments args) throws Exception {
        String jpql = "select m from Member as m join fetch m.team where m.team.name = :teamName";
        List<Member> members = entityManager.createQuery(jpql, Member.class)
                .setParameter("teamName", "NewTeam")
                .getResultList();
        System.out.println(members.get(0).getTeam().getName());
        System.out.println(members.get(0).getTeam().getClass().getName());
    }
Hibernate: 
    select
        member0_.id as id1_0_0_,
        team1_.id as id1_1_1_,
        member0_.name as name2_0_0_,
        member0_.team_id as team_id3_0_0_,
        team1_.name as name2_1_1_ 
    from
        member member0_ 
    inner join
        team team1_ 
            on member0_.team_id=team1_.id 
    where
        team1_.name=?

com.fistkim.springjpawhiteshipstudy.Team

fetch join 과 일반 join 의 차이

fetch join 을 하면 inner join이 실행되는데, 이걸 참고해서 그러면 fetch join을 사용하지 않되 sql을 join 이 나가게 설정하면 어떻게 될까?

결과적으로 sql은 join 문이 실행되지만 엔티티에는 프록시 객체가 할당된다.

fetch join 의 한계와 해결책

책에서는 아래와 같이 언급하고 있다.

  • 페치 조인 대상에는 별칭을 줄 수 없다.
  • 둘 이상의 컬렉션을 페치할 수 없다.
  • 컬렉션을 페치 조인 하면 페이징 API를 사용할 수 없다.

페치 조인 대상에는 별칭을 줄 수 없다는 제약은 하이버네이트의 경우 별칭이 가능하다. 하지만 별칭 사용은 객체 탐색의 뎁스가 더 깊어져야할 때에만 사용하도록 한다.

여기서 가장 크리티컬한 것이 페이징을 사용할 수 없다는 것이다. 사용할 수는 있으나 전체 데이터를 메모리에 올려서 페이징 처리를 하므로 문제가 있을 수 있다.

@Query(value = "select t from Team as t join fetch t.members", countQuery = "select count(t) from Team as t inner join t.members")
Page<Team> findAllFetchJoin(Pageable pageable);
.QueryTranslatorImpl   : HHH000104: firstResult/maxResults specified with collection fetch; applying in memory!
Hibernate: 
    select
        team0_.id as id1_1_0_,
        members1_.id as id1_0_1_,
        team0_.name as name2_1_0_,
        members1_.name as name2_0_1_,
        members1_.team_id as team_id3_0_1_,
        members1_.team_id as team_id3_0_0__,
        members1_.id as id1_0_0__ 
    from
        team team0_ 
    inner join
        member members1_ 
            on team0_.id=members1_.team_id

이렇게 limit, offet 같은게 하나도 없이 모두 조회되어서 메모리상에 올라간다. 그래서 ‘firstResult/maxResults specified with collection fetch; applying in memory!’ 와 같은 경고 문구가 뜬다.

해결책으로는 fetch join을 사용하지 않고 LAZY 로딩을 그대로 사용하면서 대신 default_batch_fetch_size 를 이용하는 방법이 있다. 나의 예제 코드에 적용해보면 아래와 같이 나오게 된다.

@Query(value = "select t from Team as t", countQuery = "select count(t) from Team as t")
Page<Team> findAllPage(Pageable pageable);

@Override
@Transactional
public void run(ApplicationArguments args) throws Exception {
    Page<Team> teams = teamRepository.findAllPage(PageRequest.of(0, 2));
    teams.forEach(team -> System.out.println(team.getMembers().size()));
}
# paging query. page 번호를 0으로 넣어서 limit 만 들어간 모습이다.
Hibernate: 
    select
        team0_.id as id1_1_,
        team0_.name as name2_1_ 
    from
        team team0_ limit ?

# 내가 설정해준 count query.
Hibernate: 
    select
        count(team0_.id) as col_0_0_ 
    from
        team team0_

# 지연로딩시 모아서 한번에 in 절로 나간 모습.
Hibernate: 
    select
        members0_.team_id as team_id3_0_1_,
        members0_.id as id1_0_1_,
        members0_.id as id1_0_0_,
        members0_.name as name2_0_0_,
        members0_.team_id as team_id3_0_0_ 
    from
        member members0_ 
    where
        members0_.team_id in (
            ?, ?
        )

이 해결책을 코드에 적용한 것이 정리가 잘 된 포스트가 있어 링크를 남긴다.

*결론적으로 무조건 글로벌 fetch 전략으로 LAZY를 사용하고, EAGER하게 가져와야할 경우 전부 fetch join으로 해결하자.
*다만, 페이징처리가 필요한 경우에는 default_batch_fetch_size를 사용하자!

1+N 문제해결

1+N 문제란?

N+1 문제 는 LAZY 로딩을 사용하게 되면 매우 흔히 겪을 수 있는 문제이다. 이직을 할때 면접에서도 자주 질문을 받았었다. 이는 fetch join 을 이용하면 해결할 수 있다. 또다른 방법으로는 default_batch_fetch_size 설정이 있다.

어떻게 해결하나? - fetch join

먼저 fetch join을 살펴보자.

public interface TeamRepository extends JpaRepository<Team, Long> {

    @Query(value = "select t from Team as t join fetch t.members")
    List<Team> findAllFetchJoin();

}
    @Override
    @Transactional
    public void run(ApplicationArguments args) throws Exception {
        // List<Team> teams = teamRepository.findAll(); <-- N+1 문제 발생
        List<Team> teams = teamRepository.findAllFetchJoin();
        teams.forEach(team -> team.getMembers().forEach(member -> System.out.println(member.getName())));
    }

LAZY 로딩을 사용할 경우 team.getMembers() 를 호출할 때 member 테이블에 대해서 team 의 id 를 이용해서 조회하는 쿼리가 발생한다. 그래서 teams 의 사이즈 만큼 member 테이블에 대한 조회가 발생하게 된다. 최초로 teams 에 대한 조회 1회와 teams 를 순회하며 각 team element 에서 참조하고 있는 member들 컬렉션을 조회하는 N회를 합해서 1+N 쿼리가 수행되는 것이다.

하지만 fetch join 을 사용할 경우 아래와 같이 join 을 활용해서 1회의 쿼리로 조회를 끝낼 수 있다.

Hibernate: 
    select
        team0_.id as id1_1_0_,
        members1_.id as id1_0_1_,
        team0_.name as name2_1_0_,
        members1_.name as name2_0_1_,
        members1_.team_id as team_id3_0_1_,
        members1_.team_id as team_id3_0_0__,
        members1_.id as id1_0_0__ 
    from
        team team0_ 
    inner join
        member members1_ 
            on team0_.id=members1_.team_id

하지만 문제가 있는데, 1:N 관계인 team과 member 의 경우 team 을 조회하게 되면 team의 수보다 더 많은 row가 return 될 수 있다.

    @Override
    @Transactional
    public void run(ApplicationArguments args) throws Exception {
        List<Team> teams = teamRepository.findAllFetchJoin();
        System.out.println(">>>> " + teams.size());
        teams.forEach(team -> System.out.println(team.getId()));
    }
Hibernate: 
    select
        team0_.id as id1_1_0_,
        members1_.id as id1_0_1_,
        team0_.name as name2_1_0_,
        members1_.name as name2_0_1_,
        members1_.team_id as team_id3_0_1_,
        members1_.team_id as team_id3_0_0__,
        members1_.id as id1_0_0__ 
    from
        team team0_ 
    inner join
        member members1_ 
            on team0_.id=members1_.team_id

>>>> 5
1
1
2
2
2

결과를 보면 team은 아이디가 1, 2 밖에 없는데 (총 2개) row가 5개가 나온 것을 볼 수 있다. 이는 쿼리를 보면 inner join 의 결과인 것이다.

해결책으로는 jpql 에서 제공하는 distinct 를 사용하면 된다. sql의 distinct 는 중복된 결과를 제거하는 명령이지만 jpql의 distinct 는 아래 두 가지 기능을 수행한다.

  • 중복된 결과를 제거.
  • 어플리케이션에 올린뒤 중복되는 객체를 제거.

*일대다 페치 조인은 결과 수가 증가할 수 있다.
*일대다 페치 조인은 distinct를 사용하자.
*일대일, 다대일 페치 조인은 결과가 증가하지 않는다.


어떻게 해결하나? - default_batch_fetch_size, @BatchSize

default_batch_fetch_size 의 원리는 지연 로딩으로 할당된 프록시 객체가 호출될 때 바로 호출하지 않고 모아서 in 절로 호출하는 원리이다. 이 포스팅에 정리가 잘 되어있다.

코드에 적용한 예시와 결과는 아래와 같다.

spring:  
  jpa:
    properties:
      hibernate:
        default_batch_fetch_size: 1000
@Override
@Transactional
public void run(ApplicationArguments args) throws Exception {
    List<Team> teams = teamRepository.findAll();
    teams.forEach(team -> System.out.println(team.getMembers().size()));
}
Hibernate: 
    select
        team0_.id as id1_1_,
        team0_.name as name2_1_ 
    from
        team team0_

Hibernate: 
    select
        members0_.team_id as team_id3_0_1_,
        members0_.id as id1_0_1_,
        members0_.id as id1_0_0_,
        members0_.name as name2_0_0_,
        members0_.team_id as team_id3_0_0_ 
    from
        member members0_ 
    where
        members0_.team_id in (
            ?, ?
        )

개별 엔티티에 @BatchSize 를 이용해서 사이즈를 지정해주는 방식도 있다.

    @BatchSize(size = 100)
    @OneToMany(mappedBy = "team", fetch = FetchType.LAZY)
    private Set<Member> members = new HashSet<>();
    @Override
    @Transactional
    public void run(ApplicationArguments args) throws Exception {
        List<Team> teams = teamRepository.findAll();
        teams.forEach(team -> System.out.println(team.getMembers().size()));
    }