프로젝트 기능
나의 MySongSpace의 기능은 필터 기능이다
여기서 필터기능이란
사용자가 원하는 장르,분위기,검색어를 통해서 동적으로 데이터를 조회하는 기능을 말한다.
동적 쿼리를 적용하기 위한 QueryDsl과
자바의 Page 인터페이스를 통해서
Offset 기반 페이징을 구현하였다.
기본적으로 Track를 Genre와 Mood의 관계 설명하자면
@OneToMany(mappedBy = "track", cascade = CascadeType.ALL)
private List<TrackGenre> genres = new ArrayList<>();
@OneToMany(mappedBy = "track", cascade = CascadeType.ALL)
private List<TrackMood> moods = new ArrayList<>();
Track 은 여러 Genre와 Mood를 가질 수 있다.
Genre와 Mood는 자바의 enum 타입으로 구현되어져 있는데
엔티티로 풀어서 하는 것이 아닌 처음에는
@ElementCollection 을 이용하여 값 타입 컬렉션을 만들었다가
1 : 다 관계로 풀어서 리팩토링 하였다. 이에 관해
Lazy Loading의 비효율과 관련하여 추후 포스팅 하도록 하겠다
(조회나 관리에 있어서 문제점 이슈)

여기서는 TrackGenre와 TrackMood는 enum 타입을 기준으로
1대다 관계로 풀어서 관리할 수 있도록 만들어 놓았다.
String필드로 구분자를 이용해 저장하는 방법을 이용할 수 있었지만
검색 조건과 확장성을 고려하여 엔티티로 관리하는 것이 더 효율적인 방향이라고 생각했다.
TrackGenre 엔티티
@Entity
@NoArgsConstructor
@Getter
public class TrackGenre {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "track_id")
private Track track;
@Enumerated(EnumType.STRING)
@Column(nullable = false)
private Genre genre;
}
TrackService 의 getAllTracks
public List<TrackResponseDTO> getAllTracks(int page, String sortBy, List<Mood> moods, List<Genre> genres, String keyword) {
Pageable pageable = PageRequest.of(page, 10, Sort.by(getSortDirection(sortBy)).descending());
Page<Track> trackPage = trackRepository.findTracksWithFilters(moods, genres, sortBy, keyword, pageable);
return trackPage.stream()
.map(track -> TrackResponseDTO.toResponse(track))
.collect(Collectors.toList());
}
사용자가 원하는 장르리스트와 분위기리스트 그리고 정렬방법과 검색 조건에 관하여 조회하는 쿼리를 보도록하자
문제 상황
밑에는 QueryDsl을 이용해서 BooleanBuilder 를 이용하여 동적인 조건을 연결하여 조건에 부합하는 Track을 찾는 과정이다.
저기서 1+N 문제를 방지하고 DTO 에 해당 트랙의 genres와 moods를 같이 보내줬어야 했기 때문에
TrackGenre,TrackMood 를 연관된 엔티티 (컬렉션을 조회) 패치조인을 이용하여 가져오는 방식을 생각했다.
TrackRepositoryImpl 의 findTracksWithFilters
@Override
public Page<Track> findTracksWithFilters(List<Mood> moods, List<Genre> genres, String sortBy, String keyword, Pageable pageable) {
BooleanBuilder booleanBuilder = new BooleanBuilder();
addMoodsFilter(booleanBuilder, moods);
addGenresFilter(booleanBuilder, genres);
addKeywordFilter(booleanBuilder, keyword);
List<Track> content = queryFactory.selectFrom(qTrack)
.join(qTrack.member, qMember).fetchJoin()
.join(qTrack.genres, qTrackGenre).fetchJoin()
.join(qTrack.moods, qTrackMood).fetchJoin()
.where(booleanBuilder)
.orderBy(orderMethod(sortBy))
.offset(pageable.getOffset())
.limit(pageable.getPageSize())
.fetch();
JPAQuery<Long> countQuery = queryFactory.select(qTrack.count())
.from(qTrack)
.where(booleanBuilder);
return PageableExecutionUtils.getPage(content, pageable, () -> countQuery.fetchOne());
}
조건부 역할을 하는 두 메서드
private BooleanBuilder addMoodsFilter(BooleanBuilder booleanBuilder, List<Mood> moods) {
if (moods != null && !moods.isEmpty()) {
moods.forEach(mood -> booleanBuilder.and(qTrackMood.mood.eq(mood)));
}
return booleanBuilder;
}
private BooleanBuilder addGenresFilter(BooleanBuilder booleanBuilder, List<Genre> genres) {
if (genres != null && !genres.isEmpty()) {
genres.forEach(genre -> booleanBuilder.and(qTrackGenre.genre.eq(genre)));
}
return booleanBuilder;
}
2가지의 문제점
- org.hibernate.loader.MultipleBagFetchException 예외 발생
- 조건부 문제
MultipleBagFetchException
Hibernate에서 여러 개의 @OneToMany 관계를 Fetch Join으로 한 번에 가져올 때 발생하는 예외
이 문제는 Hibernate가 중복 데이터 처리를 제대로 할 수 없기 때문에 발생한다.
중복 데이터 처리?
실제로 날라온 쿼리를 fetch join을 제거하고 join으로만 DB Console에다가 쿼리를 날려보았다..

