[Spring] JPA 트러블 슈팅(N+1 이슈)

WOOK JONG KIM·2022년 11월 24일
0

패캠_java&Spring

목록 보기
67/103
post-thumbnail

N+1 이슈

@Entity
public class User{
	...
    
    @OneToMany(fetch = FetchType.EAGER)
    @ToString.Exclude
    @JoinColumn(name = "user_id", insertable = false, updatable = false)
    private List<UserHistory> userHistories = new ArrayList<>();

	@OneToMany
    @JoinColumn(name = "user_id")
    @ToString.Exclude
    private List<Review> reviews = new ArrayList<>(); // null point exception 방지
}

user를 호출할때마다 userHistory가 항상 필요하진 않을것(않은 경우가 더 많음)
-> userHistory는 실제로 필요한 시점에만 쿼리를 실행하면 될 것
-> 필요한 시점을 JPA에서 아는 방법은 Getter에 의한 호출

즉 유저 객체를 가져오기 위해서 쿼리가 실행되고 난 후 만약 유저 객체에 대한 히스토리가 필요해서 userRepository.getHistories() 하는 시점에 쿼리가 실행되어 유저 히스토리 리스트를 가져오는 것 -> LAZY FETCH

반면 User 객체를 호출하면 관련 릴레이션을 모두 조회해오는 것을 -> EAGER FETCH

LAZY는 세션(영속성 컨텍스트가 엔티티를 관리하고 있는 시점)이 열려 잇을 때만 가능
-> @Transactional을 통해 트랜잭션이 열려있을 때 그 주기안에서만 LAZY FETCH가 동작

could not initialize proxy - no Session
-> 이 경우 @Transactional을 붙이거나 EAGER FETCH를 통해 해결

public class Review extends BaseEntity {

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

    private String title;

    private String content;

    private float score;

    @ManyToOne
    private User user;
	
    @ManyToOne
    private Book book;

    @OneToMany
    @JoinColumn(name = "review_id")
    @ToString.Exclude
    private List<Comment> comments;
}

또한 ToString을 하게 되면 지정된 위에 보이듯 여러 속성값을 출력하기 위해 GETTER를 사용하게 됨

ToString, JSON Serialize(REST API 결과가 보통 JSON)를 쓸 때 Exclude하지 않고 getter를 쓰면 LAZY 패치의 장점을 잃음
-> 엔티티 실행시마다 관련 릴레이션 쿼리를 실행하기에

연관관계의 FetchType default 값
EAGGER

  • @ManyToOne
  • @OneToOne

LAZY

  • @OneToMany
  • @ManyToMany

Review.java

	@ManyToOne
    @ToString.Exclude
    private Book book;

이 경우 리뷰 엔티티를 가져올때 book 정보를 항상 가져옴
@ManyToOne(fetch = FetchType.LAZY) 사용 시 쿼리가 간단해짐

하지만 EAGER,LAZY는 쿼리하는 시점에 문제이지 N+1 즉 쿼리 횟수에 영향을 끼치진 않음


EAGER, LAZY 사용 차이

  1. EAGER

Review

...
public class Review extends BaseEntity{
		...
        
    @ManyToOne(fetch = FetchType.LAZY)
    @ToString.Exclude
    private User user;


    @ManyToOne(fetch = FetchType.LAZY)
    @ToString.Exclude
    private Book book;

    @OneToMany(fetch = FetchType.EAGER)
    @JoinColumn(name = "review_id")
    private List<Comment> comments;
}

테스트 코드

EAGER의 경우 조회 쿼리가 실행되면 영속성 컨텍스트 캐쉬를 통해 데이터 조회 (쿼리 추가 실행 X)

@Transactional
void reviewTest() {
    List<Review> reviews = reviewRepository.findAll();
//        System.out.println(reviews);
    System.out.println("전체를 가져왔습니다.");

    System.out.println(reviews.get(0).getComments());

    System.out.println("첫번째 리뷰의 코멘트들을 가져왔습니다.");

    System.out.println(reviews.get(1).getComments());

    System.out.println("두번째 리뷰의 코멘트들을 가져왔습니다.");
    
Hibernate: 
    select
        review0_.id as id1_6_,
        review0_.created_at as created_2_6_,
        review0_.updated_at as updated_3_6_,
        review0_.book_id as book_id7_6_,
        review0_.content as content4_6_,
        review0_.score as score5_6_,
        review0_.title as title6_6_,
        review0_.user_id as user_id8_6_ 
    from
        review review0_
Hibernate: 
    select
        comments0_.review_id as review_i5_4_0_,
        comments0_.id as id1_4_0_,
        comments0_.id as id1_4_1_,
        comments0_.created_at as created_2_4_1_,
        comments0_.updated_at as updated_3_4_1_,
        comments0_.comment as comment4_4_1_,
        comments0_.review_id as review_i5_4_1_ 
    from
        comment comments0_ 
    where
        comments0_.review_id=?
Hibernate: 
    select
        comments0_.review_id as review_i5_4_0_,
        comments0_.id as id1_4_0_,
        comments0_.id as id1_4_1_,
        comments0_.created_at as created_2_4_1_,
        comments0_.updated_at as updated_3_4_1_,
        comments0_.comment as comment4_4_1_,
        comments0_.review_id as review_i5_4_1_ 
    from
        comment comments0_ 
    where
        comments0_.review_id=?

전체를 가져왔습니다.
[]
첫번째 리뷰의 코멘트들을 가져왔습니다.
[]
두번째 리뷰의 코멘트들을 가져왔습니다.
}

