QueryDSL 완벽 가이드 - Type-safe 쿼리의 모든 것
QueryDSL은 JPA 쿼리를 자바 코드로 작성할 수 있게 해주는 프레임워크다. 문법 오류를 컴파일 시점에 발견할 수 있어 안정적인 쿼리 작성이 가능하다.
문자열 쿼리의 문제점
실무에서 흔히 발생하는 상황
// 퇴근 10분 전 긴급 요구사항
String sql = "select * from member" +
"where name like ?" +
"and age between ? and ?";
// 컴파일 완료 → 배포 완료 → 퇴근 → 버그 발생!
무엇이 문제일까?
-- 실제 생성된 쿼리 (공백 누락)
select * from memberwhere name like ?and age between ? and ?
쿼리의 근본적 문제
| 문제 | 설명 |
|---|---|
| Type-check 불가 | 쿼리는 단순 문자열 |
| 런타임 에러 | 실행 전까지 오류 확인 불가 |
| IDE 지원 부족 | 자동완성, 리팩토링 불가 |
컴파일 에러 → 좋은 에러 (개발 중 발견)
런타임 에러 → 나쁜 에러 (운영 중 발견)
JPA의 쿼리 방법 비교
1. JPQL
String jpql = "SELECT m FROM Member m WHERE m.age > :age";
List<Member> members = em.createQuery(jpql, Member.class)
.setParameter("age", 18)
.getResultList();
장점: SQL과 유사해서 익숙함 단점: 문자열이므로 type-safe 아님, 동적 쿼리 작성 어려움
2. Criteria API
CriteriaBuilder cb = em.getCriteriaBuilder();
CriteriaQuery<Member> cq = cb.createQuery(Member.class);
Root<Member> m = cq.from(Member.class);
cq.select(m).where(cb.gt(m.get("age"), 18));
List<Member> members = em.createQuery(cq).getResultList();
장점: 동적 쿼리 가능
단점: 너무 복잡함, 가독성 최악, 여전히 type-safe 아님 ("age" 문자열)
3. QueryDSL
List<Member> members = queryFactory
.selectFrom(member)
.where(member.age.gt(18))
.fetch();
장점: Type-safe, 간결함, IDE 지원 단점: Q클래스 생성 설정 필요
QueryDSL이란?
DSL (Domain Specific Language)
- 도메인 특화 언어: 특정 도메인에 초점을 맞춘 제한적 표현력의 프로그래밍 언어
- 특징: 단순, 간결, 유창
QueryDSL
- 쿼리 도메인 특화 언어
- JPA, MongoDB, SQL 등을 위한 type-safe 쿼리 프레임워크
QueryDSL 코드 → JPQL 생성 → SQL 생성 → DB 실행
프로젝트 설정
Gradle (Spring Boot 3.x)
dependencies {
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'
}
Q클래스 생성
./gradlew compileJava
엔티티 클래스마다 Q클래스가 생성됨:
Member→QMemberTeam→QTeam
기본 문법
JPAQueryFactory 설정
@Configuration
public class QueryDslConfig {
@PersistenceContext
private EntityManager em;
@Bean
public JPAQueryFactory jpaQueryFactory() {
return new JPAQueryFactory(em);
}
}
JPAQueryFactory를 필드로 제공하면 동시성 문제는 어떻게 될까? 스프링이 주입해주는 EntityManager는 프록시용 가짜 엔티티 매니저다. 실제 사용 시점에 트랜잭션 단위로 실제 엔티티 매니저를 할당해주므로 동시성 문제는 걱정하지 않아도 된다.
Q클래스 사용 방법
// 별칭 직접 지정
QMember qMember = new QMember("m");
// 기본 인스턴스 사용
QMember qMember = QMember.member;
// static import (권장)
import static com.example.domain.QMember.member;
import static com.example.domain.QTeam.team;
List<Member> members = queryFactory
.selectFrom(member)
.where(member.username.eq("kim"))
.fetch();
검색 조건
비교 연산자
member.username.eq("member1") // username = 'member1'
member.username.ne("member1") // username != 'member1'
member.username.eq("member1").not() // username != 'member1'
member.username.isNotNull() // username IS NOT NULL
member.age.gt(30) // age > 30
member.age.goe(30) // age >= 30
member.age.lt(30) // age < 30
member.age.loe(30) // age <= 30
member.age.between(10, 30) // age BETWEEN 10 AND 30
member.username.like("member%") // LIKE 'member%'
member.username.contains("member") // LIKE '%member%'
member.username.startsWith("member") // LIKE 'member%'
member.age.in(10, 20, 30) // age IN (10, 20, 30)
member.age.notIn(10, 20) // age NOT IN (10, 20)
AND / OR 조건
// AND - where()에 여러 조건 (권장)
queryFactory
.selectFrom(member)
.where(
member.username.eq("kim"),
member.age.eq(30)
)
.fetch();
// null 값은 무시되어 동적 쿼리에 유용
// AND - and() 메서드
queryFactory
.selectFrom(member)
.where(member.username.eq("kim").and(member.age.eq(30)))
.fetch();
// OR
queryFactory
.selectFrom(member)
.where(member.username.eq("kim").or(member.username.eq("lee")))
.fetch();
결과 조회
// 리스트 조회 (데이터 없으면 빈 리스트)
List<Member> fetch = queryFactory
.selectFrom(member)
.fetch();
// 단건 조회 (없으면 null, 2개 이상이면 NonUniqueResultException)
Member findMember = queryFactory
.selectFrom(member)
.where(member.id.eq(1L))
.fetchOne();
// 첫 번째 결과 (limit(1).fetchOne())
Member firstMember = queryFactory
.selectFrom(member)
.fetchFirst();
// 카운트
long count = queryFactory
.select(member.count())
.from(member)
.fetchOne();
정렬과 페이징
정렬
List<Member> result = queryFactory
.selectFrom(member)
.where(member.age.eq(100))
.orderBy(member.age.desc(), member.username.asc().nullsLast())
.fetch();
desc(),asc(): 일반 정렬nullsLast(),nullsFirst(): null 데이터 순서 부여
페이징
List<Member> result = queryFactory
.selectFrom(member)
.orderBy(member.username.desc())
.offset(1) // 0부터 시작 (zero index)
.limit(2) // 최대 2건 조회
.fetch();
페이징 + 전체 카운트
public Page<Member> searchPage(MemberSearchCondition cond, Pageable pageable) {
List<Member> content = queryFactory
.selectFrom(member)
.where(/* 조건 */)
.offset(pageable.getOffset())
.limit(pageable.getPageSize())
.fetch();
Long total = queryFactory
.select(member.count())
.from(member)
.where(/* 조건 */)
.fetchOne();
return new PageImpl<>(content, pageable, total);
}
데이터 조회 쿼리는 여러 테이블을 조인해야 하지만, count 쿼리는 조인이 필요 없는 경우가 있다. 성능 최적화가 필요하면 count 전용 쿼리를 별도로 작성하자.
집합 함수
List<Tuple> result = queryFactory
.select(
member.count(),
member.age.sum(),
member.age.avg(),
member.age.max(),
member.age.min()
)
.from(member)
.fetch();
Tuple tuple = result.get(0);
Long count = tuple.get(member.count());
Integer sum = tuple.get(member.age.sum());
Double avg = tuple.get(member.age.avg());
GroupBy, Having
List<Tuple> result = queryFactory
.select(team.name, member.age.avg())
.from(member)
.join(member.team, team)
.groupBy(team.name)
.having(member.age.avg().gt(20))
.fetch();
조인
기본 조인
// Inner Join
List<Member> result = queryFactory
.selectFrom(member)
.join(member.team, team)
.where(team.name.eq("teamA"))
.fetch();
// Left Join
List<Member> result = queryFactory
.selectFrom(member)
.leftJoin(member.team, team)
.fetch();
// Right Join
queryFactory
.selectFrom(member)
.rightJoin(member.team, team)
.fetch();
세타 조인 (연관관계 없는 조인)
// 회원 이름과 팀 이름이 같은 경우
List<Member> result = queryFactory
.select(member)
.from(member, team)
.where(member.username.eq(team.name))
.fetch();
from 절에 여러 엔티티를 선택해서 세타 조인. 외부 조인 불가능.
조인 ON절
// 1. 조인 대상 필터링
List<Tuple> result = queryFactory
.select(member, team)
.from(member)
.leftJoin(member.team, team).on(team.name.eq("teamA"))
.fetch();
// 2. 연관관계 없는 엔티티 외부 조인
List<Tuple> result = queryFactory
.select(member, team)
.from(member)
.leftJoin(team).on(member.username.eq(team.name))
.fetch();
내부조인이면 where절로 해결하고, 외부조인이 필요한 경우에만 on절 사용
페치 조인 (N+1 해결)
List<Member> result = queryFactory
.selectFrom(member)
.join(member.team, team).fetchJoin()
.fetch();
서브쿼리
com.querydsl.jpa.JPAExpressions 사용
QMember memberSub = new QMember("memberSub");
// 나이가 가장 많은 회원 조회
List<Member> result = queryFactory
.selectFrom(member)
.where(member.age.eq(
JPAExpressions
.select(memberSub.age.max())
.from(memberSub)
))
.fetch();
// 나이가 평균 이상인 회원
List<Member> result = queryFactory
.selectFrom(member)
.where(member.age.goe(
JPAExpressions
.select(memberSub.age.avg())
.from(memberSub)
))
.fetch();
// IN 서브쿼리
List<Member> result = queryFactory
.selectFrom(member)
.where(member.age.in(
JPAExpressions
.select(memberSub.age)
.from(memberSub)
.where(memberSub.age.gt(10))
))
.fetch();
// SELECT 절 서브쿼리
List<Tuple> result = queryFactory
.select(member.username,
JPAExpressions
.select(memberSub.age.avg())
.from(memberSub))
.from(member)
.fetch();
FROM 절 서브쿼리 한계
- JPA JPQL 서브쿼리의 한계로 from 절의 서브쿼리(인라인 뷰)는 지원하지 않음
- 해결방안: 서브쿼리를 join으로 변경, 쿼리 2번 분리, nativeSQL 사용
Case 문
// 단순한 조건
List<String> result = queryFactory
.select(member.age
.when(10).then("열살")
.when(20).then("스무살")
.otherwise("기타"))
.from(member)
.fetch();
// 복잡한 조건
List<String> result = queryFactory
.select(new CaseBuilder()
.when(member.age.between(0, 20)).then("0~20살")
.when(member.age.between(21, 30)).then("21~30살")
.otherwise("기타"))
.from(member)
.fetch();
상수, 문자 더하기
// 상수
Tuple result = queryFactory
.select(member.username, Expressions.constant("A"))
.from(member)
.fetchFirst();
// 문자 더하기 (concat)
String result = queryFactory
.select(member.username.concat("_").concat(member.age.stringValue()))
.from(member)
.where(member.username.eq("member1"))
.fetchOne();
// 결과: member1_10
stringValue()는 문자가 아닌 타입을 문자로 변환. ENUM 처리할 때도 유용
프로젝션 - DTO 조회
순수 JPA (불편함)
List<MemberDto> result = em.createQuery(
"select new study.querydsl.dto.MemberDto(m.username, m.age) " +
"from Member m", MemberDto.class)
.getResultList();
// 패키지명을 다 적어야 함, 생성자 방식만 지원
QueryDSL 빈 생성
1. Setter 방식 (Projections.bean)
List<MemberDto> result = queryFactory
.select(Projections.bean(MemberDto.class,
member.username,
member.age))
.from(member)
.fetch();
2. 필드 직접 접근 (Projections.fields)
List<MemberDto> result = queryFactory
.select(Projections.fields(MemberDto.class,
member.username,
member.age))
.from(member)
.fetch();
3. 별칭이 다를 때
List<UserDto> result = queryFactory
.select(Projections.fields(UserDto.class,
member.username.as("name"),
ExpressionUtils.as(
JPAExpressions
.select(memberSub.age.max())
.from(memberSub), "age")
))
.from(member)
.fetch();
4. 생성자 방식 (Projections.constructor)
List<MemberDto> result = queryFactory
.select(Projections.constructor(MemberDto.class,
member.username,
member.age))
.from(member)
.fetch();
@QueryProjection (컴파일 타임 타입 체크)
@Data
public class MemberDto {
private String username;
private int age;
@QueryProjection
public MemberDto(String username, int age) {
this.username = username;
this.age = age;
}
}
// 사용
List<MemberDto> result = queryFactory
.select(new QMemberDto(member.username, member.age))
.from(member)
.fetch();
컴파일러로 타입을 체크할 수 있어 가장 안전함. 단점: DTO가 QueryDSL에 의존, Q파일 생성 필요
동적 쿼리
BooleanBuilder 방식
public List<Member> searchMember1(String usernameCond, Integer ageCond) {
BooleanBuilder builder = new BooleanBuilder();
if (usernameCond != null) {
builder.and(member.username.eq(usernameCond));
}
if (ageCond != null) {
builder.and(member.age.eq(ageCond));
}
return queryFactory
.selectFrom(member)
.where(builder)
.fetch();
}
Where 다중 파라미터 방식 (권장)
public List<Member> searchMember2(String usernameCond, Integer ageCond) {
return queryFactory
.selectFrom(member)
.where(usernameEq(usernameCond), ageEq(ageCond))
.fetch();
}
private BooleanExpression usernameEq(String usernameCond) {
return usernameCond != null ? member.username.eq(usernameCond) : null;
}
private BooleanExpression ageEq(Integer ageCond) {
return ageCond != null ? member.age.eq(ageCond) : null;
}
// 조건 조합 가능
private BooleanExpression allEq(String usernameCond, Integer ageCond) {
return usernameEq(usernameCond).and(ageEq(ageCond));
}
장점:
- 조건 메서드 재사용 가능
- 조건 조합 가능 (null 체크 자동)
- 가독성 향상
벌크 연산
// 대량 데이터 수정
long count = queryFactory
.update(member)
.set(member.username, "비회원")
.where(member.age.lt(28))
.execute();
// 숫자 더하기
long count = queryFactory
.update(member)
.set(member.age, member.age.add(1))
.execute();
// 곱하기: multiply(x)
// 대량 데이터 삭제
long count = queryFactory
.delete(member)
.where(member.age.gt(18))
.execute();
벌크 연산은 영속성 컨텍스트를 무시하고 실행됨. 벌크 연산 후
em.flush(),em.clear()로 영속성 컨텍스트 초기화 권장
SQL Function 호출
SQL function은 Dialect에 등록된 내용만 호출 가능
String result = queryFactory
.select(Expressions.stringTemplate(
"function('replace', {0}, {1}, {2})",
member.username, "member", "M"))
.from(member)
.fetchFirst();
// lower 함수 사용 예시
queryFactory
.select(member.username)
.from(member)
.where(member.username.eq(
Expressions.stringTemplate("function('lower', {0})", member.username)))
.fetch();
Spring Data JPA와 통합
Custom Repository 구조
MemberRepository (interface)
├── extends JpaRepository<Member, Long>
└── extends MemberRepositoryCustom
MemberRepositoryCustom (interface)
└── List<MemberDto> search(condition)
MemberRepositoryImpl (class)
└── implements MemberRepositoryCustom
구현 예시
// Custom 인터페이스
public interface MemberRepositoryCustom {
List<MemberTeamDto> search(MemberSearchCondition condition);
Page<MemberTeamDto> searchPage(MemberSearchCondition condition, Pageable pageable);
}
// 구현 클래스 (이름 규칙: Repository이름 + Impl)
@RequiredArgsConstructor
public class MemberRepositoryImpl implements MemberRepositoryCustom {
private final JPAQueryFactory queryFactory;
@Override
public List<MemberTeamDto> search(MemberSearchCondition condition) {
return queryFactory
.select(new QMemberTeamDto(
member.id,
member.username,
member.age,
team.id,
team.name))
.from(member)
.leftJoin(member.team, team)
.where(
usernameEq(condition.getUsername()),
teamNameEq(condition.getTeamName()),
ageGoe(condition.getAgeGoe()),
ageLoe(condition.getAgeLoe())
)
.fetch();
}
private BooleanExpression usernameEq(String username) {
return hasText(username) ? member.username.eq(username) : null;
}
private BooleanExpression teamNameEq(String teamName) {
return hasText(teamName) ? team.name.eq(teamName) : null;
}
private BooleanExpression ageGoe(Integer ageGoe) {
return ageGoe != null ? member.age.goe(ageGoe) : null;
}
private BooleanExpression ageLoe(Integer ageLoe) {
return ageLoe != null ? member.age.loe(ageLoe) : null;
}
}
// 사용
public interface MemberRepository extends JpaRepository<Member, Long>, MemberRepositoryCustom {
}
페이징 최적화
기본 페이징
@Override
public Page<MemberTeamDto> searchPage(MemberSearchCondition condition, Pageable pageable) {
List<MemberTeamDto> content = queryFactory
.select(new QMemberTeamDto(
member.id, member.username, member.age,
team.id, team.name))
.from(member)
.leftJoin(member.team, team)
.where(
usernameEq(condition.getUsername()),
teamNameEq(condition.getTeamName()),
ageGoe(condition.getAgeGoe()),
ageLoe(condition.getAgeLoe()))
.offset(pageable.getOffset())
.limit(pageable.getPageSize())
.fetch();
long total = queryFactory
.select(member.count())
.from(member)
.leftJoin(member.team, team)
.where(
usernameEq(condition.getUsername()),
teamNameEq(condition.getTeamName()),
ageGoe(condition.getAgeGoe()),
ageLoe(condition.getAgeLoe()))
.fetchOne();
return new PageImpl<>(content, pageable, total);
}
CountQuery 최적화
@Override
public Page<MemberTeamDto> searchPage(MemberSearchCondition condition, Pageable pageable) {
List<MemberTeamDto> content = /* 컨텐츠 쿼리 */;
JPAQuery<Long> countQuery = queryFactory
.select(member.count())
.from(member)
.leftJoin(member.team, team)
.where(/* 조건 */);
return PageableExecutionUtils.getPage(content, pageable, countQuery::fetchOne);
}
PageableExecutionUtils.getPage는 count 쿼리가 생략 가능한 경우 생략:
- 페이지 시작이면서 컨텐츠 사이즈가 페이지 사이즈보다 작을 때
- 마지막 페이지 일 때
Sort 처리
JPAQuery<Member> query = queryFactory.selectFrom(member);
for (Sort.Order o : pageable.getSort()) {
PathBuilder pathBuilder = new PathBuilder(member.getType(), member.getMetadata());
query.orderBy(new OrderSpecifier(
o.isAscending() ? Order.ASC : Order.DESC,
pathBuilder.get(o.getProperty())));
}
List<Member> result = query.fetch();
정렬이 복잡해지면 파라미터를 받아서 직접 처리하는 것이 좋음
exists 최적화
// count > 0 대신 exists 사용 (성능 향상)
private boolean exists(Long memberId) {
Integer fetchFirst = queryFactory
.selectOne()
.from(member)
.where(member.id.eq(memberId))
.fetchFirst();
return fetchFirst != null;
}
스프링 데이터 JPA가 제공하는 QueryDSL 기능
QuerydslPredicateExecutor
public interface MemberRepository extends JpaRepository<Member, Long>,
QuerydslPredicateExecutor<Member> {
}
// 사용
memberRepository.findAll(member.age.between(10, 40));
한계:
- 조인 불가 (묵시적 조인은 가능하지만 left join 불가)
- 서비스 클래스가 QueryDSL에 의존
QuerydslRepositorySupport
장점:
getQuerydsl().applyPagination()으로 페이징 변환 편리from()으로 시작 가능- EntityManager 제공
한계:
- Querydsl 3.x 버전 대상으로 만들어짐
- JPAQueryFactory로 시작할 수 없음 (select로 시작 불가)
- 스프링 데이터 Sort 기능이 정상 동작하지 않음
실무 팁
1. 기본 전략은 Lazy Loading + Fetch Join
queryFactory
.selectFrom(member)
.join(member.team, team).fetchJoin()
.fetch();
2. 동적 쿼리는 Where 다중 파라미터 방식
.where(
usernameEq(condition.getUsername()),
teamNameEq(condition.getTeamName()),
ageGoe(condition.getAgeGoe()),
ageLoe(condition.getAgeLoe())
)
3. 복잡한 쿼리는 네이티브 SQL 고려
// QueryDSL로 해결 어려운 경우
@Query(value = "SELECT * FROM member WHERE ...", nativeQuery = true)
List<Member> findByComplexCondition();
// 또는 JdbcTemplate, MyBatis 활용
4. DTO 조회 시 @QueryProjection 권장
컴파일 타임에 타입 체크 가능
정리
| 항목 | 설명 |
|---|---|
| Type-safe | 컴파일 시점 오류 발견 |
| IDE 지원 | 자동완성, 리팩토링 |
| 동적 쿼리 | BooleanExpression 조합 |
| 간결함 | 메서드 체이닝으로 직관적 |
한번 써보면 돌아갈 수 없다.
컴파일 에러의 감동, IDE 자동완성의 편리함을 경험하면 JPQL 문자열로 돌아가기 어렵다. 복잡한 쿼리는 네이티브 SQL(JdbcTemplate, MyBatis)을 함께 사용하는 것이 현실적인 선택이다.
댓글