반복로직/JPQL -> QueryDSL으로 리팩토링

smj_716·2025년 6월 11일

한이음 드림업

목록 보기
3/9

1. JPQL의 한계

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);

⚠️ 그런데 문제는

  • 문자열 기반 : 쿼리를 " " 안에 문자열로 작성해야 해서 오타가 나도 컴파일 타임에 알 수 없음
  • 런타임 오류 : 잘못된 필드명을 써도 에러는 실행 시점에 발생함
  • 조건문이 많을 때 : if문을 여러번 사용해야하고 코드가 복잡해짐
    특히, 동적 필터 기능이 많아질수록 JPQL은 구조적으로 한계에 부딪히게 된다.

2. QueryDSL이란?

Java 코드로 SQL처럼 쿼리를 작성할 수 있도록 도와주는 프레임워크

즉! 우리가 직접 문자열로 SQL을 쓰는 것이 아니라,
타입 안정성이 보장된 Java 메서드 체이닝 방식으로 쿼리를 작성하는 도구이다.

  • 타입 안정성 : 컴파일 타임에 필드 오타를 체크할 수 있음
  • IDE 자동완성 : 엔티티 기준으로 쿼리 작성 시 자동완성 지원
  • 동적 쿼리 : 조건을 메서드로 조립하여 동적으로 where절 생성 가능

❓그럼 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처럼 타입 안전하게 사용할 수 있다.


3. 기존 프로젝트 코드 방식

➡️ 모든 강의 조회

< 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();
}

⚠️ 문제점

  • 강의가 100개면 -> 시간조회 쿼리 100번 발생 -> N+1 문제 발생
  • 필터링 기능(전공, 학년, 과목코드 등)이 들어가면 조건문(if문) 난무 -> 유지보수 어려움

➡️ 강의를 신청한 인원 조회

< 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);

⚠️ 문제점

  • JPQL은 문자열 기반이라 오타나 필드명이 바뀌어도 컴파일 타임에 잡히지 않음
  • DTO 생성자 기반이라 필드 순서 변경 시 오류 가능성 있음
  • 추후 동적 조건이 생기면 쿼리 문자열을 다시 조립해야 하므로 유연하지 않음

4. QueryDSL로 리팩토링한 코드

📌 설정

@Configuration
public class QuerydslConfig {
	@PersistenceContext
	private EntityManager em;

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

이 설정은 QueryDSL에서 쿼리를 생성할 때 사용하는 핵심 도구인 JPAQueryFactory를 빈으로 등록한다.

  • EntityManager는 JPA의 모든 쿼리 동작을 관리하는 핵심 객체
  • JPAQueryFactory는 EntityManager를 기반으로 QueryDSL 쿼리를 만들어주는 역할

이 설정을 통해서 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줄만에 깔끔히 처리
  • null 조건은 자동으로 제거되므로 if문 필요 없음
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);
}
  • 강의 하나당 time1, time2만 뽑아서 시간 문자열을 생성하고,
    메인 강의 조회 쿼리 안에 서브쿼리 형식으로 조립해서 한 번에 처리한다.
  • 즉! 강의가 100개든 1000개든 추가적인 쿼리 실행은 발생하지 않고,
    메인 쿼리 1개 + 시간 정보 서브쿼리 2개(각 time1, time2)만 QueryDSL 내부적으로 작성되어 쿼리는 실제로 한 번에 실행된다.

➕ 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' 또는 '다대일 관계'의 단순 조회일 뿐이다.
우리 프로젝트와 같이 CourseCourseTime이 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();
  • 특정 강의 목록(courseIds)에 대해 몇 명이 신청했는지 집계하는 쿼리
  • count()groupBy()를 사용하여 쿼리 한 번에 수강인원 수를 조회
  • DTO를 바로 생성자 방식으로 반환하여 불필요한 변환 로직 생략

0개의 댓글