이게 무슨?
보면은 중복데이터들이 겹쳐서 나오는 것을 볼 수 있다.

1이 기준이 아닌 다를 기준으로 ROW가 형성되어있는 것을 볼 수 있다!!
밑에와 같은 테이블에 데이터들이 들어가 있다고 가정해보자
Track 테이블 (트랙)
| TrackId | Title |
| 1 | Song 1 |
| 2 | Song 2 |
TrackGenre 테이블 (트랙 장르)
| TrackId | genre |
| 1 | ROCK |
| 1 | POP |
| 2 | JAZZ |
TrackMood 테이블 (트랙 무드)
| TrackId | mood |
| 1 | HAPPY |
| 1 | SAD |
| 2 | CHILL |
위와 같이 테이블이 구성되어져 있다면 반환되는 결과 값은 어떻게 나오는가?
| TrackId | title | genre | mood |
| 1 | Song1 | ROCK | HAPPY |
| 1 | Song1 | ROCK | SAD |
| 1 | Song1 | POP | HAPPY |
| 1 | Song1 | POP | SAD |
| 2 | Song2 | JAZZ | CHILL |
만약에 내가 Fetch Join으로 1 : 다 : 다 를 묶어서 표현을 했다면 이런식으로 데이터를 가져왔을 가능성이 높다.
그렇게 된다면 페이징 처리에 대한 문제 뿐만 아니라. 데이터의 참조 무결성과
성능적인 부분에서도 문제가 나올 가능성이 있다.
아까 발생했던 MultipleBagFetchException 는 데이터가 카테시안 곱 형태로 늘어나면서
이를 List로 매핑하려 할 때 혼란이 발생하는데 Hibernate에서 이를 방지하기 위해 예외를 내준 상황이다.
조건부 문제
이는 간단하다 AND 조건으로
genre=? AND genre=? AND mood=? AND mood=?
내가 잘못된 조건부는 리스트가 아닌 각각의 하나의 값에 부여하는 조건부 였고
genre와 mood 가 다중리스트에 부합한 조건부가 아니였다..
해결한 방법
기본적으로 1 : 다 조회는 페이징 처리하는데 있어서 제약사항이 있기 때문에
TrackGenre와 TrackMood를 join을 해서
가져오는 경우는 배제하고 생각을 해보았다.
결국
TrackGenre에서 조건에 부합하는 TrackId와
TrackMood에서 조건에 부합하는 TrackId를 가지고 와서
해당하는 Track을 찾는 것이 좋은 방향성이라고 생각 했다..
List<Track> content = queryFactory.selectFrom(qTrack)
.join(qTrack.member, qMember).fetchJoin()
.where(booleanBuilder)
.orderBy(orderMethod(sortBy))
.offset(pageable.getOffset())
.limit(pageable.getPageSize())
.fetch();
아까와는 달리 N 에 매칭 되는 엔티티는 모두 join에서 제거하였고,
if (moods != null && !moods.isEmpty()) {
booleanBuilder.and(qTrack.trackId.in(
JPAExpressions.select(qTrackMood.track.trackId)
.from(qTrackMood)
.where(qTrackMood.mood.in(moods))
.groupBy(qTrackMood.track.trackId)
.having(qTrackMood.mood.countDistinct().eq((long) moods.size()))));
}
정확하고 명확한 조건 필터링을 구현하기 위해서 서브 쿼리 도입
긴 말하지 않고 내가 작성했던 코드에 대해서 세세하게 설명하도록 하겠다!!
JPAExpressions 를 사용하여 QueryDsl에 서브쿼리를 사용할 수 있도록 하였고,
where 절
qTrackMood.mood.in(moods)는 moods 리스트에 포함된 무드들을 검색하는 조건이다.
즉, TrackMood의 mood 값이 moods 중 하나인 경우를 필터링.
groupBy와 having
groupBy(qTrackMood.track.trackId)를 통해 각 trackId 별로 TrackMood를 그룹화.
having(qTrackMood.mood.countDistinct().eq((long) moods.size())) 조건을 통해
Track이 주어진 moods 리스트에 있는 모든 무드를 포함하는지 확인.
countDistinct()는 고유한 무드의 개수를 계산하여, moods의 크기와 동일한지 비교.
왜 countDistinct()를 사용하였는가?
| TrackId | MOOD |
| 1 | HAPPY |
| 1 | HAPPY |
| 1 | SAD |
count() 사용시 3
| TrackId | MOOD |
| 1 | 3 |
countDistinct() 사용시 2
| TrackId | MOOD |
| 1 | 2 |
혹시 같은 Mood나 Genre에 들어가 있는 경우를 방지하기 위해서 countDistinct()를 사용하였다.
Genre 도 위와 같은 방식으로 구현 하였기에 따로 설명은 하지 않겠다!!

