Hibernate/JPA(EntityManager)

[JPA] 즉시로딩/지연로딩(Eager/Lazy) 과 JPQL N+1 이슈

유혁스쿨 2023. 7. 13. 14:49
728x90
반응형

지연로딩(Lazy)

JPA는 지연로딩 전략을 지원한다.

@ManyToOne(fetch = FetchType.LAZY)

위와 같이 @XxxTOXxx 어노테이션의 fetch옵션을 통해 로딩 전략을 설정할 수 있으며 Eager, Lazy 두개의 전략을 설정할 수 있다.

  • Eager : 즉시로딩
    연관관계 엔티티를 모두 조회한다.
    @ManyToXxxx fetch 옵션 기본값이다
  • Lazy : 지연로딩
    현재 조회하려는 엔티티만을 조회하고, 연관관계 엔터티는 객체탐색으로 탐색하는 순간 조회된다.
    @XxxxToMany fetch 옵션 기본값이다

[User Entity]

@Entity(name = "USERS")
@Setter
@Getter
@ToString
public class User {
    @Id @GeneratedValue
    @Column(name = "USER_ID")
    private Long id;
    private String username;

    @ManyToOne
    @JoinColumn(name = "TEAM_ID")
    private Team team;
}

[테스트 코드]

@SpringBootTest
@Transactional
@Commit
class DemoApplicationTests {

    @PersistenceContext
    EntityManager em;
    
    @Test
    void Proxy() {
        Team team = new Team();
		team.setTeamname("TeamA");
		em.persist(team);
        
		User user = new User();
		user.setUsername("hello");
		user.setTeam(team);
		em.persist(user);

        em.flush();
        em.clear();

        User findUser = em.find(User.class, 1L);
    }
}

[테스트 결과] 단순히 User만 조회했을 뿐인데 JOIN을 통해 연관관계 엔터티 Team까지 조회한다.

insert
into
       users
       (team_id, username, user_id)
values
       (NULL, 'hello', 1)

select
       user0_.user_id as user_id1_10_0_,
       user0_.team_id as team_id3_10_0_,
       user0_.username as username2_10_0_,
       team1_.team_id as team_id1_8_1_,
       team1_.teamname as teamname2_8_1_
from
       users user0_
left outer join
       team team1_
             on user0_.team_id=team1_.team_id
where
       user0_.user_id=1

 

[User Entity] Lazy로딩 전략 설정

@Entity(name = "USERS")
@Setter
@Getter
@ToString
public class User {
    @Id @GeneratedValue
    @Column(name = "USER_ID")
    private Long id;
    private String username;

    @ManyToOne(fetch = FetchType.LAZY) // Lazy 로딩 전략
    @JoinColumn(name = "TEAM_ID")
    private Team team;
}

[테스트 결과] 연관관계 테이블에 대한 Join이 발생하지 않고 User만 조회해온다.

insert
into
       users
       (team_id, username, user_id)
values
       (NULL, 'hello', 1)

select
       user0_.user_id as user_id1_10_0_,
       user0_.team_id as team_id3_10_0_,
       user0_.username as username2_10_0_,
from
       users user0_
where
       user0_.user_id=1

이렇게 되면 User 엔티티에는 Team에 대한 객체가 존재하기 때문에 User를 조회해 오면서 Team객체는 값이 초기화되어 있지 않은 Proxy객체로 채워지게 된다.

 

@SpringBootTest
@Transactional
@Commit
class DemoApplicationTests {

    @PersistenceContext
    EntityManager em;
    
    @Test
    void Proxy() {
        Team team = new Team();
        team.setTeamname("TeamA");
        em.persist(team);
        
        User user = new User();
        user.setUsername("hello");
        user.setTeam(team);
        em.persist(user);

        User findUser = em.find(User.class, 1L);
        System.out.println(findUser.getTeam().getClass()) // 프록시 객체인지 출력
    }
}
insert
into
       team
       (teamname, team_id)
values
       ('TeamA', 1)

insert
into
       users
       (team_id, username, user_id)
values
       (1, 'hello', 2)

select
       user0_.user_id as user_id1_10_0_,
       user0_.team_id as team_id3_10_0_,
       user0_.username as username2_10_0_,
from
       users user0_
where
       user0_.user_id=2

Team = class com.example.demo.domain.Team$HibernateProxy$DBd05rYv

위와 같이 User엔터티에 대한 쿼리만 발생하고 Team은 프록시 객체를 반환한다.

 

Team엔터티에 대한 실제 쿼리를 출력하기 위해 아래과 같이 테스트 코드를 작성해보자.

@SpringBootTest
@Transactional
@Commit
class DemoApplicationTests {

    @PersistenceContext
    EntityManager em;
    
    @Test
    void Proxy() {
        Team team = new Team();
        team.setTeamname("TeamA");
        em.persist(team);
        
        User user = new User();
        user.setUsername("hello");
        user.setTeam(team);
        em.persist(user);

        User findUser = em.find(User.class, 1L);
        System.out.println(findUser.getTeam().getClass()) // 프록시 객체인지 출력
        System.out.println("=========================================");
        findUser.getTeam().getTeamname(); // Team Select 쿼리 발생(Team 프록시 초기화)
        System.out.println("=========================================");
    }
}

