[JPA] 왜 일대다 컬렉션에서 페치조인을 쓸 때 페이징을 쓰면 안되는가

땡글이·2023년 4월 3일
1

JPA

목록 보기
10/10
post-thumbnail

이전에 페치조인에 대한 글을 작성했었습니다. 해당 글에서는 일대다 컬렉션에 대한 페치 조인은 페이징이 불가능하다고 했었습니다.. 물론 불가능한 건 아니지만, 데이터의 정합성에 문제가 생길 수 있다는 것입니다. 상황을 예시로 들어보겠습니다.

데이터의 정합성 vs 데이터의 일관성

데이터의 일관성은 데이터베이스 내의 모든 데이터가 항상 일관된 상태로 유지되어야 함을 의미합니다. 이는 데이터베이스에 있는 모든 데이터가 언제나 최신 상태이고 서로 일관되게 유지되어야 함을 의미합니다.
반면 데이터의 정합성은 데이터베이스 내의 모든 데이터가 정확하고 유효한 값을 가지고 있어야 함을 의미합니다. 이는 데이터베이스 내에 저장된 모든 데이터가 의미적으로 유효하고 일관성 있는 값이어야 함을 의미합니다.

문제가 생기는 가상 시나리오

Team 과 Member 엔티티가 일대다로 연관관계를 가질 때, select distinct t from Team t join fetch t.members 로 JPQL을 작성했다고 가정하겠습니다.

  • distinct 키워드를 붙임으로써, 데이터가 중복 조회되는 문제를 예방하였습니다.

데이터가 중복조회되는 문제란, 데이터베이스에서 일대다 관계를 조인해서 결과들을 가져오게 되면 예상치 못하게 하나의 팀이 여러 개 조회되는 문제가 발생합니다. 그래서 해당 문제를 해결하기 위해 어플리케이션 단에서 중복을 제거하는 distinct를 붙여서 중복을 제거합니다.

distinct 로 데이터의 중복을 제거했더라도 페이징을 이용하게 되면, 여전히 문제는 존재합니다. 어떤 문제가 존재할까요??

distinct를 쓰더라도, 결국 데이터베이스 단에서는 데이터가 중복으로 인식되지 않습니다. 왜? 페치조인은 엔티티와 연관된 엔티티의 로우까지 같이 가져오기 때문입니다. 그래서 애플리케이션 단에서 중복으로 인식해 제거해주는 것입니다.

distinct에 대한 이해는 마쳤으니 이제 다시 예시로 돌아가봅니다. 위의 상황에서 페이징 처리를 해서 페이지 사이즈를 1로 정했다면 팀A에 포함된 유저가 회원1로 나오고, 그 다음 페이지에서 회원2로 조회되는 문제가 발생합니다.

즉, 0페이지를 조회할 땐, 팀A의 소속 회원은 회원1 1명으로 나오고, 1페이지에선 팀A의 소속회원은 회원2 1명으로 조회됩니다. 결국 앞에서 distinct를 활용해 데이터가 중복 조회되는 문제를 제거해줬지만 페이징을 함으로써 다시 중복 조회되는 문제가 발생하게 됩니다.

데이터의 정합성이 깨지게 되는 문제가 발생한다!!

시나리오 정리

컬렉션의 페치 조인에서 페이징 처리를 하게 되면, 연관된 다른 테이블의 데이터까지 모두 조회하는 것이 아니라 일부 데이터만 가져오기 때문에 연관된 데이터의 변경을 제대로 반영하지 못할 가능성이 생깁니다. 또한 데이터가 중복 조회되는 문제도 발생하게 됩니다.

만약 위의 SQL문을 통해 가져온 엔티티에서 member의 이름에 "-teamA" 와 같이 팀이름을 붙이는 작업을 한다면 일부 데이터는 이 작업이 반영되지 못할 수 있고, 데이터의 정합성이 깨지게 됩니다. 그래서 정말 주의해서 사용해야 합니다.

