여행지 검색 기능 개발#2

Park JeaHyun·2022년 9월 6일

DAMDA

목록 보기
4/6

개요

JpaRepository findAll() 메서드로 여행지 객체를 조회할 때 발생하는 JPA의 N+1 문제@EntityGraph을 사용해 해결했습니다.

아래 ERD에서 확인할 수 있듯이 여행지 테이블은 리뷰, 이미지 테이블과 @OneToMany 관계를 가지고 있다. 1개의 여행지에는 다수의 리뷰와 여행지 소개 이미지가 있을 수 있기 때문이다.

하지만 이러한 상황에서 JpaRepository findAll() 메서드를 사용하면 N+1 문제가 발생하게 된다. 또한 2개의 @OneToMany 관계로 인해 MultipleBagFetchException 문제도 해결해야 한다.

1. N+1 문제
2. MultipleBagFetchException

이번 글에서는 위 2문제를 해결하는 과정에 대해 작성했습니다.

참고 자료

N+1 문제

N+1의 간단한 정의를 살펴보자.

조회 시 1개의 쿼리를 생각하고 설계를 했으나 나오지 않아도 되는 조회의 쿼리가 N개가 더 발생하는 문제.

즉 필요 이상의 쿼리문이 실행돼서 성능 측면에서 발생하는 문제같다.
(자세한 설명은 위 참고 자료에서...)

@EntityGraph

N+1 문제를 해결하기 위한 대표적인 방법은 2가지가 있다. 첫번째는 fetch join 방법이고, 두번째는 @EntityGraph 방법이다. 무엇이 더 좋다라는 개념은 아닌 것 같고 이 중 본인이 편한 방법을 선택하는 듯 하다.

저는 가독성이 좀 더 좋아보이는 @EntityGraph 방법을 사옹하기로 했습니다.

사실 여행지를 조회하기 위해선 여행지 이미지, 리뷰, 리뷰_태그 엔티티(3개)를 모두 가져와야 하기에 중첩(nested)된 fetch join 방법을 사용하는 것이 좋다. (여행지 -> 리뷰 -> 리뷰_태그)
하지만 해당 내용의 정확한 사용법을 찾지 못하여 @EntityGraph여행지 이미지, 리뷰 엔티티(2개)만 fetch join하는 형태로 개발했다.

public interface SpotRepository extends JpaRepository<Spot, Long> {

    @EntityGraph(attributePaths = {"reviews", "spotImageURLs"})
    Page<Spot> findAll(Specification<Spot> spec, Pageable pageable);

    @EntityGraph(attributePaths = {"reviews", "spotImageURLs"})
    List<Spot> findAll(Specification<Spot> spec);

    @EntityGraph(attributePaths = {"reviews", "spotImageURLs"})
    @Query("select s from Spot s where s.selfMadeFlag = 'Y'")
    Page<Spot> findAllEntityGraph(Pageable pageable);

    @EntityGraph(attributePaths = {"reviews", "spotImageURLs"})
    @Query("select s from Spot s where s.selfMadeFlag = 'Y'")
    List<Spot> findAllEntityGraph();
}

MultipleBagFetchException

fetch join을 할 때 ToMany의 경우 한번에 fetch join을 가져오기 때문에 collection join이 2개이상이 될 경우 너무 많은 값이 메모리로 들어와 exception이 추가로 걸립니다. 그 exception이 MultipleBagFetchException인데요, 아래 사진에서 알 수 있다시피 2개 이상의 bags, 즉 collection join이 두개이상일 때 exception이 발생합니다.

해당 예외는 @OneToMany 관계가 2개 이상일 때 발생할 수 있는 예외이다. 해당 예외를 피하기 위해선 설정 파일을 아래와 같이 세팅해야 한다.

  • application.yml
spring:
  jpa:
    properties:
      hibernate.default_batch_fetch_size: 1000
  • application.properties
spring.jpa.properties.hibernate.default_batch_fetch_size=1000

하지만 어떤 이유에선지 아래 테스트 코드에서 여전히 MultipleBagFetchException 예외가 발생했다. 현재까지 추측으론 위에서 언급한 중첩된 구조때문에 예외가 발생하는 듯 하다.

