Spring JPA를 처음 배우면 대부분 @Query를 쓰거나 findByXXX() 같은 메서드 네이밍 전략을 사용해서 데이터를 조회한다.
이때 사용하는 쿼리 언어가 JPQL(Java Persistence Query Language)이다.
JPQL은 SQL처럼 생겼지만, 테이블이 아닌 엔티티를 대상으로 한 객체 지향 쿼리이다.
@Query("SELECT c FROM Course c WHERE c.department = :dept AND c.grade = :grade")
List<Course> findByDepartmentAndGrade(String dept, int grade);
" " 안에 문자열로 작성해야 해서 오타가 나도 컴파일 타임에 알 수 없음Java 코드로 SQL처럼 쿼리를 작성할 수 있도록 도와주는 프레임워크
즉! 우리가 직접 문자열로 SQL을 쓰는 것이 아니라,
타입 안정성이 보장된 Java 메서드 체이닝 방식으로 쿼리를 작성하는 도구이다.
❓그럼 QueryDSL은 어떻게 동작할까❓
QueryDSL은 내부적으로 Q타입 클래스를 자동으로 생성한다.
예를 들어 Course라는 엔티티가 있으면 QCourse라는 클래스를 만든다.
이 Q클래스는 빌드할때 Gradle의 annotationProcessor를 통해 생성되고 이 객체를 통해 타입 안전성 있는 필드를 사용할 수 있는 것이다.
public class QCourse extends EntityPathBase<Course> {
public final StringPath name = createString("name");
public final NumberPath<Integer> grade = createNumber("grade", Integer.class);
...
}
위는 Q타입 클래스 예시이다.
QCourse course = QCourse.course라고 선언하면 이 클래스 안에 있는
필드들을 course.name, course.grade처럼 타입 안전하게 사용할 수 있다.
< JPQL 없이 직접 조회 + 반복 로직>
처음에는 아래와 같이 모든 강의를 조회하고, 각각 시간 정보를 별도로 불러오는 구조였다.
public List<CourseListRes> getCourseList() {
List<Course> courses = courseReader.findAllCourses(); // 전체 강의 조회
return courses.stream()
.map(course -> {
List<CourseTime> times = courseReader.findCourseTimesByCourseId(course.getId()); // 강의별 시간 조회
String time1 = CourseTimeFormatter.formatTime(times, 0);
String time2 = CourseTimeFormatter.formatTime(times, 1);
return CourseListRes.of(course, time1, time2);
})
.toList();
}
⚠️ 문제점
< JPQL 사용 >
@Query("SELECT new com.allclearwas.domains.enrollment.dto.CourseEnrollmentCountDto(e.course.id, COUNT(e)) "
+ "FROM Enrollment e WHERE e.course.id IN :courseIds GROUP BY e.course.id")
List<CourseEnrollmentCountDto> countByCourseIds(@Param("courseIds") List<Long> courseIds);
⚠️ 문제점
@Configuration
public class QuerydslConfig {
@PersistenceContext
private EntityManager em;
@Bean
public JPAQueryFactory jpaQueryFactory() {
return new JPAQueryFactory(em);
}
}
이 설정은 QueryDSL에서 쿼리를 생성할 때 사용하는 핵심 도구인 JPAQueryFactory를 빈으로 등록한다.
이 설정을 통해서 QueryDSL을 구현하는 클래스에서 queryFactory를 주입받아 쿼리를 작성할 수 있게된다.
queryFactory
.select(...)
.from(course)
.join(course.courseInfo, courseInfo)
.where(
equalsIfNotNull(courseInfo.category, request.category()),
equalsIfNotNull(courseInfo.grade, request.grade()),
equalsIfNotNull(courseInfo.department, request.department()),
equalsIfNotBlank(course.courseCode, request.code())
)
.fetch();
select()에는 CourseListDao 생성자에 필요한 정보만 골라 조회selectTimeString()으로 서브쿼리를 이용해 시간 정보를 2줄만에 깔끔히 처리private Expression<String> selectTimeString(QCourseTime time, QCourse course, int offset) {
return JPAExpressions.select(
Expressions.stringTemplate(
"concat({0}, ' ', {1}, '~', {2})",
time.dayOfWeek.stringValue(),
time.startTime.stringValue(),
time.endTime.stringValue()
)
)
.from(time)
.where(time.course.eq(course))
.orderBy(time.id.asc())
.offset(offset)
.limit(1);
}
➕ JPAExpressions?
QueryDSL에서 서브쿼리(하위 쿼리)를 작성할 수 있도록 도와주는 클래스
🔺 기존 SQL에서 다음과 같은 서브쿼리를 작성한다고 하면:
SELECT (SELECT name FROM student WHERE id = 1) AS studentName
🔺 아래와 같이 똑같이 표현할 수 있다:
JPAExpressions.select(student.name)
.from(student)
.where(student.id.eq(1))
❓ 그럼 애초에 JPQL로 조인하면 안되는가 ❓
조인 자체는 JPQL로도 가능하지만 JPQL로도 조인해서 해결할 수 있는 건 '1:1' 또는 '다대일 관계'의 단순 조회일 뿐이다.
우리 프로젝트와 같이 Course와 CourseTime이 1:N 관계이고, 시간 2개만 선택하고 싶은 경우에는 여러 문제가 발생한다.
queryFactory
.select(Projections.constructor(CourseEnrollmentCountDao.class,
enrollment.course.id,
enrollment.count()))
.from(enrollment)
.join(enrollment.course, course)
.where(course.id.in(courseIds))
.groupBy(course.id)
.fetch();
count()와 groupBy()를 사용하여 쿼리 한 번에 수강인원 수를 조회