728x90
반응형
SMALL
반응형
SMALL

2024.10.30 업데이트


 

지연로딩과 즉시로딩에 대해 공부한 내용을 적고자 한다. 내가 JPA를 처음 이론적인 공부를 하지 않고 그냥 무작정 사용했을 때 이런 내용이 있는지도 사실 모르고 데이터를 받아올 때 무수히 많은 SQL문을 남발하곤 했는데, 그 남발하게된 SQL문의 원인 중 하나가 여기에 있다. 

 

우선 지연로딩즉시로딩은 JPA가 데이터를 데이터베이스로부터 조회할 때 조회할 레코드에 참조 객체(테이블 관점에서는 외래키)가 있는 경우 해당 데이터까지 한꺼번에 다 가져올지 말지를 정하는 기준을 말한다. 

 

다음 상황을 가정해보자.

팀 엔티티와 멤버 엔티티가 있고 팀과 멤버는 일대다 관계이다. 이 때 멤버를 조회할 때 팀도 한번에 조회해야 할까?

코드로 이를 직접 비교해보자.

 

지연로딩 (Lazy Fetch)

Member

package org.example.entity.fetch;

import org.example.entity.Team;

import javax.persistence.*;

@Entity
public class Member {

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

    private String username;

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

    public Long getId() {
        return id;
    }

    public void setId(Long id) {
        this.id = id;
    }

    public String getUsername() {
        return username;
    }

    public void setUsername(String username) {
        this.username = username;
    }

    public Team getTeam() {
        return team;
    }

    public void setTeam(Team team) {
        this.team = team;
    }
}
  • 멤버는 팀을 참조하고, LAZY(지연 로딩)를 Fetch 전략으로 가지고 있다.

Team

package org.example.entity;

import org.example.entity.fetch.Member;

import javax.persistence.*;
import java.util.ArrayList;
import java.util.List;

@Entity
@SequenceGenerator(
        name = "TEAM_SEQ_GENERATOR",
        sequenceName = "TEAM_SEQ",
        initialValue = 1,
        allocationSize = 100
)
public class Team {

    @Id @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "TEAM_SEQ_GENERATOR")
    @Column(name = "TEAM_ID")
    private Long id;

    private String name;

    @OneToMany(mappedBy = "team")
    private List<Member> members = new ArrayList<>();

    public Long getId() {
        return id;
    }

    public void setId(Long id) {
        this.id = id;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public List<Member> getMembers() {
        return members;
    }

    public void setMembers(List<Member> members) {
        this.members = members;
    }
}
  • 팀은 별다른 내용은 없고 멤버와 양방향으로 참조한다. 이런 두 엔티티가 있을 때 멤버를 조회해보자. 

Main

package org.example;

import org.example.entity.Team;
import org.example.entity.fetch.Member;

import javax.persistence.EntityManager;
import javax.persistence.EntityManagerFactory;
import javax.persistence.EntityTransaction;
import javax.persistence.Persistence;

public class Main {

    public static void main(String[] args) {
        EntityManagerFactory emf = Persistence.createEntityManagerFactory("hello");
        EntityManager em = emf.createEntityManager();
        EntityTransaction tx = em.getTransaction();

        // ! Transaction 시작
        tx.begin();

        try {
            Team team = new Team();
            team.setName("TeamA");
            em.persist(team);

            Member member = new Member();
            member.setTeam(team);
            member.setUsername("memberA");
            em.persist(member);

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

            Member findMember = em.find(Member.class, member.getId());
            System.out.println("findMember: " + findMember.getUsername());
            System.out.println("findMember Team Class: " + findMember.getTeam().getClass());

            tx.commit();
        } catch (Exception e) {
            e.printStackTrace();
            tx.rollback();
        } finally {
            em.close();
            emf.close();
        }
    }
}
  • 지연 로딩으로 설정하면 참조 객체에 대해 바로 엔티티로 찾아오는게 아니라 전 시간에 공부했던 프록시로 가져온다. 즉, 위 코드로 실행하면 멤버의 팀은 프록시로 가져와야 한다. 실행 결과는 다음과 같다.

찾은 멤버의 팀은 현재 Proxy로 가져온 상태임을 확인할 수 있다. 이게 지연로딩이고 실제 SQL문을 살펴봐도 팀과 조인한 SQL문이 실행되지 않았다. 이렇게 되면 만약 멤버를 조회했을 때 팀을 알 필요가 전혀 없는 상태라면 이런 SELECT문이 훨씬 이점을 가져갈 것이다.

 

 

이번에는 즉시로딩으로 실행해보자. 참고로 @ManyToOne, @OneToOne은 기본이 즉시로딩(EAGER)이다.

즉시로딩 (Eager Fetch)

위에서 작성한 코드에서 수정할 내용은 LAZYEAGER로 변경만 하면 된다.

package org.example.entity.fetch;

import org.example.entity.Team;

import javax.persistence.*;

@Entity
public class Member {

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

    private String username;

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

    public Long getId() {
        return id;
    }

    public void setId(Long id) {
        this.id = id;
    }

    public String getUsername() {
        return username;
    }