즉, 정리하자면 일대다 컬렉션에 대한 페치조인을 사용했을 때 페이징을 사용하면 데이터베이스 내의 데이터가 일관된 상태를 유지하지 못하게 되어 데이터의 정합성이 깨질 수 있습니다.

그럼 페이징하려면 일대다 컬렉션은 무조건 따로 조회해야하나?

만약 컬렉션 페치조인을 적용하기 전에, 반대로 생각해보시길 바랍니다. 일대다 관계에서 페치조인이 아니라, 다대일 관계에서의 페치조인은 페이징에도 전혀 문제가 없습니다. 그렇기에 제가 생각하기에 가장 간단하고 좋은 해결방안입니다. 가장 심플하고 직관적인 것이 유지보수에도 편합니다!

  • 반대로 조회한다 (다대일 관계에서 페치조인을 통해 조회)

실무에서의 복잡성 때문에 다대일로 조회할 수 없고, 무조건 일대다 컬렉션을 조회해야하는데 성능을 최적화시켜야해서 페치조인을 사용해야한다면??

JPA에서는 일대다 컬렉션의 페치 조인에 페이징을 적용하는 방법 대신 2가지 대안을 만들어뒀습니다.

  • @BatchSize
  • hibernate.default_batch_fetch_size

@BatchSize

@BatchSize 어노테이션을 일대다 컬렉션 위에 적어주고 (size = 100) 이런 식으로 배치 사이즈를 설정해준 뒤, 페치 조인 없이 페이징하려는 엔티티만을 조회하면 N+1문제를 해결할 수 있습니다. N+1 개의 쿼리를 하나로 줄이지는 못하고, 1+1 개의 쿼리로 단축시킬 수 있습니다.

  • N개 -> 1개 : 일대다 컬렉션에 대한 SQL 조회문 개수

어떻게 단축시킬 수 있는가? 일대다 관계에서 일 쪽의 엔티티들을 여러 개 조회했을 때, 다 쪽의 엔티티에 대해 in 쿼리를 통해 N개의 쿼리를 1개의 쿼리로 단축시킬 수 있는 것입니다.

즉, @BatchSize(size=X) 어노테이션의 의미는 우선 조회하려는 엔티티만을 페이징 처리를 통해 조회한 뒤, 연관된 엔티티들을 in 쿼리로 한 번에 가져옴으로써 해당 문제를 해결할 수 있습니다.

hibernate.default_batch_fetch_size

yml 파일에서 해당 옵션 값을 주게 되면, @BatchSize 처럼 일일이 컬렉션에 적어줄 필요 없이 글로벌로 설정할 수 있습니다.

이제 아래에서 직접 예시를 보며 이해해보겠습니다!

직접 프로젝트에 적용해보기

아래는 제가 이전에 만들었었던 프로젝트의 일부입니다. 이전 프로젝트에 있었던 N+1문제를 해결하고 성능을 최적화한 내용을 기록해둔 예제입니다.

(Old) N+1문제 있던 코드

@Entity
public class Review {
	
    ...
   
    @OneToMany(mappedBy = "review"
            , fetch = FetchType.LAZY
            , cascade = CascadeType.ALL
            , orphanRemoval = true)
    private Set<ReviewHashtag> reviewHashtagSet = new HashSet<>();

    @OneToMany(mappedBy = "review"
            , fetch = FetchType.LAZY
            , cascade = CascadeType.ALL
            , orphanRemoval = true)
    private Set<ReviewImage> reviewImageSet = new HashSet<>();
    
