JPA #6 fetch 전략

함형주·2022년 10월 30일
0

JPA

목록 보기
6/7

질문, 피드백 등 모든 댓글 환영합니다.

수많은 연관관계 사이에서 select 쿼리를 생성하는 일은 결코 단순하지 않습니다.

JPA에서도 연관된 엔티티를 조회하는 것을 매우 조심해야 합니다.

JPA는 엔티티 조회 시에도 매우 편리한 기능을 제공하지만 편리한 만큼 잘못 사용한다면 큰 문제를 낳을 수 있습니다.

필요없는 데이터를 함께 조회하거나 N + 1 문제, 중복 데이터를 무더기로 조회 하는 등 심하면 서비스 장애로 이어질 수 있는 상황까지 만들어 질 수 있습니다.

JPA를 사용하면 fetch 전략을 설정하는 방법으로 편리하게 이러한 문제 대부분을 해결할 수 있습니다.

먼저 즉시 로딩과 지연 로딩이 무엇인지 알아보고 구현할 때 생기는 문제점과 이를 해결하는 방법을 알아보겠습니다.


상황 : Member는 1:1 관계의 Profile을 가지고 1:N 관계의 Article을 가집니다.

@Entity
public class Member {
    @Id @GeneratedValue @Column(name = "member_id")
    private Long id;
    
    @Column(name = "member_name")
    private String name;

    @OneToOne
    private Profile profile;

    @OneToMany(mappedBy = "member")
    private List<Article> articles = new ArrayList<>();
}
@Entity
public class Profile {
    @Id @GeneratedValue @Column(name = "profile_id")
    private Long id;
    
    @Column(name = "profile_name")
    private String name;

    @OneToOne(mappedBy = "profile")
    private Member member;
}
@Entity
public class Article {
    @Id @GeneratedValue @Column(name = "article_id")
    private Long id;
    
    @Column(name = "article_name")
    private String name;

    @ManyToOne
    private Member member;
}

Getter, Setter, 생성자는 생략했습니다.

FetchType.EAGER vs FetchType.Lazy

결론부터 말씀드리면 FetchType.EAGER는 사용하지 않습니다.

만약 Member를 조회할 때 연관된 Profile과 Article을 같이 조회해야 할까요?

Member 객체를 사용할 때 Profile 객체도 함께 사용하는 경우에는 연관관계 설정(@XxxToXxx) 시 FetchType.EAGER를 사용하면 엔티티를 조회할 때 연관된 엔티티를 함께 조회하고 이를 즉시 로딩이라고 합니다.

FetchType.EAGER로 설정 시

    @Test
    public void jpa() {
        Member member = new Member();
        Profile profile = new Profile();
        member.setProfile(profile);

        em.persist(member);
        em.persist(profile);
        
        em.flush();
        em.clear();

        System.out.println("member 조회 ==================");
        em.find(Member.class, member.getId());
        System.out.println("member 조회 ==================");
	}

결과

member 조회 ==================
Hibernate: 
    select
        member0_.member_id as member_i1_1_0_,
        member0_.profile_id as profile_2_1_0_,
        profile1_.profile_id as profile_1_2_1_ 
    from
        member member0_ 
    left outer join
        profile profile1_ 
            on member0_.profile_id=profile1_.profile_id 
    where
        member0_.member_id=?
member 조회 ==================

em.find(Profile.class....) 같은 코드 없이도 Member 조회 시 Profile이 같이 조회되는 것을 확인할 수 있습니다.

때문에 두 엔티티를 함께 사용하는 경우에 FetchType.EAGER를 사용합니다.


하지만 단순히 Member의 이름만 필요한 상황에서 Member와 연관된 Profile과 Article을 함께 조회하게 되면 SQL 쿼리 분량이 많아져 성능 이슈가 생길 수 있고 애플리케이션 내부에서도 미리 작성한 글, 댓글들에 대한 객체를 만들어야 하기에 매우 비효율적일 것입니다.

때문에 이런 경우에는 연관관계 설정(@XxxToXxx) 시 FetchType.LAZY를 사용하면 엔티티를 조회할 때 연관된 엔티티를 함께 조회하지 않고 이를 지연 로딩이라고 합니다.

이를 지연 로딩이라고 하는 이유는 영속성 컨텍스트가 제공하는 프록시 기능에 있습니다.

