개요
이번 시간에는 Filmeet 프로젝트에서 동적 조건 처리를 위해 Querydsl을 어떻게 사용했는지에 대해 소개하려 합니다.
문제 상황
예를 들어 상황에 따라 조건문이 생성되어야 한다고 보겠습니다.
- 영화 제목이 오면 where title = title
- 연령 제한이 오면 where filmRatings = filmRatings
- 장르가 오면 where genre = genre
- 2개 이상이 오면 모두 포함 where title = title and filmRatings = filmRatings and genre = genre
즉, 파리미터가 어떻게 오는지에 따라 where의 조건이 변경되는 것입니다.
이를 해결하기 위한 방법으로 BooleanBuilder를 사용하는 걸 자주 봅니다.
사용한 코드는 아래와 같습니다.
@Override
public List<Movie> findMoviesByDynamicConditions(String title, FilmRatings filmRatings, String genre) {
BooleanBuilder builder = new BooleanBuilder();
// 제목 조건 추가
if (!StringUtils.isEmpty(title)) {
builder.and(movie.title.eq(title));
}
// 연령 제한 조건 추가
if (filmRatings != null) {
builder.and(movie.filmRatings.eq(filmRatings));
}
// 장르 조건 추가
if (!StringUtils.isEmpty(genre)) {
builder.and(movie.genre.eq(genre));
}
// 쿼리 실행
return queryFactory
.selectFrom(movie)
.where(builder)
.fetch();
}
if문으로 필요한 부분만을 BooleanBuilder에 추가하면서 쿼리를 만들었습니다.
이 방식의 문제점은 무엇일까요?
where문의 조건을 한눈에 보기 어렵습니다.
즉, 전혀 쿼리 형태를 예측하기가 어렵다는 것입니다.
지금은 단순한 쿼리이지만, 조금만 조건이 까다로워지면 추측하기도 힘든 쿼리가 될 것입니다.
해결
Querydsl의 where에 조건문을 쓰되 파라미터가 비어있다면, 조건절에서 생략되길 바랍니다.
Querydsl에서는 이럴 때 대비해서 BooleanExpression을 준비했습니다.
BooleanExpression은 where에서 사용할 수 있는 값인데, 이 값은 ,를 and 조건으로 사용합니다.
여기에 Querydsl의 where는 null이 파라미터로 올 경우 조건문에서 제외합니다.
2가지 속성을 이용해서 아래와 같이 코드를 작성할 수 있습니다.
@Override
public List<Movie> findMoviesByDynamicConditions(String title, FilmRatings filmRatings, String genre) {
return queryFactory
.selectFrom(movie)
.where(eqTitle(title),
eqFilmRatings(filmRatings),
eqGenre(genre))
.fetch();
}
// 제목 조건
private BooleanExpression eqTitle(String title) {
return StringUtils.isEmpty(title) ? null : movie.title.eq(title);
}
// 연령 제한 조건
private BooleanExpression eqFilmRatings(FilmRatings filmRatings) {
return filmRatings == null ? null : movie.filmRatings.eq(filmRatings);
}
// 장르 조건
private BooleanExpression eqGenre(String genre) {
return StringUtils.isEmpty(genre) ? null : movie.genre.eq(genre);
}
BooleanExpression을 리턴하는데, 각 메서드에서 조건이 맞지 않으면 null을 리턴합니다.
null을 리턴하니 where에서는 상황에 따라 조건문을 생성하게 됩니다.
첫 번째 코드에 비해 굉장히 명확하게 쿼리가 예측 가능해졌습니다.
Querydsl 설정하기
실제 프로젝트에 어떻게 적용했는지 코드를 소개하겠습니다.
QueryDsl은 JPA에서 공식적으로 제공하는 JPQL 빌더가 아니기 때문에 따로 구성해주어야 합니다.
build.gradle
/* Querydsl */
implementation 'com.querydsl:querydsl-jpa:5.0.0:jakarta'
annotationProcessor "com.querydsl:querydsl-apt:${dependencyManagement.importedProperties['querydsl.version']}:jakarta"
annotationProcessor "jakarta.annotation:jakarta.annotation-api"
annotationProcessor "jakarta.persistence:jakarta.persistence-api"
- build.gradle에 Querydsl 관련한 설정을 추가해 줍니다.
JPAQueryFactory 빈 등록
@Configuration
public class QuerydslConfig {
@Bean
JPAQueryFactory jpaQueryFactory(EntityManager em) {
return new JPAQueryFactory(em);
}
}
- QueryDSL을 사용하기 위한 JPAQueryFactory를 빈으로 등록해 줍니다.
프로젝트를 빌드하면 generated에 Q-Class가 생성됩니다.
MovieRepository
public interface MovieRepository extends JpaRepository<Movie, Long>, MovieCustomRepository {
...
}
MovieCustomRepository
public interface MovieCustomRepository {
Slice<MoviesSearchByGenreResponse> searchMoviesByGenre(List<GenreType> genreTypes, Pageable pageable);
}
MovieCustomRepositoryImpl
@RequiredArgsConstructor
public class MovieCustomRepositoryImpl implements MovieCustomRepository {
private final JPQLQueryFactory queryFactory;
@Override
public Slice<MoviesSearchByGenreResponse> searchMoviesByGenre(List<GenreType> genreTypes, Pageable pageable) {
...
}
}
Spring Data JPA에서 기본적으로 제공하는 네임드 쿼리 외의 자신만의 커스텀 쿼리들을 정의하고 싶으면 다음과 같이 하면 됩니다.
- 새로운 인터페이스를 만들고 해당 인터페이스의 구현체를 {repository 명} + Impl로 만든다.
- Repository에서 해당 커스텀 인터페이스를 상속받는다.
새로운 커스텀 인터페이스의 구현체의 이름을 {repository명} + Impl로 구성하면 스프링이 스캔을 통해 찾아서 빈으로 등록해 준다.
- ⚠️ import static을 통해서 가져오면 간편하게 사용할 수 있다.
프로젝트에 적용한 Querydsl
처음에는 여러 개의 장르에 속할 수 있고, 특정 연령 제한과 특정 평균 평점 이상인 영화만 조회하는 조건이 요구되었습니다. 이러한 동적 조건 처리를 위해 Querydsl을 도입했지만, 프로젝트를 진행하는 과정에서 "여러 개의 장르에 속하는 영화만 조회"하는 것으로 요구사항이 변경되었습니다. 사실, 이런 경우 Querydsl 없이도 IN 연산만으로 충분히 처리할 수 있습니다. 하지만 이미 Querydsl 설정을 완료한 상태였고, 향후 동적 조건이 다시 추가될 가능성을 고려해 Querydsl을 유지하는 방향으로 진행했습니다.
MovieCustomRepositoryImpl
@RequiredArgsConstructor
public class MovieCustomRepositoryImpl implements MovieCustomRepository {
private final JPQLQueryFactory queryFactory;
@Override
public Slice<MoviesSearchByGenreResponse> searchMoviesByGenre(List<GenreType> genreTypes, Pageable pageable) {
// 1. 영화 ID 목록 가져오기
List<Long> movieIds = queryFactory
.selectDistinct(movie.id)
.from(movie)
.join(movie.movieGenres, movieGenre)
.join(movieGenre.genre, genre)
.where(isNotDeleted().and(genreTypeIn(genreTypes)))
.offset(pageable.getOffset())
.limit(pageable.getPageSize() + 1)
.fetch();
if (movieIds.isEmpty()) {
return new SliceImpl<>(List.of(), pageable, false);
}
// 2. 영화별 데이터 및 장르 수집
List<Tuple> tuples = queryFactory
.select(
movie.id,
movie.title,
movie.posterUrl,
movie.releaseDate,
movie.runtime,
movie.likeCounts,
movie.ratingCounts,
movie.averageRating,
movie.filmRatings,
genre.genreType
)
.from(movie)
.join(movie.movieGenres, movieGenre)
.join(movieGenre.genre, genre)
.where(movie.id.in(movieIds))
.orderBy(movie.releaseDate.desc())
.fetch();
// 3. 영화별로 장르 리스트를 그룹화
Map<Long, MoviesSearchByGenreResponse> movieMap = new LinkedHashMap<>();
for (Tuple tuple : tuples) {
Long movieId = tuple.get(movie.id);
MoviesSearchByGenreResponse response = movieMap.computeIfAbsent(movieId,
id -> new MoviesSearchByGenreResponse(
id,
tuple.get(movie.title),
tuple.get(movie.posterUrl),
tuple.get(movie.releaseDate),
tuple.get(movie.runtime),
tuple.get(movie.likeCounts),
tuple.get(movie.ratingCounts),
tuple.get(movie.averageRating),
tuple.get(movie.filmRatings),
new ArrayList<>()
));
response.genreTypes().add(tuple.get(genre.genreType));
}
boolean hasNext = movieIds.size() > pageable.getPageSize();
return new SliceImpl<>(new ArrayList<>(movieMap.values()), pageable, hasNext);
}
// 동적 조건: 장르 필터링
private BooleanExpression genreTypeIn(List<GenreType> genreTypes) {
return (genreTypes == null || genreTypes.isEmpty()) ? null : genre.genreType.in(genreTypes);
}
}
MovieCustomRepositoryImpl 부분을 좀 더 상세히 설명하겠습니다.
Step 1 영화 ID 목록 가져오기
// 1. 영화 ID 목록 가져오기
List<Long> movieIds = queryFactory
.selectDistinct(movie.id)
.from(movie)
.join(movie.movieGenres, movieGenre)
.join(movieGenre.genre, genre)
.where(isNotDeleted().and(genreTypeIn(genreTypes)))
.offset(pageable.getOffset())
.limit(pageable.getPageSize() + 1)
.fetch();
if (movieIds.isEmpty()) {
return new SliceImpl<>(List.of(), pageable, false);
}
역할
- 영화 ID만 가져와서 페이징 처리합니다.
- 실제 영화 데이터를 가져오는 것은 두 번째 단계에서 이루어집니다.
동작
- select(movie.id): movie 테이블에서 영화 ID만 조회합니다.
- join: 영화와 장르 간의 관계를 연결합니다.
- movie.movieGenres로 영화-장르 관계(movie_genre)를 조인
- movieGenre.genre로 실제 장르(genre)를 조인
- where(genreTypeIn(genreTypes)):
- 사용자가 선택한 장르(예: ACTION, COMEDY)에 해당하는 영화만 필터링
- 예를 들어, 사용자가 'ACTION'을 선택했으면 해당 장르를 포함한 영화만 조회
- distinct():
- 동일한 영화가 여러 장르를 가지고 있어 중복될 수 있으므로, 영화 ID를 고유하게 만듭니다.
- offset 및 limit:
- 페이징 처리.
- offset: 몇 번째부터 데이터를 가져올지 결정.
- limit: 몇 개의 데이터를 가져올지 결정.
결과
- 페이징 된 영화 ID 목록(movieIds)이 반환됩니다.
- 예를 들어, movieIds = [1, 2, 3]처럼 영화 ID만 포함된 리스트가 반환됩니다.
- 만약 결과가 없으면 빈 페이지 객체를 반환합니다.
Step 2 영화별 데이터 및 장르 수집
List<Tuple> tuples = queryFactory
.select(
movie.id,
movie.title,
movie.posterUrl,
movie.releaseDate,
movie.runtime,
movie.likeCounts,
movie.reviewCounts,
movie.averageRating,
movie.filmRatings,
genre.genreType
)
.from(movie)
.join(movie.movieGenres, movieGenre)
.join(movieGenre.genre, genre)
.where(movie.id.in(movieIds))
.fetch();
역할
- Step 1에서 가져온 movieIds를 기준으로 영화 데이터를 조회하고, 각 영화에 연결된 장르를 함께 조회합니다.
동작
- select
- 영화 데이터(예: ID, 제목, 포스터 URL, 개봉일 등)와 각 영화의 장르(genre.genreType)를 모두 선택.
- where(movie.id.in(movieIds))
- Step 1에서 가져온 movieIds만 조회.
- 예를 들어, movieIds = [1, 2, 3]이면 이 영화들만 가져옵니다.
결과
- 데이터는 Tuple 형태로 반환됩니다.
- 예시 결과 데이터
- (1, "Inception", "/poster1.jpg", "2010-07-16", 148, 100, 50, 4.5, "PG-13", "ACTION")
- (1, "Inception", "/poster1.jpg", "2010-07-16", 148, 100, 50, 4.5, "PG-13", "SCI-FI")
- (2, "Dark Knight", "/poster2.jpg", "2008-07-18", 152, 200, 80, 4.9, "PG-13", "ACTION")
- 여기서 영화 ID 1이 ACTION과 SCI-FI 두 장르를 가지므로 데이터가 중복됩니다.
step1과 step2를 나눈 이유
step1과 step2를 나눈 이유는 영화가 여러 장르에 속할 수 있기 때문입니다.
장르 필터링을 위해 IN 연산을 사용하면, 하나의 영화가 여러 번 조회될 수 있습니다.
예를 들어, ACTION과 SCI-FI 장르로 조회할 경우
- ACTION 장르에서 영화 ID 1이 조회되고,
- SCI-FI 장르에서도 영화 ID 1이 다시 조회됩니다.
이처럼 중복된 데이터로 인해 페이징 처리가 제대로 동작하지 않으며,
결과적으로 성능 저하와 데이터 불일치 문제가 발생할 수 있습니다.
이를 해결하기 위해:
- step1: 여러 장르에 속하는 영화 ID를 조회하고, distinct를 사용해 중복을 제거합니다.
- step2: step1에서 조회된 영화 ID를 사용해 해당 영화의 실제 데이터를 조회합니다.
이 방식으로 중복 문제를 해결하고, 페이징 처리를 할 수 있습니다.
좀 더 상세히 설명하겠습니다.
step1에서 distinct를 사용
영화가 여러 장르를 가지면, 결과적으로 영화 데이터가 중복되기 때문에 distinct를 사용했습니다.
예시
영화 ID가 1이고, 해당 영화가 ACTION, DRAMA, HORROR 장르를 가질 경우, join을 통해 조회하면 다음과 같은 결과가 나올 수 있습니다
movie_id | genre_type
---------|-----------
1 | ACTION
1 | DRAMA
1 | HORROR
- 이렇게 동일한 movie_id가 여러 번 나타나므로, 페이징 처리를 위해 영화 ID를 고유하게 만드는 distinct가 필요합니다.
결과
distinct를 사용하면 movie_id만 고유하게 반환됩니다:
movie_id
---------
1
step1에서 페이징 처리
step1이 없는 경우
- 만약 step1 없이 바로 step2에서 페이징을 처리하면, 모든 영화-장르 데이터가 페이징의 대상이 됩니다.
- 예를 들어, 영화 1이 ACTION, DRAMA, HORROR 장르를 가진다면, offset과 limit이 다음과 같이 동작할 수 있습니다:
- 이렇게 되면, 영화 ID별로 데이터를 그룹화했을 때, 실제로 필요한 페이징 된 결과를 얻지 못하게 됩니다.
offset = 0, limit = 3
result: (1, ACTION), (1, DRAMA), (1, HORROR)
step1이 있는 경우
- step1에서는 영화 ID만 페이징 처리하므로, 정확한 페이징 결과를 보장합니다.
- 이후, step2에서 페이징 된 영화 ID를 기반으로 필요한 데이터만 조회합니다.
결론
step1과 step2를 분리하면, 페이징 처리의 정확성을 보장하면서 불필요한 데이터를 처리하지 않아 성능을 최적화할 수 있습니다.
Step 3 영화별로 장르 리스트를 그룹화
Map<Long, MoviesSearchByGenreResponse> movieMap = new LinkedHashMap<>();
for (Tuple tuple : tuples) {
Long movieId = tuple.get(movie.id);
MoviesSearchByGenreResponse response = movieMap.computeIfAbsent(movieId,
id -> new MoviesSearchByGenreResponse(
id,
tuple.get(movie.title),
tuple.get(movie.posterUrl),
tuple.get(movie.releaseDate),
tuple.get(movie.runtime),
tuple.get(movie.likeCounts),
tuple.get(movie.reviewCounts),
tuple.get(movie.averageRating),
tuple.get(movie.filmRatings),
new ArrayList<>() // 빈 리스트 초기화
));
response.genreTypes().add(tuple.get(genre.genreType));
}
역할
- Tuple 데이터를 영화 ID별로 그룹화하여 각 영화가 모든 장르를 가지도록 처리합니다.
동작
- Map<Long, MoviesSearchByGenreResponse>
- 영화 ID를 키로 하고, 영화 데이터를 값으로 저장하는 맵입니다.
- 영화 데이터가 중복되지 않도록 computeIfAbsent를 사용합니다.
- computeIfAbsent
- 영화 ID가 맵에 없으면 새로운 MoviesSearchByGenreResponse 객체를 생성해 저장합니다.
- 이미 존재하면 기존 객체를 반환합니다.
- response.genreTypes().add(...)
- Tuple에서 genre.genreType(영화 장르)을 가져와서 해당 영화의 장르 리스트(genreTypes)에 추가합니다.
결과
- 맵에는 영화별로 모든 데이터를 포함한 MoviesSearchByGenreResponse 객체가 저장됩니다.
- 예:
- Key: 1
- Value: MoviesSearchByGenreResponse 객체
- movieId: 1
- genreTypes: ["ACTION", "SCI-FI"]
- Value: MoviesSearchByGenreResponse 객체
- Key: 2
- Value: MoviesSearchByGenreResponse 객체
- movieId: 2
- genreTypes: ["ACTION"]
- Value: MoviesSearchByGenreResponse 객체
- Key: 1
Querydsl은 조건에 들어가는 값이 null이면, where 절이 자동으로 추가되지 않습니다. 반대로, 실제 값이 존재할 경우 where 절이 동적으로 생성됩니다. 이 동작을 확인하기 위해, Postman을 사용해 장르 조건을 포함하거나 제외하여 where 절이 올바르게 생성되는지 직접 테스트해 보겠습니다.
장르 조건을 넣으면 where문이 잘 추가됨
장르 조건 없이 보내면 아래 출력된 쿼리를 보면 where 조건이 없음
마무리하며
참고
https://jojoldu.tistory.com/394
'프로젝트 > Filmeet' 카테고리의 다른 글
콘텐츠 기반 추천과 유사 사용자 기반 추천을 결합한 하이브리드 추천 시스템 만들기 (3) | 2024.12.19 |
---|---|
Redis 분산 락을 활용하여 동시성 문제 해결하기 (0) | 2024.12.04 |
@Query with "not in" not work with empty List parameter (0) | 2024.12.02 |
MultipleBagFetchException - 두 개 이상의 OneToMany 관계에서 N+1 문제 최적화하기 (0) | 2024.11.22 |
Filmeet 컬렉션 기능 (0) | 2024.11.21 |