    ...
}
[ N+1 문제 있던 서비스 로직의 일부분 ]
        List<Review> reviewList = reviewRepository.findAllByHairDesignerProfileIdAndStatus(
                hairDesignerId
                , StatusKind.NORMAL.getId()
                , pageable
        );

        List<ReviewDetailResponseDto> reviewDetailResponseDtoList = new ArrayList<>();
        for (Review review : reviewList) {
            List<ReviewHashtagDto> hashtagDtoList = reviewHashtagRepository.findByReviewAndStatus(review, StatusKind.NORMAL.getId())
                    .stream()
                    .map(ReviewHashtagDto::new)
                    .collect(Collectors.toList());

            List<ReviewImageDto> imageDtoList = reviewImageRepository.findByReviewAndStatus(review, StatusKind.NORMAL.getId())
                    .stream()
                    .map(ReviewImageDto::new)
                    .collect(Collectors.toList());

            reviewDetailResponseDtoList.add(new ReviewDetailResponseDto(review.getReviewer().getName()
                    , new ReviewDto(review)
                    , hashtagDtoList
                    , imageDtoList));
        }
[N+1 문제 있는 예전 코드]
Hibernate: 
    select
        review0_.id as id1_10_,
        review0_.content as content2_10_,
        review0_.create_date as create_d3_10_,
        review0_.hair_designer_profile_id as hair_des9_10_,
        review0_.member_id as member_10_10_,
        review0_.service_rating as service_4_10_,
        review0_.status as status5_10_,
        review0_.style_rating as style_ra6_10_,
        review0_.total_rating as total_ra7_10_,
        review0_.update_date as update_d8_10_ 
    from
        review review0_ 
    left outer join
        hair_designer_profile hairdesign1_ 
            on review0_.hair_designer_profile_id=hairdesign1_.id 
    where
        hairdesign1_.id=? 
        and review0_.status=? limit ?
Hibernate: 
    select
        reviewhash0_.id as id1_11_,
        reviewhash0_.create_date as create_d2_11_,
        reviewhash0_.hashtag as hashtag3_11_,
        reviewhash0_.review_id as review_i6_11_,
        reviewhash0_.status as status4_11_,
        reviewhash0_.update_date as update_d5_11_ 
    from
        review_hashtag reviewhash0_ 
    where
        reviewhash0_.review_id=? 
        and reviewhash0_.status=?
Hibernate: 
    select
        reviewimag0_.id as id1_12_,
        reviewimag0_.create_date as create_d2_12_,
        reviewimag0_.image_url as image_ur3_12_,
        reviewimag0_.review_id as review_i6_12_,
        reviewimag0_.status as status4_12_,
        reviewimag0_.update_date as update_d5_12_ 
    from
        review_image reviewimag0_ 
    where
        reviewimag0_.review_id=? 
        and reviewimag0_.status=?
Hibernate: 
    select
        reviewhash0_.id as id1_11_,
        reviewhash0_.create_date as create_d2_11_,
        reviewhash0_.hashtag as hashtag3_11_,
        reviewhash0_.review_id as review_i6_11_,
        reviewhash0_.status as status4_11_,
        reviewhash0_.update_date as update_d5_11_ 
    from
        review_hashtag reviewhash0_ 
    where
        reviewhash0_.review_id=? 
        and reviewhash0_.status=?
Hibernate: 
    select
        reviewimag0_.id as id1_12_,
        reviewimag0_.create_date as create_d2_12_,
        reviewimag0_.image_url as image_ur3_12_,
        reviewimag0_.review_id as review_i6_12_,
        reviewimag0_.status as status4_12_,
        reviewimag0_.update_date as update_d5_12_ 
    from
        review_image reviewimag0_ 
    where
        reviewimag0_.review_id=? 
        and reviewimag0_.status=?
Hibernate: 
    select
        reviewhash0_.id as id1_11_,
        reviewhash0_.create_date as create_d2_11_,
        reviewhash0_.hashtag as hashtag3_11_,
        reviewhash0_.review_id as review_i6_11_,
        reviewhash0_.status as status4_11_,
        reviewhash0_.update_date as update_d5_11_ 
    from
        review_hashtag reviewhash0_ 
    where
        reviewhash0_.review_id=? 
        and reviewhash0_.status=?