즉 조회쿼리를 getComments를 한다고 해서 쿼리를 추가적으로 실행하는 것이 아니라 앞서 실행한 FindAll을 바탕으로 영속성 컨텍스트에 있는 캐시에 값을 가져와 프린트

  1. LAZY
...
public class Review extends BaseEntity{
    ...
    @OneToMany(fetch = FetchType.LAZY)
    @JoinColumn(name = "review_id")
    private List<Comment> comments;
}
Hibernate: 
    select
        review0_.id as id1_6_,
        review0_.created_at as created_2_6_,
        review0_.updated_at as updated_3_6_,
        review0_.book_id as book_id7_6_,
        review0_.content as content4_6_,
        review0_.score as score5_6_,
        review0_.title as title6_6_,
        review0_.user_id as user_id8_6_ 
    from
        review review0_

전체를 가져왔습니다.

Hibernate: 
    select
        comments0_.review_id as review_i5_4_0_,
        comments0_.id as id1_4_0_,
        comments0_.id as id1_4_1_,
        comments0_.created_at as created_2_4_1_,
        comments0_.updated_at as updated_3_4_1_,
        comments0_.comment as comment4_4_1_,
        comments0_.review_id as review_i5_4_1_ 
    from
        comment comments0_ 
    where
        comments0_.review_id=?

[]
첫번째 리뷰의 코멘트들을 가져왔습니다.

Hibernate: 
    select
        comments0_.review_id as review_i5_4_0_,
        comments0_.id as id1_4_0_,
        comments0_.id as id1_4_1_,
        comments0_.created_at as created_2_4_1_,
        comments0_.updated_at as updated_3_4_1_,
        comments0_.comment as comment4_4_1_,
        comments0_.review_id as review_i5_4_1_ 
    from
        comment comments0_ 
    where
        comments0_.review_id=?

[]
두번째 리뷰의 코멘트들을 가져왔습니다.

필요한 순간, 즉 조회 메서드를 실행시킬때 조회 쿼리가 생성되어 실행됨

EAGER, LAZY는 쿼리 실행시점의 문제, N+1의 실행 문제는 여전히 존재


N+1 문제 해결 방법

  1. @Query를 통해 Fetch 쿼리를 Custom

ReviewRepository.java

  • distinct를 통해 중복되는 데이터를 제거, join fetch를 통해 Review와 Comment 조인
@Repository
public interface ReviewRepository extends JpaRepository<Review, Long> {
    @Query(value = "select distinct r from Review r join fetch r.comments") // 속성값 밑 엔티티 이름 사용
    List<Review> findAllByFetchJoin();
}

data.sql

insert into review(`id`, `title`, `content`, `score`, `user_id`, `book_id`) values(1, '내 인생을 바꾼책', '너무너무 좋았어요', 5.0, 1, 1);

insert into review(`id`, `title`, `content`, `score`, `user_id`, `book_id`) values(2, '진도가 빨라요', '조금 별로였어요', 3.0, 2, 2);

insert into comment(`id`, `comment`, `review_id`) values(1, '저도 좋았어요', 1);
insert into comment(`id`, `comment`, `review_id`) values(2, '저는 별로 였습니다.', 1);
insert into comment(`id`, `comment`, `review_id`) values(3, '저는 그냥 그랬습니다.', 2);
List<Review> reviews = reviewRepository.findAllByFetchJoin();

reviews.forEach(System.out::println);

Hibernate: 
    select
        review0_.id as id1_6_0_,
        comments1_.id as id1_4_1_,
        review0_.created_at as created_2_6_0_,
        review0_.updated_at as updated_3_6_0_,
        review0_.book_id as book_id7_6_0_,
        review0_.content as content4_6_0_,
        review0_.score as score5_6_0_,
        review0_.title as title6_6_0_,
        review0_.user_id as user_id8_6_0_,
        comments1_.created_at as created_2_4_1_,
        comments1_.updated_at as updated_3_4_1_,
        comments1_.comment as comment4_4_1_,
        comments1_.review_id as review_i5_4_1_,
        comments1_.review_id as review_i5_4_0__,
        comments1_.id as id1_4_0__ 
    from
        review review0_ 
    inner join
        comment comments1_ 
            on review0_.id=comments1_.review_id
            
