본문 바로가기
Spring&Spring Boot/JPA

[JPA] 지연로딩에서 N+1 문제, Fetch Join과 Join의 차이

by 피자보다 치킨 2022. 8. 28.

N+1 문제란?

연관 관계가 설정된 엔티티를 조회할 때 조회된 데이터 갯수만큼 연관관계의 조회 쿼리가 추가로 발생하여 데이터를 읽어온다. n개가 100개면 쿼리가 100개가 나가게 되는 상황

 

현재 상황

@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Member extends BaseEntity {

    @Id @GeneratedValue
    @Column(name = "member_id")
    private Long id; //시퀀스

    private String role; //권한(user, admin)

    private String userId; //이메일(아이디)

    @Column(length = 10)
    private String nickname; //닉네임

    private String pass;
    private String username;
    private int hp;

 

@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Product extends BaseEntity { //상품

    @Id @GeneratedValue()
    @Column(name = "product_id")
    private Long id; //pk

    private String title; //제목
    private String thumbnail; //섬네일
    private String intro; //설명(게시판)
    private int Price; //상품가격

    @ManyToOne(fetch = LAZY, cascade = CascadeType.ALL)
    @JoinColumn(name = "member_id")
    private Member member; //fk

    @Enumerated(EnumType.STRING)
    private CategoryList categoryList;

    @Enumerated(EnumType.STRING)
    private ProductStatus productStatus;
    }

 

그리고 Product를 출력하기 위한 DTO가 있다.

@Getter
@AllArgsConstructor
public static class SelectProducts {

    private Long id; //상품 pk

    private String title; //제목
    private String thumbnail; //섬네일
    private String intro; //설명(게시판)
    private int price; //상품가격
    private Long memberId; //작성자 pk
    private String memberNick;
    private ProductStatus productStatus;

    /**
     * TODO
     * product.getMember().getId() : n+1 문제 (좀 더 생각해보자) fetchJoin
     * Spring Data Jpa -> entityGraph
     * @param product
     */
    public SelectProducts(Product product) {
        id = product.getId();
        title = product.getTitle();
        thumbnail = product.getThumbnail();
        intro = product.getIntro();
        price = product.getPrice();
        memberId = product.getMember().getId();
        memberNick = product.getMember().getNickname();
        productStatus = product.getProductStatus();
    }
}

 

여기서

memberId = product.getMember().getId();

memberNick = product.getMember().getNickname();을 주목하자

현재 EAGER(즉시 로딩)이 이 아닌 LAZY(지연로딩)을 사용하였기 때문에 Member가 프록시 객체로 조회가 된 후

실제 Member 객체가 조회되는 시점에 쿼리를 발생시켜 값을 가져온다.

(Member객체의 실제 값을 사용할 때 쿼리를 날려 가져온다고 생각하면 쉽다.)

 

여기서 문제가 발생된다.

 

JpaRepository를 구현한 Interface객체의 ProductRepository에서 findAll()을 호출시 아래와 같이 N+1문제가 발생한다.

select product0_.product_id as product_1_3_, product0_.create_date as create_d2_3_, product0_.price as price3_3_, product0_.category_list as category4_3_, product0_.intro as intro5_3_, product0_.member_id as member_i9_3_, product0_.product_status as product_6_3_, product0_.thumbnail as thumbnai7_3_, product0_.title as title8_3_ from product product0_
select product0_.product_id as product_1_3_, product0_.create_date as create_d2_3_, product0_.price as price3_3_, product0_.category_list as category4_3_, product0_.intro as intro5_3_, product0_.member_id as member_i9_3_, product0_.product_status as product_6_3_, product0_.thumbnail as thumbnai7_3_, product0_.title as title8_3_ from product product0_;

select member0_.member_id as member_i1_2_0_, member0_.create_date as create_d2_2_0_, member0_.address as address3_2_0_, member0_.detailed_address as detailed4_2_0_, member0_.hp as hp5_2_0_, member0_.nickname as nickname6_2_0_, member0_.pass as pass7_2_0_, member0_.role as role8_2_0_, member0_.user_id as user_id9_2_0_, member0_.username as usernam10_2_0_ from member member0_ where member0_.member_id=?
select member0_.member_id as member_i1_2_0_, member0_.create_date as create_d2_2_0_, member0_.address as address3_2_0_, member0_.detailed_address as detailed4_2_0_, member0_.hp as hp5_2_0_, member0_.nickname as nickname6_2_0_, member0_.pass as pass7_2_0_, member0_.role as role8_2_0_, member0_.user_id as user_id9_2_0_, member0_.username as usernam10_2_0_ from member member0_ where member0_.member_id=1;
select member0_.member_id as member_i1_2_0_, member0_.create_date as create_d2_2_0_, member0_.address as address3_2_0_, member0_.detailed_address as detailed4_2_0_, member0_.hp as hp5_2_0_, member0_.nickname as nickname6_2_0_, member0_.pass as pass7_2_0_, member0_.role as role8_2_0_, member0_.user_id as user_id9_2_0_, member0_.username as usernam10_2_0_ from member member0_ where member0_.member_id=?
select member0_.member_id as member_i1_2_0_, member0_.create_date as create_d2_2_0_, member0_.address as address3_2_0_, member0_.detailed_address as detailed4_2_0_, member0_.hp as hp5_2_0_, member0_.nickname as nickname6_2_0_, member0_.pass as pass7_2_0_, member0_.role as role8_2_0_, member0_.user_id as user_id9_2_0_, member0_.username as usernam10_2_0_ from member member0_ where member0_.member_id=38;
select member0_.member_id as member_i1_2_0_, member0_.create_date as create_d2_2_0_, member0_.address as address3_2_0_, member0_.detailed_address as detailed4_2_0_, member0_.hp as hp5_2_0_, member0_.nickname as nickname6_2_0_, member0_.pass as pass7_2_0_, member0_.role as role8_2_0_, member0_.user_id as user_id9_2_0_, member0_.username as usernam10_2_0_ from member member0_ where member0_.member_id=?
select member0_.member_id as member_i1_2_0_, member0_.create_date as create_d2_2_0_, member0_.address as address3_2_0_, member0_.detailed_address as detailed4_2_0_, member0_.hp as hp5_2_0_, member0_.nickname as nickname6_2_0_, member0_.pass as pass7_2_0_, member0_.role as role8_2_0_, member0_.user_id as user_id9_2_0_, member0_.username as usernam10_2_0_ from member member0_ where member0_.member_id=4;
select member0_.member_id as member_i1_2_0_, member0_.create_date as create_d2_2_0_, member0_.address as address3_2_0_, member0_.detailed_address as detailed4_2_0_, member0_.hp as hp5_2_0_, member0_.nickname as nickname6_2_0_, member0_.pass as pass7_2_0_, member0_.role as role8_2_0_, member0_.user_id as user_id9_2_0_, member0_.username as usernam10_2_0_ from member member0_ where member0_.member_id=?
select member0_.member_id as member_i1_2_0_, member0_.create_date as create_d2_2_0_, member0_.address as address3_2_0_, member0_.detailed_address as detailed4_2_0_, member0_.hp as hp5_2_0_, member0_.nickname as nickname6_2_0_, member0_.pass as pass7_2_0_, member0_.role as role8_2_0_, member0_.user_id as user_id9_2_0_, member0_.username as usernam10_2_0_ from member member0_ where member0_.member_id=10;

1. findAll()을 한 순간 select p from Product p 이라는 JPQL 구문이 생성되고 해당 구문을 분석한 select * from product이라는 쿼리가 발생된다. ( SQL 로그 중 Hibernate: select product0_.id as ~~~from product product0_ )
2. DB의 결과를 받아 Product엔티티의 인스턴스들을 생성한다.
3. 코드 중에서 Product 의 Member 객체를 사용하려고 하는 시점에 영속성 컨텍스트에서 연관된 Member가 있는지 확인한다 
4. 영속성 컨텍스트에 없다면 2에서 만들어진 Product 인스턴스들 개수에 맞게 select * from member where member_id = ? 이라는 SQL 구문이 생성된다. ( N+1 발생 )

 


Fetch Join과 일반 Join

N+1 문제를 해결하기 위한 방법은 Fetch join을 사용하는 방법이 있다.

이 fetch Join과 일반 조인의 차이점을 먼저 알아야 한다.

 

일반 Join

연관 Entity(Member)에 join을 걸어도 실제 쿼리에서 SLELCT하는 Entity는 오직 JPQL에서 조회의 추제가 되는 Entity(Product)만 조회하여 영속화 한다.

연관 관계의 Entity(Member)를 사용해야 할 경우 별도의 조회 쿼리문을 실행 해야 한다.

  • FetchType.LAZY 일 경우, 최초 조회시 획득한 id 로 조회를 N번해야함.

 

Fetch Join

조회의 주체가 되는 Entity(Product) 이외에 Fetch Join이 걸린 연관 Entity(Member)도 함께 SELECT하여 모두 영속화 한다.

즉, 2개의 Entity 모두 영속성 컨텍스트로 관리된다.

 


Fetch Join 사용하기

1. @Query

    @Query("select p from Product p join fetch p.member") // 1. 쿼리 직접 작성
    @Override
    List<Product> findAll();

직접 쿼리를 작성할 때 "select p from Product p join fetch p.member"로 작성하여 사용할 수 있다.

 

2. @EntityGraph 어노테이션

    @EntityGraph(attributePaths = {"member"}) // 2. 어노테이션 사용
    @Override
    List<Product> findAll();

findAll()을 Override하여 사용한다.

@EntityGraph 어노테이션을 추가하고 attributePaths에는 Join할 객체를 표기한다.

 

 

 

 

 

 

 

 

+ 추가방법 Batch Size 추가할 예정

+ 실무에서 N+1의 문제로 DB가 죽어버리는 문제를 방지하기 위한 해결책 공부 후 추가 예정

잘못된 부분이 있으면 피드백 부탁드립니다:)

'Spring&Spring Boot > JPA' 카테고리의 다른 글

JPA란?(HIBERNATE, HIBERNATE설정, jpa 구동방식, ORM)  (0) 2022.04.29

댓글