Hibernate: 
    select
        reviewimag0_.id as id1_12_,
        reviewimag0_.create_date as create_d2_12_,
        reviewimag0_.image_url as image_ur3_12_,
        reviewimag0_.review_id as review_i6_12_,
        reviewimag0_.status as status4_12_,
        reviewimag0_.update_date as update_d5_12_ 
    from
        review_image reviewimag0_ 
    where
        reviewimag0_.review_id=? 
        and reviewimag0_.status=?

(New) 성능 최적화한 코드

@Entity
public class Review {

	...
    
    @BatchSize(size = 100)
    @OneToMany(mappedBy = "review"
            , fetch = FetchType.LAZY
            , cascade = CascadeType.ALL
            , orphanRemoval = true)
    private Set<ReviewHashtag> reviewHashtagSet = new HashSet<>();

    @BatchSize(size = 100)
    @OneToMany(mappedBy = "review"
            , fetch = FetchType.LAZY
            , cascade = CascadeType.ALL
            , orphanRemoval = true)
    private Set<ReviewImage> reviewImageSet = new HashSet<>();
    
    ...
}
[ N+1 문제 없앤 서비스 로직의 일부분 ]
        List<Review> reviewList = reviewRepository.findAllByHairDesignerProfileIdAndStatus(
                hairDesignerId
                , StatusKind.NORMAL.getId()
                , pageable
        );

        List<ReviewDetailResponseDto> reviewDetailResponseDtoList = new ArrayList<>();
        for (Review review : reviewList) {
            reviewDetailResponseDtoList.add(new ReviewDetailResponseDto(review.getReviewer().getName()
                    , new ReviewDto(review)
                    , review.getReviewHashtagSet().stream().map(ReviewHashtagDto::new).collect(Collectors.toList())
                    , review.getReviewImageSet().stream().map(ReviewImageDto::new).collect(Collectors.toList())));
        }
[성능 최적화 이후]
Hibernate: 
    select
        review0_.id as id1_10_,
        review0_.content as content2_10_,
        review0_.create_date as create_d3_10_,
        review0_.hair_designer_profile_id as hair_des9_10_,
        review0_.member_id as member_10_10_,
        review0_.service_rating as service_4_10_,
        review0_.status as status5_10_,
        review0_.style_rating as style_ra6_10_,
        review0_.total_rating as total_ra7_10_,
        review0_.update_date as update_d8_10_ 
    from
        review review0_ 
    left outer join
        hair_designer_profile hairdesign1_ 
            on review0_.hair_designer_profile_id=hairdesign1_.id 
    where
        hairdesign1_.id=? 
        and review0_.status=? limit ?

Hibernate: 
    select
        reviewhash0_.review_id as review_i6_11_1_,
        reviewhash0_.id as id1_11_1_,
        reviewhash0_.id as id1_11_0_,
        reviewhash0_.create_date as create_d2_11_0_,
        reviewhash0_.hashtag as hashtag3_11_0_,
        reviewhash0_.review_id as review_i6_11_0_,
        reviewhash0_.status as status4_11_0_,
        reviewhash0_.update_date as update_d5_11_0_ 
    from
        review_hashtag reviewhash0_ 
    where
        reviewhash0_.review_id in (
            ?, ?, ?
        )
Hibernate: 
    select
        reviewimag0_.review_id as review_i6_12_1_,
        reviewimag0_.id as id1_12_1_,
        reviewimag0_.id as id1_12_0_,
        reviewimag0_.create_date as create_d2_12_0_,
        reviewimag0_.image_url as image_ur3_12_0_,
        reviewimag0_.review_id as review_i6_12_0_,
        reviewimag0_.status as status4_12_0_,
        reviewimag0_.update_date as update_d5_12_0_ 
    from
        review_image reviewimag0_ 
    where
        reviewimag0_.review_id in (
            ?, ?, ?
        )

Reference

자바 ORM 표준 JPA 프로그래밍

profile
꾸벅 🙇‍♂️ 매일매일 한발씩 나아가자잇!

0개의 댓글