영속성 컨텍스트는 프록시 기능을 제공하여 지연 로딩으로 조회한 엔티티가 영속화 되어있는 상태라면 후에 연관된 엔티티를 사용할 때 해당 엔티티를 자동으로 조회합니다.

FetchType.LAZY로 설정 시

    @Test
    public void jpa() {
        Member member = new Member("membername");
        Profile profile = new Profile("profilename");
        member.setProfile(profile);

        em.persist(member);
        em.persist(profile);

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

        System.out.println("member 조회 ==================");
        Member findMember = em.find(Member.class, member.getId());
        System.out.println("member 조회 ==================");

        System.out.println("profile 사용 ==================");
        System.out.println("findMember.getProfile().getName() = " + findMember.getProfile().getName());
        System.out.println("profile 사용 ==================");
    }

결과

member 조회 ==================
Hibernate: 
    select
        member0_.member_id as member_i1_1_0_,
        member0_.member_name as member_n2_1_0_,
        member0_.profile_id as profile_3_1_0_ 
    from
        member member0_ 
    where
        member0_.member_id=?
member 조회 ==================
profile 사용 ==================
Hibernate: 
    select
        profile0_.profile_id as profile_1_2_0_,
        profile0_.profile_name as profile_2_2_0_ 
    from
        profile profile0_ 
    where
        profile0_.profile_id=?
member.getProfile().getName() = profilename
profile 사용 ==================

em.find(Member.class, member.getId()) -> 이 시점에서 Member만 조회
findMember.getProfile().getName() -> em.find(Profile.class, profile.getId()) 없이도 프록시 기능을 활용하여 Profile 자동 조회

fetch 전략

앞서 말했듯이 즉시 로딩(FetchType.EAGER)는 절대 사용하지 않습니다.
언뜻 보면 매우 편리해 보이지만 N + 1 문제를 낳을 수 있습니다.

때문에 모든 연관관계에서 fetch 속성은 지연 로딩(FetchType.LAZY)로 설정해야 합니다. 만약 여러 엔티티를 같이 사용해야 한다면 JPQL이 제공하는 fetch join 을 사용하여 함께 조회합니다.

먼저 N + 1 문제가 무엇인지 알아보겠습니다.

즉시 로딩으로 연관된 엔티티 조회

    @Test
    public void jpa() throws Exception {
        Member member1 = new Member("membername1");
        Member member2 = new Member("membername2");
        Member member3 = new Member("membername3");
        Member member4 = new Member("membername4");
        Profile profile1 = new Profile("profilename1");
        Profile profile2 = new Profile("profilename2");
        Profile profile3 = new Profile("profilename3");
        Profile profile4 = new Profile("profilename4");
        member1.setProfile(profile1);
        member2.setProfile(profile2);
        member3.setProfile(profile3);
        member4.setProfile(profile4);

        em.persist(member1);
        em.persist(member2);
        em.persist(member3);
        em.persist(member4);
        em.persist(profile1);
        em.persist(profile2);
        em.persist(profile3);
        em.persist(profile4);

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

        System.out.println("member 조회 ==================");
        List<Member> result = em.createQuery("select m from Member m", Member.class).getResultList();
        System.out.println("member 조회 ==================");
}

결과

member 조회 ==================
Hibernate: 
    select
        member0_.member_id as member_i1_1_,
        member0_.member_name as member_n2_1_,
        member0_.profile_id as profile_3_1_ 
    from
        member member0_
Hibernate: 
    select
        profile0_.profile_id as profile_1_2_0_,
        profile0_.profile_name as profile_2_2_0_,
        member1_.member_id as member_i1_1_1_,
        member1_.member_name as member_n2_1_1_,
        member1_.profile_id as profile_3_1_1_ 
    from
        profile profile0_ 
    left outer join
        member member1_ 
            on profile0_.profile_id=member1_.profile_id 
    where
        profile0_.profile_id=?
Hibernate: 
    select
        member0_.member_id as member_i1_1_1_,
        member0_.member_name as member_n2_1_1_,
        member0_.profile_id as profile_3_1_1_,
        profile1_.profile_id as profile_1_2_0_,
        profile1_.profile_name as profile_2_2_0_ 
    from
        member member0_ 
    left outer join
        profile profile1_ 
            on member0_.profile_id=profile1_.profile_id 
    where
        member0_.profile_id=?