    public void setUsername(String username) {
        this.username = username;
    }

    public Team getTeam() {
        return team;
    }

    public void setTeam(Team team) {
        this.team = team;
    }
}

EAGER로 변경 후 실행해보자. SQL문을 확인해보면 다음과 같이 출력된다.

멤버를 조회할 때 바로 조인을 해서 TEAM 정보도 가져오는 SELECT문을 확인할 수 있다. 그리고 가져온 TEAM의 클래스를 찍어보면 이번엔 Proxy가 아니라 실제 엔티티를 가져왔다. 이게 즉시로딩이다.

 

 

결론

결론을 내리자면 즉시로딩은 가급적 사용하지 않기로 하자. 아무리 멤버를 조회할 때 팀도 바로 가져오는 상황이 있다고 한들 지연로딩으로 가져와서 팀 프록시를 초기화하는 방향으로 사용하는 걸 생각하자.

 

왜 그러냐면 다음과 같은 이유들이 있다. 

  • 즉시 로딩의 문제1 - 즉시 로딩을 적용하면 예상하지 못한 SQL이 발생한다.

이건 위에서 봤듯 분명 아래 코드밖에 사용하지 않았다. 

Member findMember = em.find(Member.class, member.getId());

물론, 여기서는 이 즉시로딩을 적용했고 이 코드를 사용하면 팀도 같이 가져온다는 걸 인지하고 있고 코드양이 적기 때문에 예상할 수 있는 SQL문이지만, 코드가 길어지고 메서드가 많아지면 많아질수록 실행되는 SQL문에서 왜 팀을 조인하는지 알아보기 힘들어질 가능성이 너무 크다. 거기다가, 지금이야 Team 하나만 즉시로딩으로 설정했으니 망정이지, 즉시로딩이 만약 3개, 4개 넘어가면 조인도 그만큼 많이 하게 된다.

 

  • 즉시 로딩의 문제2 - 즉시 로딩은 JPQL 사용을 할 때 N+1 문제가 생긴다.

이게 진짜 큰 문제인데 JPQL은 종종 사용된다. 복잡한 쿼리를 작성할 일이 많기 때문에 그래서 예시를 위해 다음 JPQL 코드를 살펴보자.

package org.example;

import org.example.entity.Team;
import org.example.entity.fetch.Member;

import javax.persistence.EntityManager;
import javax.persistence.EntityManagerFactory;
import javax.persistence.EntityTransaction;
import javax.persistence.Persistence;
import java.util.List;

public class Main {

    public static void main(String[] args) {
        EntityManagerFactory emf = Persistence.createEntityManagerFactory("hello");
        EntityManager em = emf.createEntityManager();
        EntityTransaction tx = em.getTransaction();

        // ! Transaction 시작
        tx.begin();

        try {
            Team team = new Team();
            team.setName("TeamA");
            em.persist(team);

            Member member = new Member();
            member.setTeam(team);
            member.setUsername("memberA");
            em.persist(member);

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

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

            tx.commit();
        } catch (Exception e) {
            e.printStackTrace();
            tx.rollback();
        } finally {
            em.close();
            emf.close();
        }
    }
}

위 코드부분에서 여기 부분이 JPQL을 사용하는 부분이다.

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

 

이 코드를 수행해보면 다음과 같은 SQL문이 출력된다.

Hibernate: 
    select
        member0_.MEMBER_ID as member_i1_0_,
        member0_.TEAM_ID as team_id3_0_,
        member0_.username as username2_0_ 
    from
        Member member0_
Hibernate: 
    select
        team0_.TEAM_ID as team_id1_1_0_,
        team0_.name as name2_1_0_ 
    from
        Team team0_ 
    where
        team0_.TEAM_ID=?

SELECT를 두번한다. 왜 두번하냐 ? 우선 위에 작성한 JPQL은 멤버를 조회한다. 멤버를 조회했을 때 멤버가 1개면 1개의 레코드, 2개면 2개의 레코드가 출력되는데 이 때 즉시로딩은 멤버가 가지고 있는 팀도 같이 가져와야하기 때문에 각 멤버별 팀까지 가져와야 하는 SELECT문이 나간다. 그럼 이게 멤버가 10명이 있을때, 각 멤버가 서로 다 다른 팀에 속한 경우, 팀을 가져오는 SELECT문 10번이 같이 나간다는 소리다. (물론, 멤버 10명이 다 같은 팀에 속해 있다면, 당연히 팀을 가져오는 쿼리는 한번만 나간다. 두번째부터는 이미 가져온 팀이니까)

 

그래서 N+1은 멤버를 조회하는 쿼리 1개 + 멤버가 가지고 있는 팀을 조회하는 쿼리 N개가 나간다. 그럼 최악의 경우, 멤버가 10명이고 각 멤버가 속한 팀이 서로 다 다르다면 한번에 10 + 1 쿼리가 나가게된다.

 

심지어! 이건 멤버가 팀만 가지고 있는 경우를 말한거지 만약 다른 참조객체가 2개, 3개, 여러개가 있으면 20 + 1, 30 + 1, N + 1이 된다는 소리다. 끔찍한 경우다. 그래서 즉시로딩은 사용하면 안된다.

 

