본문 바로가기
우아한테크코스/레벨 3 - 프로젝트

[JPA] QueryDSL로 리스트 여러개 있는 데이터 가져오기

by shyun00 2024. 8. 18.

글을 시작하기에 앞서, 이번 글은 QueryDSL 적용에 관한 내용만 정리하였다. 자세한 원리에 대한 내용은 다루고 있지 않으니 해당 부분은 다른 글을 참조하기를 바란다. (학습중인 내용이라 일부 잘못된 내용이 있을 수 있습니다! 혹시 있다면 말씀 부탁드립니다.)

 

프로젝트를 하면서 데이터베이스에서 필요한 데이터를 찾아오는 로직을 작성하게 되었다.

(현재 만들고 있는 애플리케이션은 간단히 말하면 레시피 SNS이다.)

 

레시피를 조회하면 해당 레시피가 어떤 카테고리에 속해있는지, 어떤 재료들이 필요한지 관련 데이터도 조회해야한다.

우리 팀의 ERD를 살펴보면 다음과 같다.

레시피 하나를 찾아오기 위해서는 회원테이블, 카테고리(중간테이블:카테고리 레시피), 재료(중간테이블: 재료 레시피) 총 6개 테이블의 데이터가 필요하다. 🤦🏻‍♀️

 

양방향 연관관계가 설정되어있지 않고, 우선 빠르게 구현하기 위해 아래와 같이 무지성 Join을 통해 데이터를 가져온 후 변형해서 사용하고 있었다. JPQL은 참조하는 데이터를 List 형태로 가져올 수가 없어서, 이렇게 냅다 가져온 데이터를 서비스 클래스에 RecipeId로 그룹화 한 후 category와 ingredient를 List로 바꿔서.....🤔

@Query("""
        SELECT new net.pengcook.recipe.dto.RecipeDataResponse(r, c, i, ir)
        FROM Recipe r
        LEFT JOIN CategoryRecipe cr ON cr.recipe = r
        LEFT JOIN Category c ON cr.category = c
        LEFT JOIN IngredientRecipe ir ON ir.recipe = r
        LEFT JOIN Ingredient i ON ir.ingredient = i
        WHERE r.id IN :recipeIds
        """)
List<RecipeDataResponse> findRecipeData(List<Long> recipeIds);

 

일단 동작은 하지만 List로 변경하는 로직을 일일이 작성해주어야했고, stream을 돌면서 변경 작업을 하는게 비효율적으로 느껴졌다.

 

QueryDSL을 사용하면 필드를 리스트로 바로 조회할 수 있다고 언뜻 들었었는데, 시간 관계상 시도하지 못했었다.

여러 조회 로직이 생기고, 이제는 도저히 안되겠다 싶어 이번 기회에 한번 시도해보기로 했다.

 

QueryDSL에 대한 자세한 학습은 방학 중에 하려고한다.(테코톡 발표를 해당 주제로 할까도 생각중이다.)

이번 글에서는 QueryDSL을 어떻게 적용했는지에 관해서만 다룰 예정이다.

 

1. 의존성 추가

QueryDSL을 사용하기 위해 build.gradle에 아래 설정을 추가한다. 자료를 찾아보니 querydls-apt를 설정하는 방식이 두가지로 나뉘었는데, (1) 방식은 Gradle의 Dependency Management에서 설정된 querydsl.version 값을 동적으로 참조하는 것이고, (2) 방식은 querydls 버전을 명시적으로 지정해주는 것이라고 한다.

 

그리고 QueryDSL을 사용할 때에는 QClass가 생성되는데, 엔티티 클래스를 표현하기 위해 자동 생성된 클래스로, 엔티티 속성과 메서드를 안전하게 참조할 수 있도록 해준다.(코드 작성 단계에서 타입 안정성 보장)

JavaCompile 시점에서 QClass의 생성 위치를 지정하고, clean 작업이 실행될 때 해당 디렉토리를 삭제하도록 (3)의 코드를 추가해주었다. -> 이 부분은 다른 코드와 관계를 고려해 수정할 예정이다. 동작은 하지만 잘 알아보고 사용하자.

dependencies {
    // (1)
    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"

    // (2)
    implementation 'com.querydsl:querydsl-jpa:5.0.0:jakarta'
    annotationProcessor "com.querydsl:querydsl-apt:5.0.0:jakarta"
    annotationProcessor "jakarta.annotation:jakarta.annotation-api"
    annotationProcessor "jakarta.persistence:jakarta.persistence-api"
}    

// (3)
def generated = 'src/main/generated'

tasks.withType(JavaCompile).configureEach {
    options.getGeneratedSourceOutputDirectory().set(file(generated))
}

clean {
    delete file(generated)
}

 

위 설정을 한 후 JavaCompile을 실행해보면 Entity들에 해당되는 QClass들이 지정된 경로에 생성된 것을 알 수 있다.

 

2. QueryDSL Config 설정

QueryDSL을 전역적으로 사용할 수 있도록 Config를 등록한다. JPAQueryFactory를 통해 쿼리문을 작성할 예정이다.

