ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • JPA에서 QueryDSL을 사용해 Join이 필요한 쿼리 수행하기
    Today I Learned 2023. 5. 4. 02:11

     

    JPA를 이용해 두 개 이상의 테이블을 Join한 뒤, 조건에 맞는 Column을 쿼리하려면 쿼리 메서드를 어떻게 작성해야 할까?

     

    이전에 개인 프로젝트를 진행할 때 모든 채팅 메시지 Entity 중 특정 채팅방의 입, 퇴장 메시지만을 선별해 쿼리해야 하는 상황이 있었는데, 그때는 어떻게든 조건을 만족시키는 쿼리 메서드 네이밍을 하거나, JPQL 문법에 맞는 쿼리문을 작성해 직접 쿼리를 정의하는 방법을 사용했었다.

     

    어떻게든 만들어낸 쿼리 메서드를 살펴보자.

    List<ChattingEventMessage>
        findAllByRoomIdAndTypeOrRoomIdAndType(Long roomId,
                                              ChattingMessageType enter,
                                              Long sameRoomId,
                                              ChattingMessageType quit);

     

    한 눈에 봐도 명칭이 너무 길어, 나중에 다시 봤을 때 어떤 조건을 갖는지를 한 번에 파악하기가 어려운 문제가 생길 수 있음을 짐작할 수 있다.

     

    이번에는 객체지향 쿼리 언어인 JPQL 문법에 맞게끔 쿼리문을 직접 작성한 뒤, @Query() 어노테이션에 작성한 쿼리문을 부여해 조건에 맞게끔 쿼리하도록 한 구문이다.

    @Query(
        "SELECT chatting_messages " +
            "FROM ChattingMessage chatting_messages " +
            "LEFT JOIN ChattingEventMessage event_messages " +
            "ON chatting_messages.id = event_messages.id " +
            "WHERE (chatting_messages.roomId = :roomId AND chatting_messages.type = :enter) " +
            "OR (chatting_messages.roomId = :roomId AND chatting_messages.type = :quit)")
    List<ChattingEventMessage>
        findAllByRoomIdAndEventTypes(@Param("roomId") Long roomId,
                                     @Param("enter") ChattingMessageType enter,
                                     @Param("quit") ChattingMessageType quit);

     

    JPQL 문법을 이용해 쿼리문을 작성함으로써 Entity 테이블 간에 일치시킬 Column이 무엇인지, 조건이 어떻게 되는지 쿼리문을 바탕으로 파악할 수 있다.

     

     

    현재 다른 프로젝트를 진행하는 과정에서 위처럼 두 개의 Entity 테이블을 Join한 뒤, 조건에 맞는 Entity를 쿼리해야 하는 상황이 있었다. 최근 JPA를 사용해 조건에 맞는 쿼리를 하는 방법으로 QueryDSL을 사용할 수 있다는 이야기를 들은 바 있었는데, QueryDSL을 가볍게 살펴본 결과 복잡한 쿼리를 표현할 수 있으면서도 @Query() 어노테이션에 직접 쿼리문을 작성하는 것보다 안정적인 구조로 작성할 수 있겠다는 생각이 들었고, 한번 사용해보기로 했다.

     

     

     

    QueryDSL이란?

     

    Querydsl - Unified Queries for Java

    Unified Queries for Java. Querydsl is compact, safe and easy to learn. <!-- Querydsl Unified Queries for Java Querydsl provides a unified querying layer for multiple backends in Java. Compared to the alternatives Querydsl is more compact, safer and easier

    querydsl.com

    QueryDSL은 정적 타입을 이용해서 SQL과 같은 방식으로 쿼리를 사용할 수 있도록 하는 프레임워크이다.

     

    QueryDSL이 JPQL 대비 갖는 강점은 Type-safe하다는 데 있다. JPQL 방식의 가장 큰 문제점은 쿼리문의 형태가 문자열 형태라는 데 있다. 따라서 해당 쿼리문에 문법적인 오류가 있더라도 컴파일 과정에서는 쿼리의 문법적인 오류를 잡아낼 수 없다. 오류를 발견하기 위해서는 애플리케이션을 직접 실행시키면서 오류가 발생하는 것을 확인하거나, 직접 데이터베이스를 띄우고 해당 쿼리 메서드를 호출해 반환되는 결과를 직접 확인하는 방식으로 애플리케이션 범위 밖의 영역과 데이터를 주고받는 통합 테스트 형식의 테스트를 수행해야 한다.

     

     

    반면 QueryDSL은 쿼리문을 다음과 같이 객체의 메서드 체이닝 형태로 생성한다.

    List<Member> members = queryFactory
        .select(member)
        .from(member)
        .where(member.username.eq("Inu"))
        .fetch();

     

    따라서 작성한 쿼리문에 오류가 존재할 경우 컴파일 단계에서 오류가 발생하므로, 애플리케이션을 실행하기 전 단위 테스트를 통해 쿼리문의 정합성을 검증할 수 있다.

     

     

     

    QueryDSL 사용을 위한 build.gradle 세팅

    공식 홈페이지에서는 Maven 기반의 세팅 방법을 소개하고 있기 때문에 Gradle 환경 세팅을 위해 다른 자료들을 참조했다.

     

    내가 사용하고 있던 기존에 세팅되어 있던 build.gradle에 추가된 내용들은 다음과 같다.

    // 다음 buildscript를 추가한다.
    buildscript {
        ext {
            queryDslVersion = "5.0.0"
        }
    }
    
    plugins {
        id 'java'
        id 'org.springframework.boot' version '2.7.11'
        id 'io.spring.dependency-management' version '1.0.15.RELEASE'
        
        // plugins 안에 다음을 추가한다.    
        id 'com.ewerk.gradle.plugins.querydsl' version '1.0.10'
    }
    
    group = 
    version = '0.0.1-SNAPSHOT'
    sourceCompatibility = '11'
    
    repositories {
        mavenCentral()
    }
    
    dependencies {
        implementation 'org.springframework.boot:spring-boot-starter-web'
        implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
            
        developmentOnly 'org.springframework.boot:spring-boot-devtools'
    
        runtimeOnly 'com.h2database:h2'
        runtimeOnly 'org.postgresql:postgresql'
    
        testImplementation 'org.springframework.boot:spring-boot-starter-test'
        
        // dependencies 안에 다음을 추가한다.
        implementation "com.querydsl:querydsl-jpa:${queryDslVersion}"
        implementation "com.querydsl:querydsl-apt:${queryDslVersion}"
    }
    
    tasks.named('test') {
        useJUnitPlatform()
    }
    
    /*
     * 다음의 내용들을 추가한다.
     */
    def querydslDir = "$buildDir/generated/querydsl"
    
    querydsl {
        jpa = true
        querydslSourcesDir = querydslDir
    }
    
    sourceSets {
        main.java.srcDir querydslDir
    }
    
    compileQuerydsl {
        options.annotationProcessorPath = configurations.querydsl
    }
    
    configurations {
        compileOnly {
            extendsFrom annotationProcessor
        }
        querydsl.extendsFrom compileClasspath
    }

     

    설정을 마친 뒤 프로젝트를 빌드하면, @Entity 어노테이션을 붙인 클래스들이 build/generated/querydsl 하위 디렉터리에 접두사 Q가 붙은 형태로 생성된 것을 확인할 수 있다.

     

     

     

    QueryDSL을 사용해 Entity 쿼리하기

    다음의 두 Entity를 보자.

     

    하나는 '수강신청서'를 나타내는 Entity이다. 학생이 어느 학기에 어느 과목에 수강신청했고, 수강신청 결과는 어떤지에 대한 정보가 담겨 있다.

    @Entity
    public class Application {
        @Id
        @GeneratedValue
        private Long id;
    
        private Long semesterId;
    
        private Long courseId;
    
        private Long studentId;
    
        @Enumerated
        private ApplicationResult applicationResult;
        
        // constructors
        // ...
    }

     

    다른 하나는 '과목'을 나타내는 Entity이다. 과목의 이름 정보가 담겨 있다.

    @Entity
    public class Course {
        @Id
        @GeneratedValue
        private Long id;
    
        @Enumerated
        private CourseRequirement courseRequirement;
    
        private String name;
        
        // consturctors
        // ...
    }

     

    특정 학생의 특정 학기 수강신청 결과 리소스를 생성해 보자. 그러려면 우선 특정 학생이 특정 학기에 수강신청한 모든 Application Entity들을 가져와야 할 것이다. 그 다음으로는 가져온 각 Application Entity들에 맞는 Course Entity들도 가져온 뒤, 특정 과목의 이름과 수강신청 결과를 이용해 조합하는 식으로 적절한 리소스를 생성할 수 있을 것이다.

     

    두 테이블을 Course의 id를 기준으로 Join할 수 있다면, 한 번의 쿼리로 Entity들을 한 번에 가져올 수 있을 것이다. QueryDSL을 이용해 Entity들을 한 번에 쿼리해보자.

     

    Entity들을 불러오는 Service Layer에서는 Repository에서 다음과 같이 결과를 가져올 것이다.

    @Service
    @Transactional(readOnly = true)
    public class GetApplicationResultsService {
        private final ApplicationRepository applicationRepository
    
        public GetApplicationResultsService(
            ApplicationRepository applicationRepository
        ) {
            this.applicationRepository = applicationRepository;
        }
        
        public ApplicationResultsDto getApplicantResults(Long studentId,
                                                         String semesterName) {
            // semesterName을 이용해 Semester Entity를 쿼리한다.
            // 그 외 필요한 Entity가 있다면 쿼리한다.
            
            Map<Application, Course> applicationAndCourses
            = applicationRepository
            .findAllBySemesterIdAndStudentId(semester.id(), student.id());
            
            // ApplicationResultsDto를 생성해 반환한다.
        }
    }

     

    courseId가 일치하는 Application과 Course Entity를 쌍을 지어 가져올 것이기 때문에 반환형을 Map<Application, Course>로 정의했다.

     

    이제는 추상 Repository를 생성해보자.

    public interface ApplicationAndCourseRepository {
        Map<Application, Course>
            findAllBySemesterIdAndStudentId(Long semesterId, Long studentId);
    }

     

    다음으로 JpaRepository만을 상속받고 있을 ApplicationRepository에 방금 생성해준 추상 Repository를 같이 상속시킨다.

    public interface ApplicationRepository
        extends JpaRepository<Application, Long>,
        ApplicationAndCourseRepository {
        // another method signatures
        // ...
    }

     

     

    ApplicationAndCourseRepository에 정의한 메서드 시그니쳐의 로직은 QueryDSL 문법을 이용해 직접 작성해야 한다. 작성하러 가보자.

    @Repository
    public class ApplicationAndCourseRepositoryImpl
        implements ApplicationAndCourseRepository {
        private final JPAQueryFactory jpaQueryFactory;
    
        public ApplicationAndCourseNameRepositoryImpl(JPAQueryFactory jpaQueryFactory) {
            this.jpaQueryFactory = jpaQueryFactory;
        }
    
        @Override
        public Map<Application, Course>
            findAllBySemesterIdAndStudentId(Long semesterId,
                                            Long studentId) {
            // 여기에 쿼리문을 작성해 객체들을 쿼리하도록 한 뒤,
            // 적절히 조합해 반환할 것이다.
        }
    }

     

    쿼리문을 작성하기 전에, 해당 RepositoryImpl이 주입받는 JpaQueryFactory 객체를 Bean으로 등록하기 위한 Configuration을 작성해보자.

    @Configuration
    public class QueryDslConfig {
        @PersistenceContext
        private EntityManager entityManager;
    
        @Bean
        public JPAQueryFactory jpaQueryFactory() {
            return new JPAQueryFactory(entityManager);
        }
    }

     

    모든 준비는 끝났다. 이제 쿼리문을 작성할 차례다. 쿼리해야 할 Entity들의 요구조건을 다시 한 번 살펴보면

     

    - 학생을 특정해야 한다.

    - 학기를 특정해야 한다.

    - 수강신청서에 적힌 강의 식별자에 해당하는 강의여야 한다.

     

    이 정도이다. 작성된 쿼리문을 한 번 보자.

    /* import문의 static 객체가 위치한 경로는 build.gradle에서 지정한
     * querydslDir 하위 경로로부터 시작되는 경로를 따른다.
     */
    import static models.QApplication.application;
    import static models.QCourse.course;
    
    @Override
    public Map<Application, Course>
        findAllBySemesterIdAndStudentId(Long semesterId,
                                        Long studentId) {
        List<Tuple> tuples = jpaQueryFactory
            .select(application, course)
            .from(application)
            .rightJoin(course).on(application.courseId.eq(course.id))
            .where(application.semesterId.eq(semesterId)
                .and(application.studentId.eq(studentId)))
            .fetch();
            
        return tuples.stream()
            .collect(Collectors.toMap(
                tuple -> tuple.get(application),
                tuple -> tuple.get(course)
            ));
    }

     

    사실상 SQL문과 동일한 형태로 쿼리문을 작성할 수 있다는 것을 확인할 수 있다.

     

    예시에서는 두 개의 테이블을 Join했기 때문에 반환형의 제너릭이 Tuple이 되었지만, 단일 테이블로부터 쿼리할 경우 해당 Entity 타입으로 지정할 수 있다. Tuple에서 각 Entity를 추출하기 위해서는 Tuple 인스턴스의 getter 메서드를 이용할 수 있다.

     

     

     

    RepositoryImpl 구현체의 메서드에 정의한 쿼리문 테스트하기

    QueryDSL을 이용해 이제는 작성한 쿼리문에 대해 안전하게 컴파일 시점에서 에러를 체크할 수 있게 되었지만, 어쨌든 '직접 작성한' 쿼리문이니만큼, 문법적으로 올바르더라도 의도하지 않은 쿼리 결과를 도출할 수 있는 가능성은 얼마든지 있다. 작성한 메서드 내 쿼리문이 의도한 대로 결과를 도출하는지 테스트할 수 있다면 더욱 안정적인 쿼리문 작성이 가능할 것이다.

     

    작성했던 구현체는 EntityManager가 주입된 JpaQueryFactory를 이용하고 있으므로, EntityManager에 직접 Entity들을 persist한 뒤, 쿼리 메서드를 수행했을 때 적절하게 쿼리를 수행하는지 검증하는 방식으로 단위 테스트를 작성할 수 있다.

     

    다음과 같은 구조를 따라 테스트 코드를 작성할 수 있다.

    @DataJpaTest
    @Import(QueryDslTestConfig.class)
    // @ActiveProfiles("application.properties에 정의한 실행 환경 명칭")
    @DisplayName("ApplicationAndCourseNameRepository")
    class ApplicationAndCourseRepositoryImplTest {
        @Autowired
        private ApplicationAndCourseRepositoryImpl repository;
    
        @Autowired
        private EntityManager entityManager;
    
        @Nested
        @DisplayName("Application, Course 쌍을 쿼리하기 위한 Repository의 구현체는")
        class RepositoryImpl {
            private Long studentId = 201310513L;
            private Long semesterId = 20231L;
            private Course course1;
            private Course course2;
            // ...
            private Application application1;
            private Application application2;
            // ...
    
            @BeforeEach
            void setUp() {
                // 모의 course 객체들을 생성한다.
                // 이때 id를 자동으로 생성하는 전략을 취하고 있을 경우, id 값은 부여하지 않는다.
                
                entityManager.persist(course1);
                entityManager.persist(course2);
                // ...
                
                course1 = entityManager.find(Course.class, course1.id());
                course2 = entityManager.find(Course.class, course2.id());
                // ...
    
                // persist되어 id 값이 부여된 course들의 id를 활용해
                // 모의 Application 객체들을 적절히 생성한다.
                // 마찬가지로 id 자동 생성 전략을 취하고 있을 경우 id 값은 부여하지 않는다.
                
                // 생성한 Application 객체들을 persist한다.
            }
    
            @Test
            @DisplayName("쿼리 메서드를 수행해 Map<ApplicationForm, Course> 쌍을 반환")
            void findAllBySemesterIdAndStudentId() {
                Map<Application, Course> applicationAndCourses
                    = repository.findAllBySemesterIdAndStudentId(semesterId, studentId);
    
                // 모의 객체들에 부여한 조건에 맞게끔 쿼리가 수행되어 결과가 반환되었는지 검증한다.
            }
        }
    }

     

    테스트에서도 EntityManager를 @Autowired를 이용해 주입받기 때문에, 테스트를 위한 Configuration을 다음과 같이 테스트 package에 별도로 정의해줘야 한다.

    @TestConfiguration
    public class QueryDslTestConfig {
        @PersistenceContext
        private EntityManager entityManager;
    
        @Bean
        public JPAQueryFactory jpaQueryFactory() {
            return new JPAQueryFactory(entityManager);
        }
    }

     

     

     

    References

    - https://tecoble.techcourse.co.kr/post/2021-08-08-basic-querydsl/

    - https://jojoldu.tistory.com/372

    - ChatGPT

     

     

     

     

    댓글

Designed by Tistory.