Hibernate: 
    select
        profile0_.profile_id as profile_1_2_0_,
        profile0_.profile_name as profile_2_2_0_,
        member1_.member_id as member_i1_1_1_,
        member1_.member_name as member_n2_1_1_,
        member1_.profile_id as profile_3_1_1_ 
    from
        profile profile0_ 
    left outer join
        member member1_ 
            on profile0_.profile_id=member1_.profile_id 
    where
        profile0_.profile_id=?
Hibernate: 
    select
        member0_.member_id as member_i1_1_1_,
        member0_.member_name as member_n2_1_1_,
        member0_.profile_id as profile_3_1_1_,
        profile1_.profile_id as profile_1_2_0_,
        profile1_.profile_name as profile_2_2_0_ 
    from
        member member0_ 
    left outer join
        profile profile1_ 
            on member0_.profile_id=profile1_.profile_id 
    where
        member0_.profile_id=?
Hibernate: 
    select
        profile0_.profile_id as profile_1_2_0_,
        profile0_.profile_name as profile_2_2_0_,
        member1_.member_id as member_i1_1_1_,
        member1_.member_name as member_n2_1_1_,
        member1_.profile_id as profile_3_1_1_ 
    from
        profile profile0_ 
    left outer join
        member member1_ 
            on profile0_.profile_id=member1_.profile_id 
    where
        profile0_.profile_id=?
Hibernate: 
    select
        member0_.member_id as member_i1_1_1_,
        member0_.member_name as member_n2_1_1_,
        member0_.profile_id as profile_3_1_1_,
        profile1_.profile_id as profile_1_2_0_,
        profile1_.profile_name as profile_2_2_0_ 
    from
        member member0_ 
    left outer join
        profile profile1_ 
            on member0_.profile_id=profile1_.profile_id 
    where
        member0_.profile_id=?
Hibernate: 
    select
        profile0_.profile_id as profile_1_2_0_,
        profile0_.profile_name as profile_2_2_0_,
        member1_.member_id as member_i1_1_1_,
        member1_.member_name as member_n2_1_1_,
        member1_.profile_id as profile_3_1_1_ 
    from
        profile profile0_ 
    left outer join
        member member1_ 
            on profile0_.profile_id=member1_.profile_id 
    where
        profile0_.profile_id=?
Hibernate: 
    select
        member0_.member_id as member_i1_1_1_,
        member0_.member_name as member_n2_1_1_,
        member0_.profile_id as profile_3_1_1_,
        profile1_.profile_id as profile_1_2_0_,
        profile1_.profile_name as profile_2_2_0_ 
    from
        member member0_ 
    left outer join
        profile profile1_ 
            on member0_.profile_id=profile1_.profile_id 
    where
        member0_.profile_id=?
member 조회 ==================

이렇게 분명히 즉시 로딩으로 설정했음에도 select 쿼리가 여러 번 발생하는 것을 볼 수 있습니다.

N + 1 문제가 발생하는 이유 :
select * 을 하기 위해 JPQL을 실행하면 글로벌 페치 전략(즉시 로딩)을 무시하고 SQL 쿼리를 생성합니다.
결과 조회 후 Member의 Profile 필드를 매핑하는 시점에 값이 없는 것을 확인하고 영속성 컨텍스트에 값이 있는지 확인합니다.
첫 select 쿼리에 Profile을 조회 하지 않았으므로 이를 조회하기 위한 select 쿼리를 생성합니다.
모든 Member의 Profile 매핑이 완료될 때 까지 이를 반복합니다.

N + 1 문제가 발생하는 즉시 로딩은 연관된 엔티티를 한 번에 조회하기 위해 사용하는 것이 적절하지 않습니다.

때문에 모든 연관관계의 fetch 전략은 FetchType.Lazy로 설정하고 연관된 엔티티를 함께 사용하는 곳에선 JPQL이 제공하는 fetch join을 사용합니다.

fetch join

fetch join은 연관된 엔티티를 한 번에 조회하기 위해 JPQL이 제공하는 문법입니다.