@RunWith(SpringRunner.class)
@SpringBootTest
public class SpotServiceTest {

    @Autowired
    SpotService spotService;

    @Test
    public void findAllTest() throws Exception {
        //given
        Page<SpotDto> spotList = spotService.getSpotListBy("", new ArrayList<>(), 0);

        //then
        assertThat(spotList.getNumberOfElements()).isEqualTo(8);
    }
}

연관관계 변경 (List -> Set)

리스트 구조에서 배치 사이즈를 설정해주었음에도 여전히 MultipleBagFetchException이 발생하기 때문에 다른 방법을 찾아야 했다.
이후 새로운 브랜치를 만들어서 기존 List로 설정된 엔티티들의 연관관계를 Set으로 변경해주는 작업을 진행했다. 이미 application-db.yml 파일에서 default_batch_fetch_size: 1000으로 해줬기 때문에 설정 파일 작업은 하지 않았다.

public class Spot {

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

    @Column(name = "spot_name", nullable = false)
    private String name;

    @Column(name = "spot_city", nullable = false)
    private String city;

    @Column(name = "spot_address")
    private String address;

    @Column(name = "spot_description", columnDefinition = "TEXT")
    private String description;

    @OneToMany(mappedBy = "spot")
    @Builder.Default
    private Set<SpotImage> spotImageURLs = new LinkedHashSet<>();

    @OneToMany(mappedBy = "spot")
    @Builder.Default
    private Set<Review> reviews = new LinkedHashSet<>();

    @Column(name = "spot_review_cnt", nullable = false)
    private int reviewCnt = 0;
}
public class Review {

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

    @Column(name = "review_title", nullable = false)
    private String title;

    @Column(name = "review_content", nullable = false, columnDefinition = "TEXT")
    private String content;

    @Column(name = "review_travel_start_date")
    private LocalDateTime start_date;

    @Column(name = "review_travel_end_date")
    private LocalDateTime end_date;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "spot_id")
    private Spot spot;

    @OneToMany(mappedBy = "review")
    @Builder.Default
    private Set<ReviewTag> reviewTags = new LinkedHashSet<>();
}
public class Tag {

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

    @Column(name = "tag_name")
    private String name;

    @OneToMany(mappedBy = "tag")
    @Builder.Default
    private Set<ReviewTag> reviewTags = new LinkedHashSet<>();
}

이후 테스트 코드를 다시 실행해보니 정상적으로 테스트를 통과할 수 있었다. 쿼리문 또한 제대로 조인이 걸려서 조회되는 모습.

yml 파일에서 설정한 배치 사이즈도 정상적으로 동작하는 모습.

결론

  • jpa의 default 메서드 findAll()을 사용할 경우 N+1 문제가 발생한다. 현 프로젝트에서는 모든 여행지를 조회 할 때 1개의 여행지 조회 쿼리문이 리뷰, 리뷰_태그, 여행지 이미지 조회 쿼리문을 실행시킨다.

  • 이러한 N+1 문제를 해결하기 위해 @EntityGraph를 도입했다. 하지만 @OneToMany 관계가 2개 이상일 경우 MultipleBagFetchException이 발생한다.

  • 예외를 해결하는 방법은 application.yml파일에서 default_batch_fetch_size: 1000 설정을 추가하면 된다. 하지만 현재 구조(중첩된 fetch join 필요)에서는 여전히 MultipleBagFetchException이 발생한다.

  • 모든 엔티티의 연관관계의 자료구조를 List -> Set 으로 변경하는 작업을 진행했다. 이후 정상적으로 리뷰와 여행지 이미지 엔티티에 대해서 fetch join 거는 모습을 확인했다.

  • MultipleBagFetchException 케이스 (default_batch_fetch_size: 1000 설정 상태)
    엔티티 연관 관계 / 중첩된 구조 / 결과
    List + 중첩된 구조 = fetch join 실패 (예외 발생)
    Set + 중첩된 구조 = fetch join 성공 (예외 발생 안함. 리뷰와 여행지 이미지만 조인)

0개의 댓글