오랜만에 짰던 쿼리라 간단한 것 같은데도 시간이 좀 걸렸다 ㅜㅜㅜ
Genre와 Mood에 대한 검색 조건부를 서브 쿼리를 통해 해당하는 TrackId를 가져온 후 AND 절을 통해 두 조건에 부합하는 Track 리스트를 찾을 수 있도록 구현하였다.
어플리케이션 재실행 후PostMan을 통해서 쿼리를 날려보았다!!


원하는 대로 데이터가 필터링 되어 반환되는 것을 볼 수 있었다..
하지만 오른쪽 그림을 보면 1+N 문제가 해결되지 않았다..
1개의 쿼리를 날리면 나온 데이터의 N개만큼 추가 쿼리가 나가는 상황이다.
심지어 Genre와 Mood 두개 쿼리가 더 나가는 상황이라서
1개의 쿼리를 통해 10개의 Track 객체가 나온다면 총( (10*2)개+1개)
21개의 쿼리가 나간다.
해결한 방법
Hibernate의 default_batch_fetch_size 설정
배치 단위로 가져오도록 Hibernate에 지시 한다.

적절한 Batch_Size 를 통해 네트워크 요청에 대한 부하를 줄일 수 있다.
적절한 Batch_Size란 뭔데?
만약에 size를 10이라고 가정해보자 근데 내가 가져올 데이터의 개수가 50개 라고 가정한다면
IN (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) (1~10번째 trackId)
IN (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) (11~20번째 trackId)
IN (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) (21~30번째 trackId)
IN (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) (31~40번째 trackId)
IN (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) (41~50번째 trackId)
5개의 쿼리로 분할해서 데이터를 가져온다.
그러면 무조건 size를 크게 하면 좋은거 아니야?
BatchSize 캐싱 최적화 전략에 관해서 좋은 블로그가 있어서 링크를 걸어두겠다!!
https://42class.com/dev/jpa-batchsize/
JPA N+1튜닝과정에서 선언한 배치 사이즈와 다르게 쿼리 분할 되어 수행되는 이유
42class.com
간단하게 요약하자면
적절한 배치 사이즈 설정 전략을 통해서
상황에 맞게 다르게 설정해야 한다는 것을 알고 가는 것이 우선이라고 생각한다!!!
나의 생각
데이터의 개수가 1000개 이상 이라면 그냥 BatchSize를 100~1000 사이에서 설정
만약 1000개 미만이고 내가 설정할려고 하는 BatchSize보다
데이터의 개수가 작다고 가정한다면 데이터개수/2 만큼이 적절하다고 생각한다!!
Batchsize 240이라고 가정하고 480개의 데이터가 있다고 생각해보자
총 2개의 쿼리로 끝날 수 있지만 (240,240)
Batchsize가 1000이라면 (250,250,30) in 쿼리가 3개의 쿼리가 나간다
이 이야기가 이해가 안된다면 저 위의 포스팅을 보고오자!!!!


21개의 쿼리에서 총 3개의 쿼리로 개선
실제 성능 차이를 봐볼까?
10000개의 데이터가 있는 상황

기존 지연 로딩으로 1+N 문제가 해결되지 않았을 때
실행시간 : 27.41s

BatchSize 를 지정하고 1+N 문제 해결
실행시간 : 0.132초 (132ms)
'프로젝트' 카테고리의 다른 글
| 안전한 파일 업로드에 대한 처리란 무엇일까? chapter 2 (0) | 2024.11.27 |
|---|---|
| 좋아요 버튼의 숨겨진 딜레마: @Transactional과 synchronized가 함께 풀지 못한 동시성 이슈 (0) | 2024.11.26 |
| Offset 페이징에서 효율적인 대댓글 처리 방법 (0) | 2024.11.23 |
| 안전한 파일 업로드에 대한 처리란 무엇일까? chapter 1 (0) | 2024.11.10 |
| 경광등 테스트 프로그램: 실전 프로젝트 경험 공유 (0) | 2024.01.30 |