위의 예제에서 연관된 엔티티를 조회할 때 select m.*, p.* from member m inner join profile p on m.profile_id=p.profile_id가 생성되길 원했지만 N + 1 문제를 발생시켰고 이를 fetch join으로 해결할 수 있습니다.

fetch join의 사용법은 아래와 같습니다.

em.createQuery("select m from Member m join fetch m.profile", Member.class)

이 코드를 실행하면 위의 select 쿼리와 같은 쿼리가 생성됩니다.

지연 로딩 + fetch join으로 연관된 엔티티 조회

    @Test
    public void jpa() throws Exception {
        Member member1 = new Member("membername1");
        Member member2 = new Member("membername2");
        Member member3 = new Member("membername3");
        Member member4 = new Member("membername4");
        Profile profile1 = new Profile("profilename1");
        Profile profile2 = new Profile("profilename2");
        Profile profile3 = new Profile("profilename3");
        Profile profile4 = new Profile("profilename4");
        member1.setProfile(profile1);
        member2.setProfile(profile2);
        member3.setProfile(profile3);
        member4.setProfile(profile4);

        em.persist(member1);
        em.persist(member2);
        em.persist(member3);
        em.persist(member4);
        em.persist(profile1);
        em.persist(profile2);
        em.persist(profile3);
        em.persist(profile4);

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

        System.out.println("member 조회 ==================");
        List<Member> result = em.createQuery("select m from Member m join fetch m.profile", Member.class).getResultList();
        System.out.println("member 조회 ==================");
}

결과

member 조회 ==================
Hibernate: 
    select
        member0_.member_id as member_i1_1_0_,
        profile1_.profile_id as profile_1_2_1_,
        member0_.member_name as member_n2_1_0_,
        member0_.profile_id as profile_3_1_0_,
        profile1_.profile_name as profile_2_2_1_ 
    from
        member member0_ 
    inner join
        profile profile1_ 
            on member0_.profile_id=profile1_.profile_id
member 조회 ==================

같은 상황이지만 지연 로딩 + fetch join을 사용하여 총 9번 발생하던 SQL이 하나로 줄어든 것을 확인할 수 있습니다.

fetch join을 사용하면 하나의 SQL로 연관된 데이터를 모두 조회할 수 있으므로 성능 최적화를 위해선 필수라고 할 수 있으며 객체 그래프를 활용해야 할 때 특히 유용합니다.

fetch join의 한계 (컬렉션 조회, 페이징)

지금까지의 예제를 살펴보면 Member와 @XxxToOne 관계인 Profile의 연관관계만 조회했습니다.

이번에는 @XxxToMany 관계인 Article을 조회하는 예제를 보겠습니다.

@XxxToOne (컬렉션 관계) fetch join

    @Test
    public void jpa2() throws Exception {
        Member member1 = new Member("membername1");
        Member member2 = new Member("membername2");
        Member member3 = new Member("membername3");

        setMemberArticles(member1, em);
        setMemberArticles(member2, em);
        setMemberArticles(member3, em);

        em.persist(member1);
        em.persist(member2);
        em.persist(member3);

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

        System.out.println("member 조회 ==================");
        List<Member> result = em.createQuery("select m from Member m join fetch m.articles", Member.class).getResultList();
        System.out.println("member 조회 ==================");

        for (Member member : result) {
            System.out.println("member.getName() = " + member.getName());
        }

    }

    private static void setMemberArticles(Member member, EntityManager em) {
        for (int i = 1; i < 6; i++) {
            Article article = new Article("articlename" + i);
            article.setMember(member);
            em.persist(article);
        }
    }

3개의 Member를 생성하고 각 Member 당 5개의 Article을 생성하였습니다.

결과

member 조회 ==================
Hibernate: 
    select
        member0_.member_id as member_i1_1_0_,
        articles1_.article_id as article_1_0_1_,
        member0_.member_name as member_n2_1_0_,
        member0_.profile_id as profile_3_1_0_,
        articles1_.member_member_id as member_m3_0_1_,
        articles1_.article_name as article_2_0_1_,
        articles1_.member_member_id as member_m3_0_0__,
        articles1_.article_id as article_1_0_0__ 
    from
        member member0_ 
    inner join
        article articles1_ 
            on member0_.member_id=articles1_.member_member_id