Review(super=BaseEntity(createdAt=2022-11-24T14:50:44.225102, updatedAt=2022-11-24T14:50:44.225102), id=1, title=내 인생을 바꾼책, content=너무너무 좋았어요, score=5.0, comments=[Comment(super=BaseEntity(createdAt=2022-11-24T14:50:44.225959, updatedAt=2022-11-24T14:50:44.225959), id=1, comment=저도 좋았어요), Comment(super=BaseEntity(createdAt=2022-11-24T14:50:44.226472, updatedAt=2022-11-24T14:50:44.226472), id=2, comment=저는 별로 였습니다.)])
Review(super=BaseEntity(createdAt=2022-11-24T14:50:44.225574, updatedAt=2022-11-24T14:50:44.225574), id=2, title=진도가 빨라요, content=조금 별로였어요, score=3.0, comments=[Comment(super=BaseEntity(createdAt=2022-11-24T14:50:44.226775, updatedAt=2022-11-24T14:50:44.226775), id=3, comment=저는 그냥 그랬습니다.)])

distinct 안붙이면 cross join에 의해 리뷰가 2개지만 3개 프린트되었음

쿼리가 한번 수행된것 볼 수 있다 -> N+1 문제 해결

  1. @EntityGraph 사용 (2.1 버전 이후부터 사용가능)

fetch join과 같은 효과, 쿼리 메서드에도 적용가능(ex: findAll())

@Repository
public interface ReviewRepository extends JpaRepository<Review, Long> {
		...

		//findAllByEntityGraph(), findAll() 모두 동일한 결과
	@EntityGraph(attributePaths = "comments")
    @Query("select r from Review r")
    List<Review> findAllByEntityGraph();
	or
	@EntityGraph(attributePaths = "comments")
    List<Review> findAll();
}
@Test
@Transactional
void reviewTest() {
		//findAllByEntityGraph(), findAll() 모두 동일한 결과
		List<Review> reviews = reviewRepository.findAllByEntityGraph();
	or
		List<Review> reviews = reviewRepository.findAll();

		reviews.forEach(System.out::println);
}

//실행결과
Hibernate: 
    select
        review0_.id as id1_6_0_,
        comments1_.id as id1_4_1_,
        review0_.created_at as created_2_6_0_,
        review0_.updated_at as updated_3_6_0_,
        review0_.book_id as book_id7_6_0_,
        review0_.content as content4_6_0_,
        review0_.score as score5_6_0_,
        review0_.title as title6_6_0_,
        review0_.user_id as user_id8_6_0_,
        comments1_.created_at as created_2_4_1_,
        comments1_.updated_at as updated_3_4_1_,
        comments1_.comment as comment4_4_1_,
        comments1_.review_id as review_i5_4_1_,
        comments1_.review_id as review_i5_4_0__,
        comments1_.id as id1_4_0__ 
    from
        review review0_ 
    left outer join
        comment comments1_ 
            on review0_.id=comments1_.review_id
            
Review(super=BaseEntity(createdAt=2022-11-24T14:57:24.666739, updatedAt=2022-11-24T14:57:24.666739), id=1, title=내 인생을 바꾼책, content=너무너무 좋았어요, score=5.0, comments=[Comment(super=BaseEntity(createdAt=2022-11-24T14:57:24.667415, updatedAt=2022-11-24T14:57:24.667415), id=1, comment=저도 좋았어요), Comment(super=BaseEntity(createdAt=2022-11-24T14:57:24.667926, updatedAt=2022-11-24T14:57:24.667926), id=2, comment=저는 별로 였습니다.)])
Review(super=BaseEntity(createdAt=2022-11-24T14:57:24.667055, updatedAt=2022-11-24T14:57:24.667055), id=2, title=진도가 빨라요, content=조금 별로였어요, score=3.0, comments=[Comment(super=BaseEntity(createdAt=2022-11-24T14:57:24.668222, updatedAt=2022-11-24T14:57:24.668222), id=3, comment=저는 그냥 그랬습니다.)])

N+1이라고 무조건 잘못 된것이 아님

파일 크기가 크거나, 작은 파일이여도 쿼리를 많이 발생 시 오히려 시스템 과부하를 시킬수 있음
-> 어떤 방식이 더 적합한지 판단 후 적용하는 것이 중요

profile
Journey for Backend Developer

0개의 댓글