객체지향 쿼리 언어 - Querydsl

10. 객체지향 쿼리 언어

10.4 Querydsl

10.4.1 Querydsl 설정

1) 필요 라이브러리

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

Tags:

Categories:

Updated:

Leave a comment