member 조회 ==================
member.getName() = membername1
member.getName() = membername1
member.getName() = membername1
member.getName() = membername1
member.getName() = membername1
member.getName() = membername2
member.getName() = membername2
member.getName() = membername2
member.getName() = membername2
member.getName() = membername2
member.getName() = membername3
member.getName() = membername3
member.getName() = membername3
member.getName() = membername3
member.getName() = membername3

분명히 3개의 Member를 생성하였는데 fetch join으로 Member와 Article을 조회하니 result에 같은 Member가 5개씩 생성된 것을 확인할 수 있습니다.

결과가 이렇게 생기는 이유는 컬렉션을 fetch join 할 경우 아래와 같이 데이터를 조회하기 때문입니다.

member_idmember_namearticle_idarticle_namemember_id
1membername14articlename11
1membername15articlename21
1membername16articlename31
1membername17articlename41
1membername18articlename51
2membername29articlename12
2membername210articlename22
.....
.....
.....

Member 한 개당 Article을 조회하는 것이 바람직하다고 할 수 있지만 Member가 가진 Article을 모두 조회하며 그 때마다 동일한 Member를 같이 조회하기 때문에 이런 문제가 발생합니다.

JPA는 이런 문제를 해결하기 위해 JPQL에서 조회 시 distinct를 사용할 수 있습니다.
em.createQuery("select distinct m from Member m join fetch m.articles", Member.class)

@XxxToOne (컬렉션 관계) fetch join + distinct 결과

member 조회 ==================
Hibernate: 
    select
        distinct member0_.member_id as member_i1_1_0_,
        articles1_.article_id as article_1_0_1_,
        member0_.member_name as member_n2_1_0_,
        member0_.profile_id as profile_3_1_0_,
        articles1_.member_member_id as member_m3_0_1_,
        articles1_.article_name as article_2_0_1_,
        articles1_.member_member_id as member_m3_0_0__,
        articles1_.article_id as article_1_0_0__ 
    from
        member member0_ 
    inner join
        article articles1_ 
            on member0_.member_id=articles1_.member_member_id
member 조회 ==================
member.getName() = membername1
member.getName() = membername2
member.getName() = membername3

JPQL이 지원하는 distinct는 단순히 애플리케이션에서 중복된 엔티티(식별자 기준)를 제거하는 기능만 지원하고 distinct를 사용하더라도 조회하는 DB 데이터를 최적화하진 못합니다.

즉 fetch join을 사용하여 @XxxToMany(컬렉션)을 조회하면 DB 성능 이슈가 필연적으로 발생합니다.

이것이 fetch join의 첫 번째 한계입니다.

이보다 더욱 중요한 것은 둘 이상의 @XxxToMany(컬렉션)은 조회가 불가능하며 이를 fetch join 할 시 페이징 APi를 사용할 수 없습니다.(하이버네이트는 페이징이 가능하지만 애플리케이션 레벨에서 페이징하므로 서버 리소스를 잡아먹고 심할 경우 OutOfMemory 에러가 발생하여 장애로 이어질 수 있습니다. 때문에 컬렉션 fetch join 시 절대 페이징 Api를 사용해선 안됩니다.)

즉 @XxxToMany(컬렉션) 관계의 엔티티는 되도록 fetch join으로 조회하지 않습니다.

결론 : @XxxToOne 관계일 경우 지연 로딩 + fetch join으로 조회, 그럼 @XxxToMany(컬렉션)는?

fetch join 한계 돌파, BatchSize

모든 연관관계를 지연 로딩으로 설정하고 fetch join으로 조회하는데 @XxxToMany(컬렉션)은 fetch join을 사용하기 어렵습니다.

@XxxToMany(컬렉션) 연관관계의 경우 BatchSize를 사용하여 이러한 문제를 해결할 수 있습니다.

BatchSize를 설정하면 지연 로딩 조회 시 컬렉션이나 프록시 객체를 설정한 size 만큼 쿼리에 in 절로 조회합니다.

BatchSize를 사용하면 fetch join 처럼 연관된 엔티티를 하나의 쿼리로 조회하지는 않지만 N + 1 형식으로 발생하는 쿼리를 1 + 1 형식으로 조회하여 쿼리 성능을 최적화 할 수 있습니다.

