JPA

[JPA] 즉시로딩과 지연로딩, JPQL에서의 N+1 문제와 해결 방법

뚜코맨 2024. 7. 21. 18:02

JPA에서 데이터를 조회할 때 즉시 로딩(EAGER)과 지연 로딩(LAZY) 두가지 방식이 있다. 이 두가지 방식을 간단하게 설명하면 즉시 로딩은 데이터를 조회할 때 연관된 데이터까지 한 번에 불러오는 것이고, 지연 로딩은 필요한 시점에 연관된 데이터를 가져오는 것이다.

Fetch Type이란

Fetch Type이란, JPA가 하나의 Entity를 조회할 때, 연관관계에 있는 객체들을 어떻게 가져올 것이냐를 나타내는 설정 값이다.

 

JPA는 ORM 기술로, 사용자가 직접 SQL를 생성하지 않고 JPA에서 JPQL을 이용하여 쿼리문을 생성하기 때문에 객체와 필드를 보고 쿼리를 생성하게 된다. 따라서, 다른 객체와 연관관계가 매핑이 되어있으면 그 객체들까지 조회하게 되는데, 이때 이 객체를 어떤 시점에 불러올 것인지를 설정할 수 있다.

 

Fetch Type의 디폴트 값은 @___ToOne은 EAGER 이고, @___ToMany는 LAZY이다.

즉시로딩(EAGER)

즉시로딩은 데이터를 조회할 때, 연관된 모든 객체의 데이터를 한번에 불러오는 것이다.

@Entity
public class Member {

    @Id @GeneratedValue
    private Long id;
    private String username;

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

@Entity
public class Team {

    @Id @GeneratedValue
    private Long id;
    private String teamname;
    
    ...
}
Team teamA = new Team();
teamA.setName("teamA");
em.persist(teamA);

Member member1 = new Member();
member1.setName("member1");
member1.setTeam(teamA);
em.persist(member1);

Member m = em.find(Member.class, member1.getId());

 

이 코드를 보면 난 지금 member에 대한 정보만 가져오고 있다. 실행 로그를 보자.


member만 조회를 했을 뿐인데, member 엔티티에서 team이 연관관계 매핑이 되어있기 때문에 한번에 left join으로 team의 정보까지 가져온 걸 볼 수 있다. 이것이 즉시로딩이다. 내가 team에 대한 데이터가 필요가 없어도, 연관된 데이터가 있다면 바로 가져오는 것이다.

지연로딩(LAZY)

지연로딩은 필요한 시점에 연관된 객체의 데이터를 가져오는 것이다.

