노래 데이터를 조회하는데 발생한 N + 1 해결하기

SeokHwan An·2023년 12월 1일
0

shook

목록 보기
9/15

문제 사항

저희 서비스는 JPA를 이용하고 있습니다. Song의 상세페이지를 조회하게 되면 KillingPart에 대한 정보와 KillingPart의 좋아요 수가 필요한데 이 때 의도하지 않았던 Query가 발생하는 문제가 발생했습니다.

현재 위의 사진과 같이 Song과 KillingPart는 1:N(@OneToMany)의 연관관계를 가지고 있고 KillingPart와 Like 역시 1:N(@OneToMany)의 연관관계를 가지고 있습니다. (이해하기 쉽게 축소해서 표현했습니다.)

이와 같은 상황에서 오른쪽 사진과 같이 노래의 듣기 기능을 위해 노래 상세 페이지에 들어가게 되면 노래 정보 뿐만 아니라 킬링파트에 대한 상세 정보 및 좋아요 수도 필요했고 이를 불러오기 위해 DB에 요청을 보낸 상황입니다.

다시 정리하면 다음과 같습니다.

즉, 하나의 Song의 상세페이지를 조회하는 경우에 총 5개의 쿼리가 발생했습니다.

Song 상페 페이지 요청 = Song정보 query 1회 + KillingPart정보 query 1회 + KillingPart별 Like정보 3회

문제 상황 분석하기

저희 팀이 접한 문제는 JPA의 N+1 문제였습니다. 이 문제가 발생한 이유는 Song을 조회할 때 Song과 연관된 KillingPart와 Like를 한번에 불러오지 않아 KillingPart와 Like 정보가 필요한 경우 다시 DB로 요청을 보내기 때문입니다.

JPA의 경우 Lazy로 연관관계를 맺으면 해당 객체를 우선 Proxy 객체로 불러오기 때문입니다.(@OneToMany의 경우 기본적으로 Lazy Fetch Type 그렇기에 Proxy 객체에서 필요한 정보가 있을 때에는 DB로 요청을 보내서 원하는 정보를 가져와야 했습니다.

따라서 의도하지 않았던 Query 요청(KillingPart를 불러오는 요청)이 발생하는 문제를 해결하기 위해서 Song을 불러올 때 KillingPart과 Like 대한 정보도 함께 불러와야 했습니다.

문제 해결하기

N + 1 문제를 해결하는 방안은 여러가지가 있는데 그 중에서도 저희 팀은 Fetch Join을 이용해서 N + 1을 해결하는 방안을 택했습니다.

Fetch Join 이용하기

Fetch Join은 해당 엔티티를 DB에서 불러올 때 연관된 엔티티 정보까지 불러오는 방법으로 @Query를 활용해 직접 쿼리를 작성해서 해결하는 방법입니다.

이 때 아래와 같이 MultipleBagFetchException이 발생했습니다.

위의 에러가 발생한 이유는 Song을 불러오는데 두 개 이상의 Bag 타입을 불러오는 과정에서 문제가 발생했기 때문입니다. (Bag 타입은 List 처럼 중복을 허용하지만 set 처럼 순서를 보장하지 않는 타입으로 ArrayList가 영속화가 되면 hibernate는 PersistentBag로 관리합니다.)

그러면 왜 JPA는 두개 이상에 PersistentBag 타입을 Fetch Join을 할 수 없는 것일까요?

Fetch Join의 경우 카사디안 곱으로 동작하여 중복된 데이터들이 생기는데 두 개 이상의 Collection을 Fetch하는 경우 중복된 데이터에 대한 엔티티 매핑 처리를 하지 못해서 발생하는 문제였습니다.

MultipleBagException을 해결하는 방법은 여러가지가 있는데 저희 팀에서는 KillingPart의 Like의 ArrayList를 Set으로 변경을 했습니다. 그 이유는 다음과 같습니다.

  • like는 회원이 추가하는 것이고 한 회원이 같은 KillingPart에 중복된 like를 허용하지 않는다.
  • KillingPart의 경우 많은 정보가 쌓일 수 있기 때문에 BatchSize로 In절을 이용해서 N + 1 문제를 해결하게 되는 경우 추가적으로 발생하는 query를 예측하기 어렵다고 판단했습니다. (대부분의 DB가 in절에 1000개가 넘는 경우를 허용하지 않으며, 1000개 이상 넘어가는 것을 허용하더라도 DB라도 성능을 파악해야했습니다.)

다시 Fetch Join을 통해 노래 정보를 불러오면 다음과 같은 query를 볼 수 있습니다.

Song을 불러올 때 KillingPart 정보와 Like 정보를 잘 불러오는 것을 확인할 수 있습니다.

이를 통해서 Song 한 개를 상세조회 할 때 발생하는 쿼리의 요청 횟수를 **5회 → 1회**로 줄일 수 있었습니다.

추가로 더 생각해보기

이번에 N + 1을 해결하면서 JPA는 왜 @OneToMany의 경우 기본 FetchType을 Lazy로 설정해 연관된 엔티티 정보가 필요할 때 불필요한 Query가 나가는 것을 막았을까?에 대해 생각을 해보았습니다.

아무래도 가장 큰 이유는 해당 엔티티를 불러오는 과정에서 연관된 엔티티의 정보가 필요하지 않은 상황을 대비한 것 같습니다. 저희 서비스에서도 이와 같은 상황이 있었습니다.

이는 현재 저희 서비스의 메인 페이지로 일련의 Song 정보들을 보여주고 있습니다. 이 페이지에서는 KillingPart에 대한 정보가 필요없었기에 Song을 불러오는 과정에서 굳이 KillingPart 테이블과 join과정이 필요 없었습니다.

이런 상황과 같이 JPA는 LAZY로 연관관계를 설정하면 불필요하게 연관관계를 가지는 데이터를 불러오지 않도록 설계가 된 것 같습니다.

0개의 댓글