BatchSize는 @BatchSize 어노테이션을 대상이 컬렉션일 경우 컬렉션 필드에, 엔티티는 클래스에 사용하거나 application.properites(yml)에서 spring.jpa.properties.hibernate.default_batch_fetch_size 를 사용하여 설정할 수 있습니다.

이제 앞의 모든 예제를 통합하여 보여드리겠습니다.

모든 연관관계를 지연 로딩으로, @XxxToOne은 fetch join, @XxxToMany는 BatchSize로 조회

    @Test
    public void jpa3() throws Exception {
        Member member1 = new Member("membername1");
        Member member2 = new Member("membername2");
        Member member3 = new Member("membername3");

        setMemberArticles(member1, em);
        setMemberArticles(member2, em);
        setMemberArticles(member3, em);

        Profile profile1 = new Profile("profilename1");
        Profile profile2 = new Profile("profilename2");
        Profile profile3 = new Profile("profilename3");
        member1.setProfile(profile1);
        member2.setProfile(profile2);
        member3.setProfile(profile3);

        em.persist(member1);
        em.persist(member2);
        em.persist(member3);
        em.persist(profile1);
        em.persist(profile2);
        em.persist(profile3);

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

        System.out.println("member 조회 ==================");
        List<Member> result = em.createQuery("select m from Member m join fetch m.profile", Member.class).getResultList();
        System.out.println("member 조회 ==================");

        System.out.println("\nBatchSize로 조회 =================");
        for (Member member : result) {
            System.out.println("member.getName() = " + member.getName());
            System.out.println("member.getProfile().getName() = " + member.getProfile().getName());
            member.getArticles().stream().forEach(a -> System.out.println("article.getName() = " + a.getName()));
        }

    }

    private static void setMemberArticles(Member member, EntityManager em) {
        for (int i = 1; i < 6; i++) {
            Article article = new Article("articlename" + i);
            article.setMember(member);
            em.persist(article);
        }
    }

결과

member 조회 ==================
Hibernate: 
    select
        distinct member0_.member_id as member_i1_1_0_,
        profile1_.profile_id as profile_1_2_1_,
        member0_.member_name as member_n2_1_0_,
        member0_.profile_id as profile_3_1_0_,
        profile1_.profile_name as profile_2_2_1_ 
    from
        member member0_ 
    inner join
        profile profile1_ 
            on member0_.profile_id=profile1_.profile_id
member 조회 ==================

BatchSize로 조회 =================
member.getName() = membername1
member.getProfile().getName() = profilename1
Hibernate: 
    select
        articles0_.member_member_id as member_m3_0_1_,
        articles0_.article_id as article_1_0_1_,
        articles0_.article_id as article_1_0_0_,
        articles0_.member_member_id as member_m3_0_0_,
        articles0_.article_name as article_2_0_0_ 
    from
        article articles0_ 
    where
        articles0_.member_member_id in (
            ?, ?, ?
        )
article.getName() = articlename1
article.getName() = articlename2
article.getName() = articlename3
article.getName() = articlename4
article.getName() = articlename5
member.getName() = membername2
member.getProfile().getName() = profilename2
article.getName() = articlename1
article.getName() = articlename2
article.getName() = articlename3
article.getName() = articlename4
article.getName() = articlename5
member.getName() = membername3
member.getProfile().getName() = profilename3
article.getName() = articlename1
article.getName() = articlename2
article.getName() = articlename3
article.getName() = articlename4
article.getName() = articlename5

@XxxToMany(컬렉션)은 여전히 지연 로딩이기 때문에 Article에 접근하는 시점에 BatchSize로 인한 쿼리가 생성됩니다.

이 전략으로 엔티티 조회 시 즉시 로딩을 사용할 때 발생한 N + 1 문제가 발생하지 않으면서 컬렉션을 BatchSize로 조회하여 성능 최적화와 동시에 fetch join의 한계인 여러 컬렉션 조회, 페이징을 가능하게 했습니다.

최종 정리

모든 연관관계를 지연 로딩으로, @XxxToOne은 fetch join, @XxxToMany는 BatchSize로 조회, 이 때 BatchSize의 size는 DB와 애플리케이션 성능에 맞게 사용. 권장은 ~1000(특정 데이터베이스는 In 절 파라미터 수를 최대 1000으로 제한)

profile
평범한 대학생의 공부 일기?

0개의 댓글