무지를 아는 것이 곧 앎의 시작

JPA

[JPA] 즉시 로딩과 지연 로딩, 지연 로딩을 써야하는 이유

Alex96 2022. 7. 26. 18:33

JPA에서는 연관 관계로 설정된 엔티티를 조회할 때 즉시 로딩과 지연 로딩 두 가지 방식이 있다.

당장 지연 로딩으로 검색을 해보면 두 방식중 지연 로딩이 즉시 로딩보다 권장되는 분위기다.

 

이번 포스팅에선 즉시 로딩과 지연 로딩의 차이점과 왜 두 방식중 지연 로딩이 추천되는 지 알아보려 한다.🧐

 


학습테스트 준비

 

Team 엔티티

@Entity
public class Team {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String teamName;

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

    protected Team() {
    }

    public Team(final String teamName) {
        this.teamName = teamName;
    }

    public void addMember(final Member member) {
        member.participateTeam(this);
        members.add(member);
    }

    // getters...
}

 

Member 엔티티

@Entity
public class Member {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String name;

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

    protected Member() {
    }

    public Member(final String name, final Team team) {
        this.name = name;
        this.team = team;
    }

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

    // getters...
}

 

양방향으로 연관 관계를 매핑하고 연관관계 편의 메서드를 정의한 상태

 

테스트 코드 세팅

@DataJpaTest
class LazyLoadingLearningTest {

    @Autowired
    EntityManager em;
    Team team1;
    Team team2;
    Member member1;
    Member member2;
    Member member3;

    @BeforeEach
    void setUp() {
        team1 = new Team("팀1");
        team2 = new Team("팀2");
        member1 = new Member("멤버1", team1);
        member2 = new Member("멤버2", team1);
        member3 = new Member("멤버3", team2);
        em.persist(team1);
        em.persist(team2);
        em.persist(member1);
        em.persist(member2);
        em.persist(member3);

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

    @Test
    void select() {
        // 조회 코드 작성할 곳 ㅎㅎ
    }
}

 

편하게 세팅하기 위해 Spring Data JPA 의존성을 추가하고 @DataJpaTest 어노테이션을 써서 EntityManager를 주입받아 테스트 코드를 작성했다.

여러 조회 방식에 따라 어떻게 쿼리가 생성되는지 확인할 것이기 때문에 사전에 필요한 데이터는 @BeforeEach로 세팅했고 테스트 메서드에서 @BeforeEach와 같은 트랜잭션으로 적용되기 때문에 조회시 DB가 아니라 영속성 컨텍스트에서 가져올 것이기 때문에 데이터 세팅 후에 영속성 컨텍스트를 초기화.

 

이 세팅에서 조회코드 작성시 어떤 쿼리문이 생성되는지 콘솔에 남겨서 확인할 예정이다.

 

쿼리문을 확인하기 위한 세팅

application.yml

spring:
  jpa:
    properties:
      hibernate:
        format_sql: true
    show-sql: true

위의 세팅을 추가한다.


즉시 로딩(EAGER)

 

엔티티를 조회시 해당 엔티티와 연관관계에 있는 엔티티도 같이 조회해서 값이 다 채워진 완전한 엔티티로 조회하는 방식이다.

 

즉시로딩으로 연관관계를 세팅.

@Entity
public class Team {
	
    /// ...

    @OneToMany(mappedBy = "team", fetch = FetchType.EAGER)
    private List<Member> members = new ArrayList<>();

    /// ...
}

Member 의 @ManyToOne 연관관계는 fetch 타입이 디폴트가 EAGER이기 때문에 Team의 @OneToMany에만 EAGER로 명시해주면 모든 연관관계가 즉시로딩으로 설정된다. 이제 조회하는 코드를 작성하고 조회된 쿼리문을 살펴보겠다.

 

멤버 리스트를 조회

@Test
void select() {
    System.out.println("쿼리 실행!");
    final Member findMember = em.find(Member.class, member1.getId());
    System.out.println("쿼리 종료!");
    System.out.println(findMember.getTeam().getTeamName());
}

코드 실행시 콘솔엔 이런 쿼리가 출력

즉시 로딩 단건조회 결과

두 개의 쿼리가 실행됐는데 먼저 실행된 쿼리는 Member를 조회할 때 Member에 ManyToOne으로 걸린 팀의 데이터를 같이 가져오느라 조인 쿼리가 실행된 것을 볼 수 있다.

두번 째 실행된 쿼리는 Member와 Team이 양방향으로 매핑되어 있기 때문에 Team의 members를 채우기 위해 실행된 쿼리다.

 

즉시 로딩은 특정 엔티티를 조회할 때 연관관계가 설정된 다른 엔티티까지 한번에 조회하는 것을 볼 수 있다.(위에서 다대일의 경우 조인 쿼리가 실행되었고, 일대다인 경우 조건절에 외래키 조건으로 여러 데이터를 한번에 들고왔다.)

 


지연 로딩(LAZY)

 

지연 로딩은 엔티티를 조회할 때 연관관계에 있는 엔티티를 같이 조회하지 않고, 그 엔티티의 프록시를 등록해서 조회하려는 엔티티를 반환하는 방식이다. 프록시는 해당 프록시에게 호출이 올 때 프록시 내부에 실제 엔티티를 조회한다. 즉시 로딩에서 썻던 테스트 코드와 같은 테스트 코드를 지연로딩만 설정해서 실행시켜보겠다.

 

지연로딩 설정

@Entity
public class Member {

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