  • 위의 예제에서 fetchType만 변경해보았다.
@ManyToOne(fetch = FetchType.LAZY)
Team teamA = new Team();
teamA.setName("teamA");
em.persist(teamA);

Member member1 = new Member();
member1.setName("member1");
member1.setTeam(teamA);
em.persist(member1);

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

Member m = em.find(Member.class, member1.getId());

System.out.println("================="); // 이 로그를 주목하자
System.out.println("멤버의 팀 이름: "+m.getTeam().getName());
System.out.println("================="); // 이 로그를 주목하자
Hibernate: 
    select
        m1_0.member_id,
        m1_0.createdAt,
        m1_0.insert_member,
        m1_0.lastModifiedAt,
        m1_0.update_member,
        m1_0.username,
        m1_0.team_team_id 
    from
        Member m1_0 
    where
        m1_0.member_id=?
=================
Hibernate: 
    select
        t1_0.team_id,
        t1_0.createdAt,
        t1_0.insert_member,
        t1_0.lastModifiedAt,
        t1_0.update_member,
        t1_0.name 
    from
        Team t1_0 
    where
        t1_0.team_id=?
멤버의 팀 이름: teamA
=================

 

 

지연 로딩을 적용하면 이런 실행 로그가 찍히게 된다. 처음에 Member m = em.find(Member.class, member1.getId()); 을 실행하면 오로지 Member에 대한 정보만 가져오기에, 멤버에 대한 정보만 select가 나가게 되고, 다음 m.getTeam().getName() 을 실행할 때 그때서야 team에 대한 정보를 가져오는 로그를 볼 수 있다. team에 대한 select 쿼리발생 시점은 m.getTeam()을 하더라도 select 쿼리는 찍히지 않는다. 왜냐 getTeam()은 member 안에 있기 때문에 member.getTeam().getName() 오로지 team 객체에 직접적인 touch가 이루어질 때 team에 대한 정보를 select 쿼리로 가져오는 것이다.

 

그럼 지금까지 지연 로딩과 즉시 로딩에 대해 알아봤다. 다음으론 즉시로딩을 쓰면 안되는 이유에 대해 설명하겠다.

즉시로딩(EAGER)을 사용하면 안되는 이유

즉시 로딩을 사용하게 되면 예상하지 못한 SQL의 발생하게 되고, 가장 치명적인 JPQL에서 N+1의 문제를 일으킨다. 

즉시 로딩을 적용한 후 아래의 JPQL 쿼리를 보자.

@ManyToOne(fetch = FetchType.EAGER)
Team teamA = new Team();
teamA.setName("teamA");
em.persist(teamA);

Team teamB = new Team();
teamB.setName("teamB");
em.persist(teamB);

Member member1 = new Member();
member1.setName("member1");
member1.setTeam(teamA);
em.persist(member1);

Member member2 = new Member();
member2.setName("member2");
member2.setTeam(teamB);
em.persist(member2);

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

Member m = em.find(Member.class, member1.getId());

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

 

현재 select m from Member m으로 SQL로 치면 select * from member를 수행하는 쿼리인데 

// 최초 1회 쿼리
Hibernate: 
    /* select
        m 
    from
        Member m */ select
            m1_0.member_id,
            m1_0.createdAt,
            m1_0.insert_member,
            m1_0.lastModifiedAt,
            m1_0.update_member,
            m1_0.username,
            m1_0.team_team_id 
        from
            Member m1_0
            
// "teamA"을 찾아오는 select 쿼리(실행결과에 따른 N개의 쿼리 발생)
Hibernate: 
    select
        t1_0.team_id,
        t1_0.createdAt,
        t1_0.insert_member,
        t1_0.lastModifiedAt,
        t1_0.update_member,
        t1_0.name 
    from
        Team t1_0 
    where
        t1_0.team_id=?
        
// "teamB"을 찾아오는 select 쿼리(실행결과에 따른 N개의 쿼리 발생)
Hibernate: 
    select
        t1_0.team_id,
        t1_0.createdAt,
        t1_0.insert_member,
        t1_0.lastModifiedAt,
        t1_0.update_member,
        t1_0.name 
    from
        Team t1_0 
    where
        t1_0.team_id=?

 

첫번째로는 멤버를 처음에 가져온다. 멤버의 결과값이 2개가 있다. 따라서 해당 멤버 2개의 team정보를 가져오는 것이다.

N(결과값) + 1(최초쿼리) 문제가 발생하는 것이다. 최초 쿼리를 1번이 날라가고 쿼리의 결과값에 따라 N개의 쿼리가 또 나가게 되는 것이다.

 

위 예제의 즉시 로딩에서는 Member와 연관된 Team이 2개여서 2개의 Team을 조회하는 쿼리가 나갔지만, 만약 Member를 조회하는 JPQL을 날렸는데 연관된 Team이 1000개라면? Member를 조회하는 쿼리를 하나 날렸을 뿐인데 Team을 조회하는 SQL 쿼리 1000개가 추가로 나가게 된다. 상상만해도 끔찍하지 않은가. 그렇기 때문에 가급적이면 기본적으로 지연 로딩을 사용하는 것이 좋다.

 

이런 N+1 문제를 해결하는 방법은 일단, 무조건 지연 로딩(fetchType=LAZY)을 사용하는 것이다. 그리고 fetch join을 사용한다.

예를 들자면, 비즈니스적으로 member를 조회할 때 team에 대한 정보를 가져 올 상황이 있을 때 JPQL의 fetch join을 사용하면 해결된다.

List<Member> members = em.createQuery("select m from Member m join fetch m.team", Member.class)
        .getResultList();
Hibernate: 
    /* select
        m 
    from
        Member m 
    join
        
    fetch
        m.team */ select
            m1_0.member_id,
            m1_0.createdAt,
            m1_0.insert_member,
            m1_0.lastModifiedAt,
            m1_0.update_member,
            m1_0.username,
            t1_0.team_id,
            t1_0.createdAt,
            t1_0.insert_member,
            t1_0.lastModifiedAt,
            t1_0.update_member,
            t1_0.name 
        from
            Member m1_0 
        join
            Team t1_0 
                on t1_0.team_id=m1_0.team_team_id

 

fetch join을 사용하면 이렇게 join을 해서 한번에 값을 가져올 수 있다.

즉시 로딩 주의사항

  • 가급적 지연 로딩만 사용한다(특히 실무에서)
  • 즉시 로딩을 적용하면 예상하지 못한 SQL이 발생한다
  • 즉시 로딩은 JPQL에서 N+1 문제를 일으킨다
  • @ManyToOne, @OneToOne은 기본이 즉시 로딩 -> LAZY로 설정
  • @OneToOne, @ManyToMany는 기본이 지연 로딩