[결과 출력]

insert
into
       team
       (teamname, team_id)
values
       ('TeamA', 1)

insert
into
       users
       (team_id, username, user_id)
values
       (1, 'hello', 2)

select
       user0_.user_id as user_id1_10_0_,
       user0_.team_id as team_id3_10_0_,
       user0_.username as username2_10_0_,
from
       users user0_
where
       user0_.user_id=2

Team = class com.example.demo.domain.Team$HibernateProxy$DBd05rYv

=========================================
select
      
team0_.team_id as team_id1_8_0_,
      team0_.teamname as teamname2_8_0_
from
       team team0_
where
       team0_.team_id=1
=========================================

Team의 속성을 직접적으로 접근하는 시점에 프록시 객체가 채워지면서 실질적으로 쿼리가 발생한다.

 

지연 로딩일 때 연관관계 엔티티를 앞서 배운 Proxy객체로 가져오게된다!

 


즉시로딩(Eager)

User 엔터티에 선언한 연관관계 엔터티인 Team엔터티의 로딩전략을 EAGER로 변경해보자.

@Entity(name = "USERS")
@Setter
@Getter
@ToString
public class User {
    @Id @GeneratedValue
    @Column(name = "USER_ID")
    private Long id;
    private String username;

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

[테스트 결과] 앞서 기본 전략과 동일하게 JOIN이 걸려서 하나의 쿼리로 조회한다.

insert
into
       users
       (team_id, username, user_id)
values
       (NULL, 'hello', 1)

select
       user0_.user_id as user_id1_10_0_,
       user0_.team_id as team_id3_10_0_,
       user0_.username as username2_10_0_,
       team1_.team_id as team_id1_8_1_,
       team1_.teamname as teamname2_8_1_
from
       users user0_
left outer join
       team team1_
             on user0_.team_id=team1_.team_id
where
       user0_.user_id=1

Team = class com.example.demo.domain.Team

=========================================
=========================================

Team객체는 프록시 객체가 아닌 진짜 Entity객체를 가져오며, User를 조회함과 동시에 JOIN을 통해 함께 반환받기 때문에 프록시 객체로 반환하지 않고 결과적으로 Team 전용 조회 쿼리가 발생하지 않게된다.

 

주의점

 

  • 가급적 지연 로딩만 사용한다(실무)

  • 즉시 로딩(Eager)을 적용하면 예상하지 못한 SQL이 발생하게 된다
    (의도치 않게 @ManyToXxx으로 연관관계가 걸려있는 모든 엔터티를 JOIN으로 모든 엔터티가 조회된다)

  • 즉시 로딩(Eager)은 JPQL에서 N+1 문제를 야기한다.
    List<User> users em.createQuery("select u from User u", User.class).getResultList();

    위와 같이 JPQL예시로 코드를 작성할 경우에 Eager전략임에도 불구하고 쿼리가 2번 나가게 된다.
    em.find()라는것은 PK를 기준으로 내부적으로 최적화해서 가져오지만, JPQL의 경우 SQL로 그대로 번역이 된다.
    당연히 User만 쿼리를 날리게 된다. User를 반환한 뒤 User의 Team필드가 Eager 전략인 것을 확인한다.
    Eager는 값이 무조건 채워져 있어야 하기 때문에 Team에 대한 쿼리가 별도로 한번 더 날라간다.
    1. User에 대한 SELECT 쿼리 발생
    2. User의 연관관계 Team엔터티의 전략이 EAGER 임을 확인.
    3. Team엔터티에 대한 쿼리 발생
      (EAGER전략은 연관관계 엔터티 Proxy객체 값이 무조건 채워져 있어야 하기 때문)
    4. N+1이란 무엇인가?
      만약 User가 두명이고 각각은 서로 다른 Team을 가지고 있다고 가정해본다. [userA : teamA / userB : teamB]
      위와 같은 상황에서 모든 user를 List로 가져오게 되면 User 조회쿼리 1번과 TeamA, TeamB 조회쿼리 2개를 가져온다.
      Team 데이터가  만약 10개가 존재하게 된다면 10 + 1 현상이 발생하게 되는것이다.
      여기서 말하는 1은 처음 리스트로 조회해 오는 User에 대한 갯수이고, 나머지 10 은 User각각이 가지고 있어야 할 Team에 대한 쿼리 조회수 이다.
    5. N+1 해결방 : LAZY 로딩 정책을 기본으로 가져간다.
      1. fetch join : Query에 join 뒤에 fetch를 선언한다. (join fetch)
      2. @EntityGraph : @EntityGraph(attributePaths = ("연관엔터티필드명"))
      3. Batch Size 
  • @ManyToOne, @OneToOne은 기본이 즉시로딩이다.
    (지연 로딩인 Lazy로 설정한다.)


  • @OneToMany, @ManyToMany는 기본이 지연로딩이다.

 

728x90
반응형