    /// ...
}
@Entity
public class Team {
	
    /// ...

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

    /// ...
}

@ManyToOne은 디폴트 값이 EAGER이기 때문에 LAZY로 명시해주고 @OneToMany는 디폴트 값이 LAZY이기 때문에 아무 명시하지 않았다.

 

실행 결과

지연 로딩 단건조회 결과

 

Member 조회코드가 있는 부분에서 나가는 쿼리문은 딱 Member만 조회한다.

이후에 member.getTeam().getTeamName()을 콘솔에 찍기 직전에 팀을 조회하는 쿼리문이 나가는데,

이처럼 실제 엔티티가 아니라 프록시가 대신 들어오고 프록시에서 연관 관계에 매핑된 엔티티를 사용해야할 때 실제 엔티티를 조회하도록 하는 방식이 지연 로딩이다.

 

그럼 대체 왜 지연 로딩이 권장되는 것일까?🤔


지연 로딩을 써야하는 이유

 

1. 필요 없는 데이터 조회

 

만약 비즈니스 로직상 단 하나의 Member에 대한 데이터만 필요한 비즈니스 로직이 있다면 Member를 조회할 때 조인으로 엮인 Team을 같이 조회하는 것 자체가 손해이다. 사용하지도 않는 자원을 조회하는 데에 비용이 낭비된다.

 

2. 예상치 못한 쿼리문 발생

 

위에 즉시 로딩 단건 조회 결과화면을 보면 Member와 Team을 조인한 쿼리 외에 다른 쿼리가 한개 더 실행되었다.

바로 Team에서 members를 가지는 양방향 매핑 형태여서 팀에서 members를 위한 쿼리문이 발생한 것이다.

 

자.. 만약에 Team에서 다른 연관 관계를 또 가지고 있었다면..? 그리고 Member에서도 Team말고 다른 연관 관계를 또 가지고 그 연관된 엔티티가 또 다른 연관 관계를 가진다면..?😱

연관 관계의 복잡도에 따라 단순 조회문에 어떤 쿼리가 실행될지 예측하기가 힘들다. 전부 즉시 로딩이라면 엮여있는 모든 엔티티를 조회하는 쿼리가 각각 나갈 것이고, 어떤 곳은 즉시 로딩 어떤 곳은 지연 로딩 이러면 누구는 가져오고 누구는 프록시고... 더 파악하기 힘들어진다.

 

3. N + 1 문제

 

너무 심각하고 유명한 문제다. 내가 조회하려는 조회문에 조회하려는 테이블의 데이터 로우 수 만큼 쿼리가 추가로 나가는 문제이다.

이 문제는 학습테스트로 알아보자

 

우선 즉시 로딩으로 세팅(위에 즉시 로딩 세팅한 코드 참고)한 뒤에 테스트 코드를 다음처럼 작성했다.

@Test
void select() {
    System.out.println("쿼리 실행!");
    final List<Team> findTeam = em.createQuery("select t from Team t", Team.class)
            .getResultList();
    System.out.println("쿼리 종료!");
}

실행시키고 실행된 쿼리문을 보겠다.

N + 1 발생!

우선 맨 처음 모든 Team을 조회하는 쿼리문이 나간다. 그래서 Team에 대한 데이터는 모두 들고올 수 있다.

그런데 Team에서 Member를 members로 참조하고 있고 즉시 로딩으로 설정되어 있기 때문에 members를 채워줘야한다.

members를 채우기 위해 각 Team마다 연관된 Member목록을 조회하는 쿼리문이 실행된다.

즉 N(Team의 총 갯수) + 1(Team목록 조회 쿼리문)개의 쿼리문이 실행된다.

 

지연 로딩을 써도 N + 1 문제가 발생하는 경우가 있다.

가령, 위 예제에서 Member 목록을 조회하고 각 Member마다 순환하며 Team을 사용하는 로직을 수행하면 N + 1이 발생한다.

이러한 경우는 Member 조회시에 Team을 동시에 조회해야 할 것이다.

동시에 조회하는 방식으로 EAGER를 택한다기 보단 해당 부분에서 JPQL fetch join을 사용하는 것이 좋다.

 


정리

즉시 로딩 쓰지 말자. 연관 관계 매핑할 때 전부 지연 로딩으로 발라야 한다.

연관 관계된 엔티티를 꼭 동시에 조회해야 하는 상황이 필요하다면 JPQL fetch join으로 풀어내야 한다.