JPQL은 문자열 기반 쿼리로 다음과 같은 문제점이 있다.
// JPQL - 런타임 에러 발생 가능
String jpql = "SELECT m FROM Member m WHERE m.age > :age";
TypedQuery<Member> query = em.createQuery(jpql, Member.class);
QueryDSL은 타입 안전한 쿼리를 제공한다.
// QueryDSL - 컴파일 타임 에러 검증
QMember member = QMember.member;
List<Member> result = queryFactory
.selectFrom(member)
.where(member.age.gt(18))
.fetch();
JPQL과 QueryDSL은 모두 최종적으로 같은 SQL을 생성하므로 성능상 차이는 없다. QueryDSL은 JPQL 생성기 역할을 한다.
QueryDSL에서 엔티티에 대응하는 정적 타입을 Q-Type이라 한다.
@Entity
public class Member {
@Id @GeneratedValue
private Long id;
private String username;
private int age;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "team_id")
private Team team;
}
위 엔티티에 대해 다음과 같은 Q-Type이 생성된다.
// 자동 생성되는 QMember 클래스
public class QMember extends EntityPathBase<Member> {
public static final QMember member = new QMember("member");
public final NumberPath<Long> id = createNumber("id", Long.class);
public final StringPath username = createString("username");
public final NumberPath<Integer> age = createNumber("age", Integer.class);
public final QTeam team = new QTeam(forProperty("team"));
}
Q-Type은 두 가지 방식으로 사용할 수 있다.
// 1. 별칭 직접 지정
QMember qMember = new QMember("m");
// 2. 기본 인스턴스 사용
QMember member = QMember.member;
기본 인스턴스 사용을 권장하며, 같은 테이블을 조인해야 하는 경우에만 별칭을 직접 지정한다.
// 셀프 조인 시 별칭 지정
QMember memberA = new QMember("memberA");
QMember memberB = new QMember("memberB");
QueryDSL 사용을 위한 기본 설정이다.
@Repository
public class MemberRepository {
private final EntityManager em;
private final JPAQueryFactory queryFactory;
public MemberRepository(EntityManager em) {
this.em = em;
this.queryFactory = new JPAQueryFactory(em);
}
}
public List<Member> findAll() {
return queryFactory
.selectFrom(member)
.fetch();
}
public Member findOne(Long id) {
return queryFactory
.selectFrom(member)
.where(member.id.eq(id))
.fetchOne();
}
QueryDSL은 다양한 검색 조건을 제공한다.
public List<Member> search(String username, Integer age) {
return queryFactory
.selectFrom(member)
.where(
member.username.eq(username), // username = ?
member.age.gt(age), // age > ?
member.age.goe(age), // age >= ?
member.age.lt(age), // age < ?
member.age.loe(age), // age <= ?
member.age.ne(age), // age != ?
member.age.between(10, 30), // age between 10 and 30
member.username.contains("member"), // username like '%member%'
member.username.startsWith("member"), // username like 'member%'
member.username.like("member%"), // username like 'member%'
member.username.in("member1", "member2"), // username in ('member1', 'member2')
member.username.notIn("member1", "member2") // username not in ('member1', 'member2')
)
.fetch();
}
// AND 조건 (기본)
.where(member.username.eq("member1"), member.age.eq(10))
// OR 조건
.where(member.username.eq("member1").or(member.age.eq(10)))
// 복합 조건
.where(
member.username.eq("member1")
.and(member.age.eq(10))
.or(member.age.eq(20))
)
public List<Member> findAllOrderBy() {
return queryFactory
.selectFrom(member)
.orderBy(
member.age.desc(),
member.username.asc().nullsLast()
)
.fetch();
}
public List<Member> findByPage(int offset, int limit) {
return queryFactory
.selectFrom(member)
.orderBy(member.username.desc())
.offset(offset)
.limit(limit)
.fetch();
}
// 페이징 정보와 함께 조회
public Page<Member> findByPageWithCount(Pageable pageable) {
List<Member> content = queryFactory
.selectFrom(member)
.offset(pageable.getOffset())
.limit(pageable.getPageSize())
.fetch();
long total = queryFactory
.select(member.count())
.from(member)
.fetchOne();
return new PageImpl<>(content, pageable, total);
}
public MemberStatDto getMemberStat() {
return queryFactory
.select(Projections.constructor(MemberStatDto.class,
member.count(),
member.age.sum(),
member.age.avg(),
member.age.max(),
member.age.min()
))
.from(member)
.fetchOne();
}
public List<TeamStatDto> getTeamStat() {
return queryFactory
.select(Projections.constructor(TeamStatDto.class,
team.name,
member.age.avg()
))
.from(member)
.join(member.team, team)
.groupBy(team.name)
.having(member.age.avg().gt(20))
.fetch();
}
// 내부 조인
public List<Member> innerJoin() {
return queryFactory
.selectFrom(member)
.join(member.team, team)
.where(team.name.eq("teamA"))
.fetch();
}
// 외부 조인
public List<Member> leftJoin() {
return queryFactory
.selectFrom(member)
.leftJoin(member.team, team)
.fetch();
}
// 세타 조인 (연관관계가 없는 필드로 조인)
public List<Member> thetaJoin() {
return queryFactory
.select(member)
.from(member, team)
.where(member.username.eq(team.name))
.fetch();
}
// ON절을 활용한 조인
public List<Tuple> joinOnFiltering() {
return queryFactory
.select(member, team)
.from(member)
.leftJoin(member.team, team)
.on(team.name.eq("teamA"))
.fetch();
}
// 연관관계 없는 엔티티 외부 조인
public List<Tuple> joinOnNoRelation() {
return queryFactory
.select(member, team)
.from(member)
.leftJoin(team)
.on(member.username.eq(team.name))
.fetch();
}
// 페치 조인 미적용
public List<Member> findMembersLazy() {
return queryFactory
.selectFrom(member)
.fetch(); // N+1 문제 발생
}
// 페치 조인 적용
public List<Member> findMembersWithTeam() {
return queryFactory
.selectFrom(member)
.join(member.team, team).fetchJoin()
.fetch(); // N+1 문제 해결
}
// 서브쿼리 eq 사용
public List<Member> findBySubQueryEq() {
QMember memberSub = new QMember("memberSub");
return queryFactory
.selectFrom(member)
.where(member.age.eq(
JPAExpressions
.select(memberSub.age.max())
.from(memberSub)
))
.fetch();
}
// 서브쿼리 goe 사용
public List<Member> findBySubQueryGoe() {
QMember memberSub = new QMember("memberSub");
return queryFactory
.selectFrom(member)
.where(member.age.goe(
JPAExpressions
.select(memberSub.age.avg())
.from(memberSub)
))
.fetch();
}
// 서브쿼리 in 사용
public List<Member> findBySubQueryIn() {
QMember memberSub = new QMember("memberSub");
return queryFactory
.selectFrom(member)
.where(member.age.in(
JPAExpressions
.select(memberSub.age)
.from(memberSub)
.where(memberSub.age.gt(10))
))
.fetch();
}
// select 절에 서브쿼리
public List<Tuple> findBySelectSubQuery() {
QMember memberSub = new QMember("memberSub");
return queryFactory
.select(member.username,
JPAExpressions
.select(memberSub.age.avg())
.from(memberSub)
)
.from(member)
.fetch();
}
JPA JPQL 서브쿼리의 한계
프로젝션 대상이 하나면 타입을 명확하게 지정할 수 있다.
// 단일 대상 프로젝션
List<String> result = queryFactory
.select(member.username)
.from(member)
.fetch();
프로젝션 대상이 둘 이상이면 튜플이나 DTO로 조회한다.
// 튜플 조회
List<Tuple> result = queryFactory
.select(member.username, member.age)
.from(member)
.fetch();
for (Tuple tuple : result) {
String username = tuple.get(member.username);
Integer age = tuple.get(member.age);
}
순수 JPA에서 DTO 조회시 new 명령어를 사용해야 한다.
// JPQL - new 명령어 사용
List<MemberDto> result = em.createQuery(
"select new study.querydsl.dto.MemberDto(m.username, m.age) from Member m",
MemberDto.class)
.getResultList();
QueryDSL은 3가지 방법을 지원한다.
// DTO에 기본 생성자와 setter가 필요
List<MemberDto> result = queryFactory
.select(Projections.bean(MemberDto.class,
member.username,
member.age))
.from(member)
.fetch();
// DTO에 기본 생성자만 필요, setter 불필요
List<MemberDto> result = queryFactory
.select(Projections.fields(MemberDto.class,
member.username,
member.age))
.from(member)
.fetch();
// DTO의 생성자와 타입이 맞아야 함
List<MemberDto> result = queryFactory
.select(Projections.constructor(MemberDto.class,
member.username,
member.age))
.from(member)
.fetch();
// 프로퍼티나 필드 접근 생성 방식에서 이름이 다를 때 해결 방안
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();
DTO 생성자에 @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();
@QueryProjection의 장단점
동적 쿼리를 해결하는 두 가지 방식이 있다.
public List<Member> searchByBuilder(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();
}
public List<Member> searchByWhere(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));
}
Where 조건에 null 값은 무시된다. 메서드를 다른 쿼리에서도 재활용할 수 있고, 쿼리 자체의 가독성이 높아진다.
쿼리 한번으로 대량 데이터를 수정하거나 삭제한다.
// 28살 미만인 회원의 이름을 비회원으로 변경
long count = queryFactory
.update(member)
.set(member.username, "비회원")
.where(member.age.lt(28))
.execute();
// 모든 회원의 나이를 1살 증가
long count = queryFactory
.update(member)
.set(member.age, member.age.add(1))
.execute();
// 18살 미만인 회원 삭제
long count = queryFactory
.delete(member)
.where(member.age.lt(18))
.execute();
주의사항
벌크 연산은 영속성 컨텍스트를 무시하고 실행되기 때문에, 벌크 연산을 실행하고 나면 영속성 컨텍스트를 초기화하는 것이 안전하다.
long count = queryFactory
.update(member)
.set(member.username, "비회원")
.where(member.age.lt(28))
.execute();
em.flush();
em.clear();
SQL function은 JPA와 같이 Dialect에 등록된 내용만 호출할 수 있다.
List<String> result = queryFactory
.select(Expressions.stringTemplate(
"function('replace', {0}, {1}, {2})",
member.username, "member", "M"))
.from(member)
.fetch();
List<String> result = queryFactory
.select(member.username)
.from(member)
.where(member.username.eq(
Expressions.stringTemplate("function('lower', {0})", member.username)))
.fetch();
lower 같은 ansi 표준 함수들은 querydsl이 상당부분 내장하고 있다. 따라서 다음과 같이 처리해도 결과는 같다.
List<String> result = queryFactory
.select(member.username)
.from(member)
.where(member.username.eq(member.username.lower()))
.fetch();