JPA(Java Persistence API)

[JPA] Part 11. 지연로딩과 즉시로딩

cwchoiit 2023. 10. 23. 09:34
728x90
반응형
SMALL
728x90
반응형
SMALL

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

 

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

 

다음 상황을 가정해보자.

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

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

 

지연로딩(LAZY Fetch)

Member Class

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 Class

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;
    }
}

팀은 뭐 별다른 내용은 없고 이런 두 엔티티가 있을 때 멤버를 조회해보자. 

실행 코드

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)

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

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가 아니라 실제 엔티티를 가져왔다. 이게 즉시로딩이다.

 

 

결론

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

 

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

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

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

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

물론, 여기서는 이 즉시로딩을 적용했고 이 코드를 사용하면 팀도 같이 가져온다는 걸 인지하고 있고 코드양이 적기 때문에 예상할 수 있는 SQL문이지만, 코드가 길어지고 메서드가 많아지면 많아질수록 실행되는 SQL문에서 왜 팀을 조인하는지 알아보기 힘들어질 가능성이 너무 크다. 

  • 즉시 로딩은 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번이 같이 나간다는 소리다. 그래서 N+1은 멤버를 조회하는 쿼리 1개 + 멤버가 가지고 있는 팀을 조회하는 쿼리 N개가 나간다. 그럼 멤버가 10명이면 한번에 10 + 1 쿼리가 나가는거고 이건 멤버가 팀만 가지고 있는 경우를 말한거지 만약 다른 참조객체가 2개, 3개, 여러개가 있으면 20 + 1, 30 + 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