객체지향 쿼리 언어 - Querydsl
10. 객체지향 쿼리 언어
10.4 Querydsl
- 쿼리를 문자가 아닌 코드로 작성할 수 있는 기능을 제공하는 오픈소스 프로젝트이다.
- 데이터를 조회하는 데 기능이 특화되어 있다.
- Querydsl 4.0.0 한글 레퍼런스 문서
- Querydsl 4.4.0 영문 레퍼런스 문서
10.4.1 Querydsl 설정
1) 필요 라이브러리
- build.gradle에 아래와 같은 내용을 추가한다.
- http://honeymon.io/tech/2020/07/09/gradle-annotation-processor-with-querydsl.html
- https://jojoldu.tistory.com/372?category=637935
dependencies {
implementation 'com.querydsl:querydsl-core'
implementation 'com.querydsl:querydsl-jpa'
annotationProcessor "com.querydsl:querydsl-apt:${dependencyManagement.importedProperties['querydsl.version']}:jpa"
annotationProcessor 'jakarta.persistence:jakarta.persistence-api'
annotationProcessor 'jakarta.annotation:jakarta.annotation-api'
}
def generated='src/main/generated'
sourceSets {
main.java.srcDirs += [ generated ]
}
tasks.withType(JavaCompile) {
options.generatedSourceOutputDirectory = file(generated)
}
clean.doLast {
file(generated).deleteDir()
}
2) 환경설정
- Querydsl을 사용하려면 엔티티를 기반으로 쿼리 타입이라는 쿼리용 클래스를 생성해야 한다.
- View -> Tool Windows -> Gradle 에서 Tasks -> other -> compileJava 를 더블클릭하여 실행하면 src/main/generated에 Q로 시작하는 쿼리 타입들이 생성된다.
- Tasks -> build -> clean 실행시 쿼리 타입들이 삭제된다.
10.4.2 시작
@Entity
@Setter @Getter
@NoArgsConstructor
@ToString(exclude = "team")
public class Member {
@Id @GeneratedValue
@Column(name = "member_id")
private Long id;
private String name;
private int age;
@ManyToOne(fetch = LAZY)
@JoinColumn(name = "team_id")
private Team team;
public Member(String name, int age, Team team) {
this.name = name;
this.age = age;
this.team = team;
}
}
@Entity
@Getter @Setter
@NoArgsConstructor
@ToString(exclude = "members")
public class Team {
@Id @GeneratedValue
@Column(name = "team_id")
private Long id;
private String name;
@OneToMany(mappedBy = "team")
private List<Member> members = new ArrayList<>();
public Team(String name) {
this.name = name;
}
}
public class QuerydslTest {
static boolean isLoaded = false;
@PersistenceContext
EntityManager em;
@Autowired JPAQueryFactory queryFactory;
@BeforeEach
void init() {
if (!isLoaded) {
Team teamA = new Team("teamA");
Team teamB = new Team("teamB");
em.persist(teamA);
em.persist(teamB);
Member member1 = new Member("member1", 10, teamA);
Member member2 = new Member("member2", 20, teamA);
Member member3 = new Member("member3", 30, teamB);
Member member4 = new Member("member4", 30, teamB);
em.persist(member1);
em.persist(member2);
em.persist(member3);
em.persist(member4);
em.flush();
em.clear();
isLoaded = true;
}
}
@Test
void querydsl() {
JPAQueryFactory queryFactory = new JPAQueryFactory(em); // JPAQueryFactory 생성자 파라미터로 EntityManager를 전달
QMember qMember = new QMember("m"); // 생성되는 JPQL의 별칭 m
List<Member> members = queryFactory.selectFrom(qMember)
.where(qMember.name.eq("member"))
.orderBy(qMember.name.desc())
.fetch();
}
}
- Querydsl을 사용하려면 JPAQueryFactory 객체를 먼저 생성한다. 생성자에 EntityManager를 전달한다.
- 사용할 쿼리 타입을 생성하는데 생성자에 별칭을 준다. 이 별칭을 JPQL에서 별칭으로 사용한다.
1) 기본 Q 생성
- 쿼리 타입은 기본 인스턴스를 보관한다. 기본 인스턴스를 static import를 사용하면 코드를 간결하게 작성할 수 있다.
- JPAQueryFactory를 스프링 빈으로 등록하여 어디서든 주입받아 사용할 수 있다.
@Configuration
public class AppConfig {
@PersistenceContext
EntityManager entityManager;
@Bean
public JPAQueryFactory jpaQueryFactory() {
return new JPAQueryFactory(entityManager);
}
}
import static jpabook.example.domain.QMember.member;
import static jpabook.example.domain.QTeam.team;
@SpringBootTest
@Transactional
@Rollback(false)
public class QueryDslTest {
@Autowired JPAQueryFactory queryFactory;
@Test
void basic() {
List<Member> members = queryFactory.selectFrom(member)
.where(member.name.eq("member"))
.orderBy(member.name.desc())
.fetch();
}
}
10.4.3 검색 조건 쿼리
- where 절에 and 또는 or을 사용할 수 있다.
- and(), or() 메서드를 체인으로 연결할 수 있다.
- where()에 파라미터로 추가하면 and 연산이 수행된다. null이 전달될 경우 무시한다.
@Test
void search1() {
Member foundMember = queryFactory.selectFrom(member)
.where(member.name.eq("member1").and(member.age.eq(10)))
.fetchOne();
assertThat(foundMember.getName()).isEqualTo("member1");
assertThat(foundMember.getAge()).isEqualTo(10);
}
@Test
void search2() {
Member foundMember = queryFactory.selectFrom(member)
.where(member.name.eq("member1"), member.age.eq(10)) // 파라미터로 넘길 경우 and 연산 수행
.fetchOne();
assertThat(foundMember.getName()).isEqualTo("member1");
assertThat(foundMember.getAge()).isEqualTo(10);
}
member.age.in(10, 20) // age in (10, 20)
member.age.notIn(10, 20) // age not in (10, 20)
member.age.between(10, 20) // between 10, 20
member.age.goe(10) // age >= 10
member.age.gt(10) // age > 10
member.age.loe(10) // age <= 10
member.age.lt(10) // age < 10
member.name.eq("member")
member.name.ne("member")
member.name.like("member")
member.name.contains("member")
member.name.startsWith("member")
10.4.4 결과 조회
- 결과 조회 메서드를 호출하면 실제 데이터베이스를 조회한다.
- fetch(): 리스트 조회, 데이터가 없다면 빈 리스트를 반환한다.
- fetchOne(): 데이터가 한 개일 경우 조회한다.
- 데이터가 없으면 null을 반환한다.
- 데이터가 둘 이상이면 com.querydsl.core.NonUniqueResultException 예외가 발생한다.
- fetchFirst(): 첫 번째 데이터를 반환한다.
- 내부 구현은 limit(1).fetchOne() 이다.
- fetchResults(): 페이징 정보를 포함하여 조회한다. Count 쿼리가 추가 실행된다.
- fetchCount(): Count 쿼리가 실행되어 전체 데이터 수를 조회한다.
@Test
void result() {
List<Member> members = queryFactory.selectFrom(member)
.fetch();
User user = queryFactory.selectFrom(QUser.user)
.fetchOne();
assertThat(user).isNull();
assertThrows(NonUniqueResultException.class, () -> queryFactory.selectFrom(member)
.fetchOne());
Member firstMember = queryFactory.selectFrom(member).fetchFirst();
long count = queryFactory.selectFrom(member).fetchCount();
assertThat(count).isEqualTo(4);
QueryResults<Member> memberQueryResults = queryFactory.selectFrom(member).fetchResults();
List<Member> results = memberQueryResults.getResults();
assertThat(results).isEqualTo(members);
long total = memberQueryResults.getTotal();
assertThat(total).isEqualTo(4);
long offset = memberQueryResults.getOffset();
long limit = memberQueryResults.getLimit();
}
10.4.5 페이징과 정렬
- 정렬은 orderBy를 사용한다. desc()는 내림차순, asc()는 오름차순이다.
- 아래 테스트 코드에서 정렬 순서는 나이 내림차순, 나이가 같을 경우 이름 오름차순이다.
@Test
void sort() {
/*
Member member1 = new Member("member1", 10, teamA);
Member member2 = new Member("member2", 20, teamA);
Member member3 = new Member("member3", 30, teamB);
Member member4 = new Member("member4", 30, teamB);
*/
List<Member> members = queryFactory.selectFrom(member)
.orderBy(member.age.desc(), member.name.asc())
.fetch();
Assertions.assertThat(members.get(0).getName()).isEqualTo("member3");
Assertions.assertThat(members.get(1).getName()).isEqualTo("member4");
Assertions.assertThat(members.get(2).getName()).isEqualTo("member2");
Assertions.assertThat(members.get(3).getName()).isEqualTo("member1");
}
- 페이징은 offset()과 limit()를 사용한다.
- fetchResults() 메서드를 사용하면 count 쿼리가 추가적으로 나가 전체 데이터 수를 알 수 있다.
- 문제는 여러 테이블을 조인해서 사용할 경우 fetchResults() 메서드를 사용하면 count 쿼리도 테이블을 조인해서 조회한다. 하지만 count는 조인이 필요가 없는 경우도 있으므로 이럴 때는 count 전용 쿼리를 별도로 작성해야 한다.
@Test
void paging() {
List<Member> members = queryFactory.selectFrom(member)
.orderBy(member.name.desc())
.offset(1) // 0 부터 시작
.limit(2) // 최대 2개 조회
.fetch();
Assertions.assertThat(members.get(0).getName()).isEqualTo("member3");
Assertions.assertThat(members.get(1).getName()).isEqualTo("member2");
}
10.4.6 그룹, 집합
- JPQL이 제공하는 모든 집합 함수를 제공한다.
@Test
void aggregation() {
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);
assertThat(tuple.get(member.count())).isEqualTo(4);
assertThat(tuple.get(member.age.sum())).isEqualTo(90);
assertThat(tuple.get(member.age.avg())).isEqualTo(90.0/4.0);
assertThat(tuple.get(member.age.max())).isEqualTo(30);
assertThat(tuple.get(member.age.min())).isEqualTo(10);
}
- 그룹은 groupBy() 메서드를 사용한다.
- 그룹화된 결과를 제한하려면 having() 메서드를 사용한다.
@Test
void group() {
List<Tuple> result = queryFactory.select(team.name, member.age.avg())
.from(member)
.join(member.team, team)
.groupBy(member.team)
.fetch();
Tuple teamA = result.get(0);
Tuple teamB = result.get(1);
assertThat(teamA.get(team.name)).isEqualTo("teamA");
assertThat(teamA.get(member.age.avg())).isEqualTo(15);
assertThat(teamB.get(team.name)).isEqualTo("teamB");
assertThat(teamB.get(member.age.avg())).isEqualTo(30);
}
10.4.7 조인
1) 기본 조인
- 내부조인으로 join(), innerJoin()을 사용할 수 있다.
- 외부조인으로 leftJoin(), rightJoin()을 사용할 수 있다.
- 조인 메서드의 첫 번째 파라미터로 조인대상, 두 번째 파라미터로 별칭으로 사용할 쿼리 타입을 입력한다.
@Test
void join() {
List<Member> members = queryFactory.selectFrom(member)
.join(member.team, team)
.fetch();
}
2) 세타 조인
- 연관 관계가 없는 필드를 조인할 때 사용한다.
@Test()
void thetaJoin() {
em.persist(new Team("member1"));
em.persist(new Team("member2"));
List<Tuple> result = queryFactory.select(member, team)
.from(member, team)
.where(member.name.eq(team.name))
.fetch();
/*
tuple = [Member(id=3, name=member1, age=10), Team(id=7, name=member1)]
tuple = [Member(id=4, name=member2, age=20), Team(id=8, name=member2)]
*/
for (Tuple tuple : result) {
System.out.println("tuple = " + tuple);
}
}
3) 조인 on절
- 조인 on절은 외부 조인에서만 사용해야 한다. 왜냐하면 내부 조인에 사용하면 where 절에서 필터링 하는 것과 동일한 결과가 출력된다.
- 아래 테스트 코드는 회원과 팀을 조인하면서, 팀 이름이 teamA인 팀만 조인한다. 회원은 모두 조회한다.
@Test()
void joinOn() {
List<Member> members = queryFactory.select(member)
.from(member)
.leftJoin(member.team, team).on(team.name.eq("teamA"))
.fetch();
}
4) 연관 관계 없는 엔티티 외부 조인
- on을 사용해서 서로 관계가 없는 필드를 조인하는 기능이다.
@Test()
void joinOnNoRelation() {
em.persist(new Team("member1"));
em.persist(new Team("member2"));
List<Tuple> result1 = queryFactory.select(member, team)
.from(member)
.leftJoin(team).on(member.name.eq(team.name))
.fetch();
/*
tuple = [Member(id=3, name=member1, age=10), Team(id=7, name=member1)]
tuple = [Member(id=4, name=member2, age=20), Team(id=8, name=member2)]
tuple = [Member(id=5, name=member3, age=30), null]
tuple = [Member(id=6, name=member4, age=30), null]
*/
for (Tuple tuple : result1) {
System.out.println("tuple = " + tuple);
}
List<Tuple> result2 = queryFactory.select(member, team)
.from(member)
.join(team).on(member.name.eq(team.name))
.fetch();
/*
tuple = [Member(id=3, name=member1, age=10), Team(id=7, name=member1)]
tuple = [Member(id=4, name=member2, age=20), Team(id=8, name=member2)]
*/
for (Tuple tuple : result2) {
System.out.println("tuple = " + tuple);
}
}
5) 페치 조인
- 연관된 엔티티를 한 번에 조회하는 기능이다.
- 엔티티 조회에만 사용 가능하다.
@Test
void fetchJoin() {
Member foundMember = queryFactory.selectFrom(member)
.join(member.team).fetchJoin()
.where(member.name.eq("member1"))
.fetchOne();
assertThat(foundMember.getTeam().getName()).isEqualTo("teamA");
}
10.4.8 서브 쿼리
- JPAExpressions를 사용한다.
@Test
void subQuery() {
QMember memberSub = new QMember("memberSub");
List<Member> members = queryFactory.selectFrom(QMember.member)
.where(QMember.member.age.eq(
JPAExpressions
.select(memberSub.age.max())
.from(memberSub)))
.fetch();
assertThat(members.size()).isEqualTo(2);
assertThat(members.get(0).getAge()).isEqualTo(30);
assertThat(members.get(1).getAge()).isEqualTo(30);
}
@Test
void subQuerySelect() {
QMember memberSub = new QMember("memberSub");
List<Tuple> result = queryFactory.select(member.name, JPAExpressions.select(memberSub.age.avg()).from(memberSub))
.from(member)
.fetch();
for (Tuple tuple : result) {
String name = tuple.get(member.name);
Double age = tuple.get(JPAExpressions.select(memberSub.age.avg()).from(memberSub));
System.out.println("name = " + name + " age = " + age);
}
}
- JPQL 서브쿼리와 동일하게 from 절의 서브쿼리는 지원하지 않는다.
- 하이버네이트 구현체 사용시 select 절의 서브쿼리는 지원한다.
10.4.9 프로젝션과 결과 반환
- select 절에 조회 대상을 지정하는 것을 프로젝션이라 한다.
1) 프로젝션 대상이 하나
- 프로젝션 대상이 하나이면 해당 타입으로 반환할 수 있다.
- 프로젝션 대상이 둘 이상이면 튜플이나 DTO로 조회해야 한다.
@Test
void projectionBasic() {
List<String> memberNames = queryFactory.select(member.name)
.from(member)
.fetch();
}
2) 여러 컬럼 반환과 튜플
- 프로젝션 대상으로 여러 필드를 사용할 때 튜플을 반환한다.
- tuple.get() 메서드에 조회한 쿼리 타입을 지정하면 된다.
@Test
void projectionTuple() {
List<Tuple> result = queryFactory.select(member.name, member.age)
.from(member)
.fetch();
for (Tuple tuple : result) {
String name = tuple.get(member.name);
Integer age = tuple.get(member.age);
System.out.println("name = " + name);
System.out.println("age = " + age);
}
}
3) 빈 생성
- 순수 JPA에서 DTO 조회시 new 명령어를 사용한다.
- DTO의 패키지 이름을 다 적어줘야하고, 생성자 방식만 지원한다.
@Data
@NoArgsConstructor
public class MemberDto {
private String name;
private int age;
public MemberDto(String name, int age) {
this.name = name;
this.age = age;
}
}
@Test
void projectionJpaDto() {
List<MemberDto> memberDtos = em.createQuery("select new jpabook.example.domain.MemberDto(m.name, m.age) from Member m", MemberDto.class).getResultList();
for (MemberDto memberDto : memberDtos) {
System.out.println("memberDto = " + memberDto);
}
}
1. 프로퍼티
- Projections.bean() 메서드는 수정자(setter)를 사용해서 값을 채운다.
- 쿼리 결과와 매핑할 프로퍼티 이름이 다르면 as() 메서드를 사용해서 별칭을 준다.
@Data
@NoArgsConstructor
public class UserDto {
private String username;
private int age;
public UserDto(String username, int age) {
this.username = username;
this.age = age;
}
}
@Test
void projectionDtoProperty() {
List<MemberDto> memberDtos = queryFactory.select(Projections.bean(MemberDto.class, member.name, member.age))
.from(member)
.fetch();
for (MemberDto memberDto : memberDtos) {
System.out.println("memberDto = " + memberDto);
}
List<UserDto> userDtos = queryFactory.select(Projections.bean(UserDto.class, member.name.as("username"), member.age))
.from(member)
.fetch();
for (UserDto userDto : userDtos) {
System.out.println("userDto = " + userDto);
}
}
2. 필드
- Projections.fields() 메서드를 사용한다.
@Test
void projectionDtoField() {
List<MemberDto> memberDtos = queryFactory.select(Projections.fields(MemberDto.class, member.name, member.age))
.from(member)
.fetch();
for (MemberDto memberDto : memberDtos) {
System.out.println("memberDto = " + memberDto);
}
List<UserDto> userDtos = queryFactory.select(Projections.fields(UserDto.class, member.name.as("username"), member.age))
.from(member)
.fetch();
for (UserDto userDto : userDtos) {
System.out.println("userDto = " + userDto);
}
}
3. 생성자
- Projections.constructor() 메서드를 사용한다.
- 지정한 프로젝션과 파라미터 순서가 같은 생성자가 필요하다.
@Test
void projectionDtoConstructor() {
List<MemberDto> memberDtos = queryFactory.select(Projections.constructor(MemberDto.class, member.name, member.age))
.from(member)
.fetch();
for (MemberDto memberDto : memberDtos) {
System.out.println("memberDto = " + memberDto);
}
List<UserDto> userDtos = queryFactory.select(Projections.constructor(UserDto.class, member.name, member.age))
.from(member)
.fetch();
for (UserDto userDto : userDtos) {
System.out.println("userDto = " + userDto);
}
}
4. @QueryProjection
- DTO 생성자에 @QueryProjection 어노테이션을 선언하고, Tasks -> other -> compileJava 를 실행하여 QMemberDto 클래스를 생성한다.
@Data
@NoArgsConstructor
public class MemberDto {
private String name;
private int age;
@QueryProjection
public MemberDto(String name, int age) {
this.name = name;
this.age = age;
}
}
@Test
void queryProjection() {
List<MemberDto> mem = queryFactory.select(new QMemberDto(member.name, member.age))
.from(member)
.fetch();
for (MemberDto memberDto : mem) {
System.out.println("memberDto = " + memberDto);
}
}
5. Distinct
- JPQL의 distinct와 같다.
@Test
void distinct() {
List<Member> result1 = queryFactory.selectDistinct(member)
.from(member)
.fetch();
List<Member> result2 = queryFactory.selectFrom(member).distinct()
.from(member)
.fetch();
List<Member> result3 = queryFactory.select(member).distinct()
.from(member)
.fetch();
}
10.4.10 수정, 삭제 배치 쿼리
- 수정, 삭제 쿼리는 영속성 컨텍스트에 있는 엔티티에 반영되지 않기 때문에, 쿼리 실행 후 영속성 컨텍스트를 초기화 하는것이 안전하다.
@Test
void update() {
long count = queryFactory.update(member)
.set(member.name, "none")
.where(member.age.goe(30))
.execute();
}
@Test
void delete() {
long count = queryFactory.delete(member)
.where(member.age.goe(30))
.execute();
}
10.4.11 동적 쿼리
1) BooleanBuilder
- BooleanBuilder를 사용하여 조건에 따라 동적 쿼리를 편리하게 생성할 수 있다.
@Test
void dynamicQueryBooleanBuilder() {
// select m.member_id, m.age, m.name, m.team_id from member m where m.name='member1' and m.age=10
List<Member> result1 = searchMember("member1", 10);
// select m.member_id, m.age, m.name, m.team_id from member m where m.name='member1';
List<Member> result2 = searchMember("member1", null);
}
private List<Member> searchMember(String name, Integer age) {
BooleanBuilder builder = new BooleanBuilder();
if (name != null) {
builder.and(member.name.eq(name));
}
if (age != null) {
builder.and(member.age.eq(age));
}
return queryFactory.selectFrom(member)
.where(builder)
.fetch();
}
2) where 다중 파라미터 사용
- where() 메서드에서 null 값은 무시된다.
- nameEq(), ageEq() 메서드를 다른 쿼리에서도 재활용 할 수 있다.
@Test
void dynamicQueryWhere() {
List<Member> result1 = searchMember2("member1", 10);
List<Member> result2 = searchMember2("member1", null);
}
private List<Member> searchMember2(String name, Integer age) {
return queryFactory.selectFrom(member)
.where(nameEq(name), ageEq(age))
.fetch();
}
private BooleanExpression nameEq(String name) {
return name != null ? member.name.eq(name) : null;
}
private BooleanExpression ageEq(Integer age) {
return age != null ? member.age.eq(age) : null;
}
10.4.12 메서드 위임
- 메서드 위임 기능을 사용하면 쿼리 타입에 검색 조건을 직접 정의할 수 있다.
- 먼저 정적 메서드를 만들고 @QueryDelegate 어노테이션 속성으로 적용할 엔티티를 지정한다.
- 정적 메서드의 첫 번째 파라미터는 대상 엔티티의 쿼리 타입을 지정하고 나머지는 필요한 파라미터를 정의한다.
public class MemberExpression {
@QueryDelegate(Member.class)
public static BooleanExpression isOlderThan(QMember member, Integer age) {
return member.age.gt(age);
}
}
- Tasks -> other -> compileJava 를 실행하여 QMember 클래스에 해당 기능이 추가된 것을 확인한다.
public class QMember extends EntityPathBase<Member> {
//...
public BooleanExpression isOlderThan(Integer age) {
return MemberExpression.isOlderThan(this, age);
}
}
- 아래와 같이 사용한다.
@Test
void delegateMethod() {
List<Member> members = queryFactory.selectFrom(member)
.where(member.isOlderThan(20))
.fetch();
System.out.println("members = " + members);
}
Leave a comment