(6번에서 언급할 예정이지만, SpringBoot 3.X 버전을 사용한다면 JPQLTemplates.DEFAULT 설정을 추가해야한다.)

@Configuration
public class QueryDslConfig {

    @PersistenceContext
    private EntityManager entityManager;

    @Bean
    public JPAQueryFactory jpaQueryFactory() {
        return new JPAQueryFactory(entityManager);
    }
}

 

3. CustomRepository 작성, Impl 구현

우리는 지금 JpaRepository와 QueryDSL을 같이 사용하려고 한다.

이 때, Repository가 JpaRepository와 CustomRepository를 다중상속받으면 된다.

CustomRepository에 대한 구현 내용은 CustomRepositoryImpl에 구현하는데, 이렇게 구현된 내용은 JPA가 자동으로 삽입해주기 때문에 꼭 Impl을 붙여서 구현하도록 하자.(관련 공식문서: Custom Implementations for Spring Data Repositories)

 

따라서, QueryDSL을 사용하기 위한 Repository interface를 정의하고 아래와 같이 구현체를 작성해주었다.

public interface RecipeQueryDslRepository {

    List<MainRecipeResponse> findRecipesByQueryDsl(List<Long> recipeIds, long currentUserId);
}
@Repository
@RequiredArgsConstructor
public class RecipeQueryDslRepositoryImpl implements RecipeQueryDslRepository {

    private final JPAQueryFactory jpaQueryFactory;

    @Override
    public List<MainRecipeResponse> findRecipesByQueryDsl(List<Long> recipeIds, long currentUserId) {
        return jpaQueryFactory.from(recipe)
                .leftJoin(categoryRecipe).on(categoryRecipe.recipe.eq(recipe))
                .leftJoin(categoryRecipe.category, category)
                .leftJoin(ingredientRecipe).on(ingredientRecipe.recipe.eq(recipe))
                .leftJoin(ingredientRecipe.ingredient, ingredient)
                .where(recipe.id.in(recipeIds))
                .orderBy(recipe.id.desc())
                .transform(groupBy(recipe.id).list( // Recipe의 id를 사용해 그룹화해주었다.
                        // Projections는 QueryDSL에서 쿼리 결과를 특정 형식으로 변환, 매핑하는데 사용되는 기능이다.
                        Projections.constructor(MainRecipeResponse.class,
                                recipe,
                                list( 
                                        Projections.constructor(CategoryResponse.class,
                                                category.id,
                                                category.name
                                        )
                                ),
                                list(
                                        Projections.constructor(IngredientResponse.class,
                                                ingredient.id,
                                                ingredient.name,
                                                ingredientRecipe.requirement)
                                ),
                                recipe.author.id.eq(currentUserId)
                        )
                ));
    }
}

 

4. JpaRepository에 적용

기존에 작성해두었던 Repository가 Custom interface를 상속하도록 하면 기존 메서드는 유지하면서 Impl에서 구현한 메서드를 사용할 수 있다.

public interface RecipeQueryRepository extends JpaRepository<Recipe, Long>, RecipeQueryDslRepository {

    int countByAuthorId(long userId);
    ...
    
}

 

5. 작성한 쿼리 메서드 사용

recipeRepository를 통해 QueryDSL로 작성한 메서드를 사용할 수 있다.

    public List<MainRecipeResponse> readRecipes(UserInfo userInfo, PageRecipeRequest pageRecipeRequest) {
        ...
        
        return recipeRepository.findRecipesByQueryDsl(recipeIds, userInfo.getId());
    }

 

6. 트러블 슈팅

그런데, 이 과정에서 아래 에러가 발생했다.

 

SpringBoot 3.X 이상의 환경에서는 2.X과 다른 설정을 해주어야 사용이 가능하다고 한다.

JPAQueryFactory에 JPQLTemplates.DEFAULT 설정을 적용해야한다. 

@Configuration
public class QueryDslConfig {

    @PersistenceContext
    private EntityManager entityManager;

    @Bean
    public JPAQueryFactory jpaQueryFactory() {
        return new JPAQueryFactory(JPQLTemplates.DEFAULT, entityManager);
    }
}

 

최종적으로, 아래 DTO 형태로(일부 필드 생략) 원하는 데이터를 잘 찾아오는 것을 확인할 수 있었다.

public record MainRecipeResponse(
        long recipeId,
        String title,
        ...
        List<CategoryResponse> category,
        List<IngredientResponse> ingredient,
        boolean mine
) {
}

 

QueryDSL 사용이 굉장히 복잡할거라고 생각했는데, 생각보다는 간단했다.(깊게 학습하지 않아서 그럴지도..)

이번에는 시간 관계상 원리나 자세한 설정에 대해서는 학습하지 못했는데, 이번 스프린트 이후에 좀 더 학습해보면 좋을 것 같다.

 

참고자료

(공식문서) QueryDSL

[JPA] QueryDSL

SpringBoot에서 QueryDSL JPA 사용하기

QueryDSL로 한방 쿼리 작성하기

SpringBoot3.x버전에서 QueryDsl 사용 설정 시 이슈 정리