ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • 지연로딩 , 즉시로딩 JPA 최적화
    Spring 2024. 7. 1. 17:17

    서론

    프로젝트를 진행하며 RDB를 이용해서 많은 테이블들을 JOIN하는 상황이 생겼다. 이전에 JPA의 Annotation(One-to-Many, Many-to-One)을 사용해서 다중관계를 처리한적이 있기 때문에 이를 적용하면 된다고 생각하였다. 하지만, 이를 적용해나가는 과정에서 여러 문제들에 직면하게 되었다. 여러 종류들의 JOIN들을 분석하고 프로젝트에서 최적의 JOIN을 하는 법을 알아보려고 한다.

    현재 프로젝트에 적용된 Entity

    Lab Entity

    @Data
    @Entity
    @Table(name = "labs")
    public class LabEntity {
        @Id
        @Column(name = "lab_id")
        private String id;
    //    private enum school;
    //    private enum major
        private String professor;
        private String contents;
        private long likeSum;
        @OneToMany(mappedBy = "labEntity", cascade = CascadeType.ALL)
        private List<BoardEntity> boardEntityList;
        @OneToMany(mappedBy = "labEntity", cascade = CascadeType.ALL)
        private List<StarEntity> starEntityList;
        private long createdAt;
    
    }
    

    Board Entity

    @Entity
    @Data
    @Table(name = "boards")
    public class BoardEntity {
        @Id
        @Column(name = "board_id")
        private String id;
        private String userId;
        @OneToMany(mappedBy = "boardEntity", cascade = CascadeType.ALL)
        private List<CommentEntity> commentEntities;
        @OneToMany(mappedBy = "boardEntity", cascade = CascadeType.ALL)
        private List<LikeEntity> likeEntities;
        private String contents;
        private boolean deleted;
        private long createdAt;
        private long deletedAt;
    
        @ManyToOne
        @JoinColumn(name = "lab_id", nullable = true)
        private LabEntity labEntity;
    
    }
    

    Comment Entity

    @Entity
    @Data
    @Table(name = "comments")
    public class CommentEntity {
        @Id
        @Column(name = "comment_id")
        private String id;
        private String userId;
        private String contents;
    
        @OneToMany(mappedBy = "commentEntity",cascade = CascadeType.ALL)
        private List<RecommentEntity> recommentEntities;
    
        @OneToMany(mappedBy = "commentEntity", cascade = CascadeType.ALL)
        private List<LikeEntity> likeEntities;
    
        private boolean deleted;
        private long createdAt;
        private long deletedAt;
    
        @ManyToOne
        @JoinColumn(name = "board_id",nullable = true)
        private BoardEntity boardEntity;
    
    }
    

    엔티티들을 정리를 하자면 Lab 안에 Board 배열이 있고, Board 배열안에 Comment배열이 존재한다.

    전체 Data를 가져오려면 2번의 JOIN연산을 해야한다.(참고로 JOIN연산은 굉장히 무겁다)

    즉시로딩(Eager)

    즉시로딩이 동작하는 원리와 이로 초래되는 상황들에 대해서 설명해보겠다.

    Entity

    @Entity
    public class Team {
        @Id @GeneratedValue
        private Long id;
        private String name;
    
        @OneToMany(mappedBy = "team", fetch = FetchType.EAGER)
        private List<Member> members = new ArrayList<>();
    }
    
    @Entity
    public class Member {
        @Id @GeneratedValue
        private Long id;
        private String name;
        
        @ManyToOne
        @JoinColumn(name = "team_id")
        private Team team;
    
        public void setTeam(Team team) {
            this.team = team;
        }
    }
    
    

    Repository

    package org.example.dbjointest.repository;
    
    import org.example.dbjointest.entity.Team;
    import org.springframework.data.jpa.repository.JpaRepository;
    import org.springframework.data.jpa.repository.Query;
    import org.springframework.data.repository.query.Param;
    
    import java.util.List;
    
    public interface TeamRepository extends JpaRepository<Team,Long> {
        @Query("SELECT t FROM Team t")
        List<Team> findAllTeams();
    
        @Query("SELECT t FROM Team t WHERE t.id = :id")
        Team findOneTeam(@Param("id") Long id);
    }
    
    

    Service

    package org.example.dbjointest.service;
    
    import jakarta.persistence.*;
    import org.example.dbjointest.entity.Team;
    import org.example.dbjointest.repository.TeamRepository;
    import org.springframework.beans.factory.annotation.Autowired;
    import org.springframework.stereotype.Service;
    import org.springframework.transaction.annotation.Transactional;
    
    import java.util.List;
    import java.util.Optional;
    
    @Service
    public class TeamService {
        @Autowired
        private TeamRepository teamRepository;
    
        @Transactional(readOnly = true)
        public List<Team> findAllTeams() {
            return teamRepository.findAll();
        }
    
        public List<Team> findAllTeamsByJPQL(){
            return teamRepository.findAllTeams();
        }
    
        public Optional<Team> findOneTeam(){
            return teamRepository.findById(1L);
        }
    
        public Team findOneTeamByJPQL(Long id){
            return teamRepository.findOneTeam(id);
        }
    }
    
    

    팀하나 넣고

    member 2개

    case1) JPQL + 전체로딩

    ---------JPQL  START-------------
    Hibernate: select t1_0.id,t1_0.name from team t1_0
    Hibernate: select m1_0.team_id,m1_0.id,m1_0.name from member m1_0 where m1_0.team_id=?
    Hibernate: select m1_0.team_id,m1_0.id,m1_0.name from member m1_0 where m1_0.team_id=?
    ---------End-------------
    

    case2) JPA + 전체로딩

    ---------JPA  START-------------
    Hibernate: select t1_0.id,t1_0.name from team t1_0
    Hibernate: select m1_0.team_id,m1_0.id,m1_0.name from member m1_0 where m1_0.team_id=?
    Hibernate: select m1_0.team_id,m1_0.id,m1_0.name from member m1_0 where m1_0.team_id=?
    ---------End-------------
    

    case3) JPQL + 개별로딩

    ---------JPQL  START-------------
    Hibernate: select t1_0.id,t1_0.name from team t1_0 where t1_0.id=?
    Hibernate: select m1_0.team_id,m1_0.id,m1_0.name from member m1_0 where m1_0.team_id=?
    ---------End-------------
    

    case4) JPA + 개별로딩

    ---------JPA  START-------------
    Hibernate: select t1_0.id,t1_0.name,m1_0.team_id,m1_0.id,m1_0.name from team t1_0 left join member m1_0 on t1_0.id=m1_0.team_id where t1_0.id=?
    ---------End-------------
    

    결론

    1. 전체로딩을 했을 경우 JPQL, JPA 모두 N+1 문제가 발생한다
    2. 개별로딩을 했을때, JPA인 경우에 내부에서 최적화가 일어나지만 JPQL인 경우에는 추가적인 쿼리가 발생한다.

    —> 전체로딩시에 만약 팀이 1000개면 1000개의 팀이 멤버를 조회하는 일이 발생한다

    지연로딩(LAZY)

    case1) JPQL + 전체로딩

    ---------JPQL  START-------------
    Hibernate: select t1_0.id,t1_0.name from team t1_0
    ---------End-------------
    

    case2) JPA + 전체로딩

    ---------JPA  START-------------
    Hibernate: select t1_0.id,t1_0.name from team t1_0
    ---------End-------------
    

    case3) JPQL + 개별로딩

    ---------JPQL  START-------------
    Hibernate: select t1_0.id,t1_0.name from team t1_0 where t1_0.id=?
    ---------End-------------
    

    case4) JPA + 개별로딩

    ---------JPA  START-------------
    Hibernate: select t1_0.id,t1_0.name from team t1_0 where t1_0.id=?
    ---------End-------------
    

    결론 전부다 같게 나온다 —> 하나의 쿼리만 발생

    하지만?

    • 지연로딩인 경우에 연관된 값들을 프록시로 가져온다.
    • 결국, Team내의 Member에 접근하게 될경우 N+1 문제가 발생한다.
    @Test
    public void ttest(){
    	  Team team = teamService.findOneTeam();
    	  System.out.println(team.getMembers());
    }
    
    Hibernate: select t1_0.id,t1_0.name from team t1_0 where t1_0.id=?
    
    org.hibernate.LazyInitializationException: failed to lazily initialize a collection of role: org.example.dbjointest.entity.Team.members: could not initialize proxy - no Session
    	at org.hibernate.collection.spi.AbstractPersistentCollection.throwLazyInitializationException(AbstractPersistentCollection.java:634)
    	at org.hibernate.collection.spi.AbstractPersistentCollection.withTemporarySessionIfNeeded(AbstractPersistentCollection.java:217)
    	at org.hibernate.collection.spi.AbstractPersistentCollection.initialize(AbstractPersistentCollection.java:613)
    	at org.hibernate.collection.spi.AbstractPersistentCollection.read(AbstractPersistentCollection.java:136)
    	at org.hibernate.collection.spi.PersistentBag.toString(PersistentBag.java:587)
    	at java.base/java.lang.String.valueOf(String.java:4220)
    	at java.base/java.io.PrintStream.print(PrintStream.java:877)
    	at org.gradle.internal.io.LinePerThreadBufferingOutputStream.println(LinePerThreadBufferingOutputStream.java:230)
    	at org.example.dbjointest.DbJoinTestApplicationTests.LazyInitiazling(DbJoinTestApplicationTests.java:45)
    	at java.base/java.lang.reflect.Method.invoke(Method.java:568)
    	at java.base/java.util.ArrayList.forEach(ArrayList.java:1511)
    	at java.base/java.util.ArrayList.forEach(ArrayList.java:1511)
    
    

    만약 initializing을 하지 않으면 에러가 발생한다.

    결국 member루프를 돌면서 initializing을 해야하기 때문에 전체값을 가져오려면 결국 N+1문제가 발생한다.

    —> 하지만, 개발을 하다보면 전체값이 필요하지 않는 경우도 있다. 단순히 Team을 가져오고 싶은 경우도 있을텐데 이런 경우에 해당 방법을 사용하면 효과적이다.

      @Transactional(readOnly = true)
        public List<Team> findAllTeams() {
            List<Team> teams = teamRepository.findAllTeams();
            for (Team team : teams){
                team.getMembers();
            }
            return teamRepository.findAll();
        }
    

    Fetch Join

    JPQL에서는 Fetch join을 제공한다.이는 지연로딩을 즉시로딩 처럼 사용하면서 N+1문제를 해결할수 있는 좋은 방법이다.

    case1) Fetch Join으로 하나(Team) 가져오기

    @Query("SELECT t FROM Team t LEFT JOIN FETCH t.members WHERE t.id = :id")
    Team findOneTeamWithFetchJoin(@Param("id") Long id);
    
    ---------FETCH JOIN  START-------------
    Hibernate: 
    select t1_0.id,m1_0.team_id,m1_0.id,m1_0.name,t1_0.name 
    from team t1_0 
    left join member 
    m1_0 on t1_0.id=m1_0.team_id 
    where t1_0.id=?
    ---------END-------------
    

    case2) Fetch Join으로 여러개 가져오기

    @Query("SELECT t FROM Team t LEFT JOIN FETCH t.members")
    List<Team> findAllTeamsWithFetchJoin();
    
    ---------FETCH JOIN  START-------------
    Hibernate: 
    select t1_0.id,m1_0.team_id,m1_0.id,m1_0.name,t1_0.name 
    from team t1_0 
    left join 
    member m1_0 on t1_0.id=m1_0.team_id
    ---------END-------------
    

    둘다 하나의 쿼리로 깔끔하게 잘 가져온다 → N+1 문제 해결

    하지만 ? SQL이 LEFT JOIN 인것을 잘 생각해보자

    결과적으로는 Entity에 매핑이 될때는

    Team1 : member(1,2,3)

    Team2 : member(4,5,6)

    이렇게 되겠지만 이는 아래의 결과를 기반으로 매핑이 된다.

    '1','1','3','Member 3','Team A' '1','1','2','Member 2','Team A' '1','1','1','Member 1','Team A' '2','2','6','Member 6','Team B' '2','2','5','Member 5','Team B' '2','2','4','Member 4','Team B'

    LEFT JOIN을 하는 경우에 데이터 Multiplication이 발생한다 !

    → 이는 Offset Limit 기반의 Pagination이 불가능하다는 것을 의미한다.

    Case3) 그냥 Join을 사용하는 경우

    @Query("SELECT t FROM Team t JOIN t.members m")
    List<Team> findAllTeamsWithJoin();
    
    ---------Just Join  START-------------
    Hibernate: 
        select
            t1_0.id,
            t1_0.name 
        from
            team t1_0 
        join
            member m1_0 
                on t1_0.id=m1_0.team_id
    
    failed to lazily initialize a collection of role: org.example.dbjointest.entity.Team.members: could not initialize proxy - no Session
    org.hibernate.LazyInitializationException: failed to lazily initialize a collection of role: org.example.dbjointest.entity.Team.members: could not initialize proxy - no Session
    	at org.hibernate.collection.spi.AbstractPersistentCollection.throwLazyInitializationException(AbstractPersistentCollection.java:634)
    	at org.hibernate.collection.spi.AbstractPersistentCollection.withTemporarySessionIfNeeded(AbstractPersistentCollection.java:217)
    	at org.hibernate.collection.spi.AbstractPersistentCollection.initialize(AbstractPersistentCollection.java:613)
    	at org.hibernate.collection.spi.AbstractPersistentCollection.read(AbstractPersistentCollection.java:136)
    	at org.hibernate.collection.spi.PersistentBag.toString(PersistentBag.java:587)
    

    Proxy 에러가 발생한다

    요약

    1. Fetch Join을 사용하면, 연관된 엔티티들을 전부 Left Join을 통해서 가져오기 때문에 proxy 에러가 발생하지 않는다
    2. 하지만, Left join의 특성상 Data Multiplication이 발생한다

    번외

    • 그냥 Join을 사용하는 경우, 쿼리문을 보면 조금 이상한것을 확인할 수 있다. Join자체는 걸리지만, 해당 Join한 값인 member를 가져오진 않는다.
    • select 안에는 team.id, team.name만 있는것을 확인할 수 있다.

    Fecth Join의 데이터 뻥튀기 현상을 어떻게 해결할 수 있을까?

    BatchSize 적용하기

    @OneToMany(mappedBy = "team", fetch = FetchType.LAZY)
    @BatchSize(size = 30)  // 이곳에 batch size 설정
    private List<Member> members;
    
    @Query("SELECT t FROM Team t")
    List<Team> findAllTeams();
    
    public List<Team> findAllTeams() {
            List<Team> teams = teamRepository.findAllTeams();
            for (Team team : teams){
                team.getMembers();
            }
            return teamRepository.findAll();
        }
    
    //결과 
    Hibernate: 
        select
            t1_0.id,
            t1_0.name 
        from
            team t1_0
    Hibernate: 
        select
            m1_0.team_id,
            m1_0.id,
            m1_0.name 
        from
            member m1_0 
        where
            m1_0.team_id in (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
        
    

    두번의 쿼리가 실행된다

    1. 전체 팀을 조회하는 쿼리
    2. 조회된 팀 내의 멤버들을 한번에 Where in 절을 통해서 조회하는 쿼리

    참고로 BathSize는 Fetch Join과 연관이 없다

    • 위 예제에서도 Fetch Join이 아닌 일반 Join을 사용한다

    where in 절을 이용하여 한번에 가져올수 있어서 Paging이 가능하다.

    오 Fetch Join 이 매우 좋은 방법이구나 ! 그렇다면 우리 프로젝트에서도 JPA 의 FK를 사용하는게 맞을까?

    One to Many Annotation 의 단점

    단점

    1. 성능이 느려짐
      • DB 차원에서 정합성을 확인해주어야 하기 때문에 성능이 느려집니다.
    2. 확장이 어려움
      • 작성해야될 것이 생각보다 많고, 직관성이 떨어진다고 생각한다(개인적인 의견)
      • Mapping Type, Cascade 방식, 참조키 등등.. 작성할것들이 많다
    3. 테스트의 번거로움
    4. 순환참조
      • One to many, many to one으로 결국 순환매핑 되기 때문에, 로직을 잘못 짜버린다면 순환참조 오류가 발생할 수 도 있다.

    경험담

    • 이전에 회사에서도 데이터베이스에 전부 FK가 없어서 의아한적이 있었다. 물어보니깐 그거 없어도 잘 돌아갈 뿐더러, 확장성때문에 잘 사용하지 않는다라는 답을 들었다 → (정답이 아닐수도 있음)
    • 여러 커뮤나 글들을 보면, 실무에서 FK를 사용하는 일이 많지 않다고 한다.

    결론

    • One To Many Annotation은 최대한 피하는 것이 좋다 → Id 참조를 이용해서도 충분히 JOIN을 할 수 있다
    • One To Many Annotation은 쿼리를 효율적으로, 짧게 만드는데 큰 도움을 주기 때문에 사용이 불가피한 경우도 존재한다
      • 어그리게이트 끼리는 (무조건)ID참조를 통해서 데이터를 접근한다
      • 하나의 어그리게이트 안에 속한 local entity 같은 경우에는 One-to-Many Annotation을 사용하는 경우가 좋을 수도 있다.
    • JOIN시에 발생하는 쿼리의 갯수차이로, 일반적으로 Lazy-Loading을 사용하는것이 좋다
    • Many to One 관계는 Fetch Join을 적극적으로 활용해보자
Designed by Tistory.