그럼 지연로딩으로 설정만 하면 이런 N + 1 문제가 아예 없어지나요!?

→ 안타깝게도 아니다. 그러나, 최소한 지연로딩으로 설정하면 손 쓸 수도 없게 문제를 바라만봐야 하는 상황은 막을 수 있다.

 

왜 근데 아닐까? 지연로딩으로 설정하고 아래와 같이 팀 2개, 유저 2개를 만들어서 DB에 넣고 영속성 컨텍스트를 깔끔하게 정리한 후 다시 조회해보자.

package cwchoiit;

import cwchoiit.fetch.Team;
import cwchoiit.fetch.Users;

import javax.persistence.EntityManager;
import javax.persistence.EntityManagerFactory;
import javax.persistence.Persistence;
import java.util.List;

public class JpaMain {
    public static void main(String[] args) {
        EntityManagerFactory entityManagerFactory = Persistence.createEntityManagerFactory("hellojpa");
        EntityManager entityManager = entityManagerFactory.createEntityManager();

        entityManager.getTransaction().begin();
        try {

            Team team = new Team();
            team.setName("TEAM1");

            Team team2 = new Team();
            team2.setName("TEAM2");

            Users users = new Users();
            users.setUsername("choi");
            users.setTeam(team);

            Users users2 = new Users();
            users2.setUsername("kim");
            users2.setTeam(team2);

            entityManager.persist(team);
            entityManager.persist(team2);

            entityManager.persist(users);
            entityManager.persist(users2);

            entityManager.flush();
            entityManager.clear();


            List<Users> findUsers = entityManager.createQuery("SELECT u FROM Users u", Users.class).getResultList();

            entityManager.getTransaction().commit();
        } catch (Exception e) {
            entityManager.getTransaction().rollback();
        } finally {
            entityManager.close();
        }
        entityManagerFactory.close();
    }
}

 

 

그리고 아래 코드처럼 JPQL로 모든 유저들을 가져오는 쿼리를 날리고 그 결과를 보자.

List<Users> findUsers = entityManager.createQuery("SELECT u FROM Users u", Users.class).getResultList();

 

실행 결과

Hibernate: 
    /* SELECT
        u 
    FROM
        Users u */ select
            users0_.USER_ID as user_id1_12_,
            users0_.TEAM_ID as team_id3_12_,
            users0_.username as username2_12_ 
        from
            Users users0_
            
...

보다시피, 당연히 지연로딩이기 때문에 팀에 대한 정보는 아직 조회를 하지 않는다. 그러나, 만약 이 리스트를 순회해서 팀을 가져온다면?

그땐, 당연히 N + 1 문제가 결국엔 나타나게 된다. 

...

List<Users> findUsers = entityManager.createQuery("SELECT u FROM Users u", Users.class).getResultList();

for (Users findUser : findUsers) {
    System.out.println(findUser.getTeam().getName());
}

...

 

실행 결과

...

Hibernate: 
    /* SELECT
        u 
    FROM
        Users u */ select
            users0_.USER_ID as user_id1_12_,
            users0_.TEAM_ID as team_id3_12_,
            users0_.username as username2_12_ 
        from
            Users users0_
Hibernate: 
    select
        team0_.TEAM_ID as team_id1_11_0_,
        team0_.name as name2_11_0_ 
    from
        Team team0_ 
    where
        team0_.TEAM_ID=?
TEAM1
Hibernate: 
    select
        team0_.TEAM_ID as team_id1_11_0_,
        team0_.name as name2_11_0_ 
    from
        Team team0_ 
    where
        team0_.TEAM_ID=?
TEAM2

...

실행 결과를 보면 역시 마찬가지로 각 멤버가 속한 팀에 대한 쿼리를 하나씩 날리고 있다. 즉, 지연로딩으로 해도 N + 1 문제는 여전히 발생할 수 있다는 소리다. 그러나 차이점은 명확하다. 즉시로딩은 JPQL 실행 시, 개발자가 손 쓸 수도 없게 바로 N + 1 문제가 발생하지만, 지연로딩은 JPQL 실행 시 바로 N + 1 문제는 발생하지 않는다. 그래서 만약, 순회를 하더라도 팀을 조회하는 코드가 없다면 N + 1 문제는 지연로딩만으로도 해결할 수 있다. (물론, 반쪽짜리 해결이고 사실 해결한 것이라 볼 수 없지만.)

 

그럼 이 N + 1 문제를 완전히 해결하는 방법은 뭔데요?

→ 이후에 배우겠지만 "지연 로딩 + 페치 조인"이다.

728x90
반응형
LIST

'JPA(Java Persistence API)' 카테고리의 다른 글

[JPA] Part 13. 임베디드 값 타입  (0) 2023.10.23
[JPA] Part 12. CASCADE, 고아 객체  (2) 2023.10.23
[JPA] Part 10. 프록시  (0) 2023.10.22
[JPA] Part 9. @MappedSuperclass  (0) 2023.10.22
[JPA] Part 8. 상속관계 매핑  (2) 2023.10.22

+ Recent posts