course/inflearn

[실전! Querydsl] 기본 문법

hjkim0502 2022. 11. 22. 01:54
  • 기초 설정
    • 스프링, Querydsl 버전에 따라 설정하는 법이 계속 바뀜
    • Q타입 생성 확인 후 테스트 케이스로 실행 검증
    • h2 설치 및 설정 -> sql 로그 출력, 쿼리 파라미터 로그 출력(운영 단계에서는 성능 테스트하고 사용)
# application.yml
spring:
  profiles:
    active: local
  datasource:
    url: jdbc:h2:tcp://localhost/~/querydsl
    username: sa
    password:
    driver-class-name: org.h2.Driver

  jpa:
    hibernate:
      ddl-auto: create
    properties:
      hibernate:
#        show_sql: true
        format_sql: true
        use_sql_comments: true
logging.level:
  org.hibernate.SQL: debug
  # org.hibernate.type: trace

 

* extract String: ctrl + alt + V

 

JPQL vs Querydsl

  • 컴파일 시점에 쿼리 코드 오류를 잡아줌
  • 파라미터 바인딩 자동 처리
@Test
public void startJPQL() {
    //member1 찾기
    String qlString =
            "select m from Member m " +
            "where m.username = :username";
    Member findMember = em.createQuery(qlString, Member.class)
            .setParameter("username", "1")
            .getSingleResult();

    Assertions.assertThat(findMember.getUsername()).isEqualTo("1");
}

@Test
public void startQuerydsl() {
    //member1 찾기
    //JPAQueryFactory queryFactory = new JPAQueryFactory(em); // EntityManager로 JPAQueryFactory 생성
    QMember m = new QMember("m");

    Member findMember = queryFactory // Java 코드로 쿼리 생성 -> 컴파일 시점에서 오류 검증
            .select(m)
            .from(m)
            .where(m.username.eq("1")) // 자동 파라미터 바인딩
            .fetchOne();

    Assertions.assertThat(findMember.getUsername()).isEqualTo("1");
}
  • JPAQueryFactory를 필드로 두고 사용
    • entity manager 자체적으로 멀티 쓰레드의 동시성 문제를 해결하도록 설계되어있어 상관 없음
    • 스프링은 여러 쓰레드에서 동시에 같은 em에 접근해도 트랜잭션마다 별도의 영속성 컨텍스트를 제공하기 때문
  • Q타입 활용
    • 같은 테이블을 조인하는 경우에만 별칭 직접 지정하여 사용
QMember m = new QMember("m"); // 별칭 직접 지정
QMember m = QMember.member; // 기본 인스턴스 사용

Member findMember = queryFactory
        .select(m)
        .from(m)
        .where(m.username.eq("1"))
        .fetchOne();
        
// static import 권장!
import static study.querydsl.entity.QMember.member;

Member findMember = queryFactory // Java 코드로 쿼리 생성 -> 컴파일 시점에서 오류 검증
        .select(member)
        .from(member)
        .where(member.username.eq("1")) // 자동 파라미터 바인딩
        .fetchOne();
// jpql 쿼리문도 확인하고 싶다면 다음과 같이 설정
spring.jpa.properties.hibernate.use_sql_comments: true

 

검색 조건 쿼리

  • where()
@Test
public void search() {
    Member findMember = queryFactory
            .selectFrom(member)
            .where(member.username.eq("2")
                    .and(member.age.eq(10)))
            .fetchOne();

    assertThat(findMember.getUsername()).isEqualTo("2");
}
  • 검색 조건은 .and() 또는 .or()를 메서드 체인으로 연결 가능
  • select, from을 selectFrom으로 합칠 수 있음
// JPQL이 제공하는 모든 검색 조건 제공
member.username.eq("member1") // username = 'member1'
member.username.ne("member1") // username != 'member1'
member.username.eq("member1").not() // username != 'member1'

member.username.isNotNull() //이름이 is not null

member.age.in(10, 20) // age in (10,20)
member.age.notIn(10, 20) // age not in (10, 20)
member.age.between(10,30) //between 10, 30

member.age.goe(30) // age >= 30
member.age.gt(30) // age > 30
member.age.loe(30) // age <= 30
member.age.lt(30) // age < 30

member.username.like("member%") // like 검색
member.username.contains("member") // like ‘%member%’ 검색
member.username.startsWith("member") //like ‘member%’ 검색
// where()에 AND 조건을 파라미터로 처리
// 이 경우 null 값 무시 -> 동적 쿼리에 유리
@Test
public void searchAndParam() {
    List<Member> result = queryFactory
            .selectFrom(member)
            .where(member.username.eq("2"), member.age.eq(10))
            .fetch();

    assertThat(result.size()).isEqualTo(1);
}

 

결과 조회

  • fetch()
  • fetchOne()
  • fetchFirst()
  • fetchResults()
  • fetchCount()
// 리스트 조회
// 결과 없으면 빈 리스트 반환
List<Member> fetch = queryFactory
        .selectFrom(member)
        .fetch();

// 단 건 조회
// 결과가 없으면 null 반환
// 결과가 둘 이상이면 NonUniqueResultException
Member fetchOne = queryFactory
        .selectFrom(member)
        .fetchOne();

// 첫 요소 조회
Member fetchFirst = queryFactory
        .selectFrom(member)
        .fetchFirst();
        //.limit(1).fetch()

// 페이징에 사용
// count 쿼리 이후 데이터 조회 쿼리 실행 (2번)
// 복잡한 상황에서는 쿼리 따로 생성
QueryResults<Member> results = queryFactory
        .selectFrom(member)
        .fetchResults(); // 결과 개수가 꼭 필요한 경우가 아니면 그냥 fetch 사용

results.getTotal(); // 개수: select 절에서 member_id만 사용
results.getResults(); // 결과 목록: select 절에서 엔티티의 모든 요소 사용

// count 쿼리로 변경
long count = queryFactory
        .selectFrom(member)
        .fetchCount();

 

정렬

  • orderBy()
/**
 * 회원 정렬 순서
 * 1. 회원 나이 내림차순(desc)
 * 2. 회원 이름 올림차순(asc)
 * 단, 2에서 회원 이름이 없으면 마지막에 출력(nulls last)
 */
@Test
public void sort() {
    em.persist(new Member(null, 100));
    em.persist(new Member("5", 100));
    em.persist(new Member("6", 100));

    List<Member> result = queryFactory
            .selectFrom(member)
            .where(member.age.eq(100))
            .orderBy(member.age.desc(), member.username.asc().nullsLast())
            .fetch();

    Member member5 = result.get(0);
    Member member6 = result.get(1);
    Member memberNull = result.get(2);

    assertThat(member5.getUsername()).isEqualTo("5");
    assertThat(member6.getUsername()).isEqualTo("6");
    assertThat(memberNull.getUsername()).isNull();
}

 

페이징

  • offset(), limit()
  • 데이터를 조회할 때 여러 테이블을 조인하는 경우가 있는데, 같은 상황에서 count 쿼리는 조인이 필요없는 경우 성능을 위해 count 쿼리는 따로 작성하는 것이 좋다
@Test
public void paging1() {
    List<Member> result = queryFactory
            .selectFrom(member)
            .orderBy(member.username.desc())
            .offset(1) //0부터 시작
            .limit(2) //최대 2건 조회
            .fetch();

    assertThat(result.size()).isEqualTo(2);
}

@Test
public void paging2() {
    QueryResults<Member> queryResults = queryFactory
            .selectFrom(member)
            .orderBy(member.username.desc())
            .offset(1) //0부터 시작
            .limit(2) //최대 2건 조회
            .fetchResults();

    assertThat(queryResults.getTotal()).isEqualTo(4);
    assertThat(queryResults.getLimit()).isEqualTo(2);
    assertThat(queryResults.getOffset()).isEqualTo(1);
    assertThat(queryResults.getResults().size()).isEqualTo(2);
}

 

집합

  • 집합 함수: JPQL이 제공하는 모든 집합 함수 제공
  • select 절에서 가져오는 데이터들의 타입이 다양할 수 있기 때문에 반환값이 tuple 형식
/**
* JPQL
* select
* COUNT(m), //회원수
* SUM(m.age), //나이 합
* AVG(m.age), //평균 나이
* MAX(m.age), //최대 나이
* MIN(m.age) //최소 나이
* from Member m
*/
@Test
public 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(100);
    assertThat(tuple.get(member.age.avg())).isEqualTo(25);
    assertThat(tuple.get(member.age.max())).isEqualTo(40);
    assertThat(tuple.get(member.age.min())).isEqualTo(10);
}

* settings > Live Templates 에서 테스팅 템플릿 생성

@Test
public void $NAME$() {
    //given
    $END$
    //when
    
    //then
}
  • group by
    • group by 절에 지정된 컬럼의 값이 같은 행에 대해 집합 함수를 적용하여 계산
    • 여러 컬럼을 그룹화 할 경우 해당 컬럼들의 값이 모두 같아야 같은 그룹으로 묶임
    • having 절은 집합 함수를 포함하고, 그룹화된 결과에 조건을 부여
    • https://keep-cool.tistory.com/37
/**
 * 팀의 이름과 각 팀의 평균 연령을 구해라
 */
@Test
public void group() {
    List<Tuple> result = queryFactory
            .select(team.name, member.age.avg())
            .from(member)
            .join(member.team, team)
            .groupBy(team.name)
            .fetch();

	// 팀 이름으로 그룹화했기 때문에 튜플 두개 반환
    Tuple teamA = result.get(0);
    Tuple teamB = result.get(1);

    assertThat(teamA.get(team.name)).isEqualTo("A");
    assertThat(teamA.get(member.age.avg())).isEqualTo(15);

    assertThat(teamB.get(team.name)).isEqualTo("B");
    assertThat(teamB.get(member.age.avg())).isEqualTo(35);
}

//having 예시
//아이템 가격으로 그룹화한 후 그 가격이 1000 초과되는 것만 결과로
    ...
    .groupBy(item.price)
    .having(item.price.gt(1000))
    ...

 

조인 - 기본 조인

join(조인 대상, 별칭으로 사용할 Q 타입)
  • 내부 조인: join() ≡ innerJoin()
  • 외부 조인: leftJoin(), rightJoin()
/**
 * 팀 A에 소속된 모든 회원 조회
 */
@Test
public void join() {
    List<Member> result = queryFactory
            .selectFrom(member)
            .join(member.team, team)
            .where(team.name.eq("A"))
            .fetch();

    assertThat(result)
            .extracting("username")
            .containsExactly("1", "2");
}
  • 세타 조인: 연관관계 없는 필드로 조인
    • from 절에 엔티티 나열
    • 외부 조인 불가능 -> 조인 on 사용하여 해결
/**
 * 회원의 이름이 팀 이름과 같은 회원 조회
 */
@Test
public void thetaJoin() {
    em.persist(new Member("A"));
    em.persist(new Member("B"));

    List<Member> result = queryFactory
            .select(member)
            .from(member, team) // from 절에서 관계 없는 두 객체 나열
            .where(member.username.eq(team.name))
            .fetch();

    //assertThat(result.get(0).getUsername()).isEqualTo("A");
    //assertThat(result.get(1).getUsername()).isEqualTo("B");

    assertThat(result)
            .extracting("username")
            .containsExactly("A", "B");
}

 

조인 - on 절(JPA2.1 이상)

  • 조인 대상 필터링
    • 외부 조인이 필요한 경우만 on 절 사용
    • 내부 조인의 경우 익숙한 where 절 사용
    • 예시에서는 team이 조인 대상
/**
 * 회원과 팀을 조인하면서, 팀 이름이 A인 팀만 조인, 회원은 모두 조회
 * JPQL: SELECT m, t FROM Member m LEFT JOIN m.team t on t.name = 'A'
 */
@Test
public void joinOnFiltering() {
    List<Tuple> result = queryFactory
            .select(member, team)
            .from(member)
            .leftJoin(member.team, team).on(team.name.eq("A"))
            .fetch();

    for (Tuple tuple : result) {
        System.out.println("tuple = " + tuple);
    }
}
//결과
tuple = [Member(id=3, username=1, age=10), Team(id=1, name=A)]
tuple = [Member(id=4, username=2, age=20), Team(id=1, name=A)]
tuple = [Member(id=5, username=3, age=30), null]
tuple = [Member(id=6, username=4, age=40), null]
//참고
//내부 조인으로 바꾸면 A에 속한 멤버만 반환
...
.join(member.team, team)
.on(team.name.eq("A"))
//.where(team.name.eq("A")) //on절과 내부조인을 사용하는 것은 where로 필터링하는 것과 동일
...

//결과
tuple = [Member(id=3, username=1, age=10), Team(id=1, name=A)]
tuple = [Member(id=4, username=2, age=20), Team(id=1, name=A)]
  • 연관관계 없는 엔티티 외부 조인(hibernate 5.1 이상)
/**
 * 회원의 이름이 팀 이름과 같은 회원 조회 (외부 조인)
 */
@Test
public void joinOnNoRelation() {
    em.persist(new Member("A"));
    em.persist(new Member("B"));

    List<Tuple> result = queryFactory
            .select(member, team)
            .from(member)
            .leftJoin(team).on(member.username.eq(team.name))
            .fetch();

    for (Tuple tuple : result) {
        System.out.println("tuple = " + tuple);
    }
}
// 결과
// 멤버는 모두 가져오고, 조인 대상인 team은 on절의 조건에 맞는 팀만 조회
tuple = [Member(id=3, username=1, age=10), null]
tuple = [Member(id=4, username=2, age=20), null]
tuple = [Member(id=5, username=3, age=30), null]
tuple = [Member(id=6, username=4, age=40), null]
tuple = [Member(id=7, username=A, age=0), Team(id=1, name=A)]
tuple = [Member(id=8, username=B, age=0), Team(id=2, name=B)]
//일반 조인
leftJoin(member.team, team)
//on 조인
//서로 연관관게가 없기 때문에 그냥 team만 명시
leftJoin(team).on(...)

 

조인 - 페치 조인

  • 연관된 엔티티를 SQL 한번에 조회하여 성능 최적화를 위한 기능
  • join 문 뒤에 .fetchJoin()만 연결해주면 됨
@PersistenceUnit
EntityManagerFactory emf;

@Test
public void fetchJoinNo() {
    em.flush();
    em.clear();

    Member findMember = queryFactory
            .selectFrom(member)
            .where(member.username.eq("1"))
            .fetchOne();

    boolean loaded = emf.getPersistenceUnitUtil().isLoaded(findMember.getTeam());
    assertThat(loaded).as("페치 조인 미적용").isFalse();
}

@Test
public void fetchJoinYes() {
    em.flush();
    em.clear();

    Member findMember = queryFactory
            .selectFrom(member)
            .join(member.team, team).fetchJoin()
            .where(member.username.eq("1"))
            .fetchOne();

    boolean loaded = emf.getPersistenceUnitUtil().isLoaded(findMember.getTeam());
    assertThat(loaded).as("페치 조인 적용").isTrue();
}

 

서브 쿼리: 쿼리 속 쿼리

  • JPAExpressions 사용

 

  • where 절
/**
 * 나이가 가장 많은 회원 조회
 */
@Test
public void subQuery() {
    QMember memberSub = new QMember("memberSub"); // 별칭 안 겹치게

    List<Member> result = queryFactory
            .selectFrom(member)
            .where(member.age.eq(
                    JPAExpressions
                            .select(memberSub.age.max())
                            .from(memberSub)
            ))
            .fetch();

    assertThat(result).extracting("age")
            .containsExactly(40);
}

/**
 * 나이가 평균 이상인 회원 조회
 */
@Test
public void subQueryGoe() {
    QMember memberSub = new QMember("memberSub"); // 별칭 안 겹치게

    List<Member> result = queryFactory
            .selectFrom(member)
            .where(member.age.goe(
                    JPAExpressions
                            .select(memberSub.age.avg())
                            .from(memberSub)
            ))
            .fetch();

    assertThat(result).extracting("age")
            .containsExactly(30, 40);
}

/**
 * 여러 건 처리, in 사용
 */
@Test
public void subQueryIn() {
    QMember memberSub = new QMember("memberSub"); // 별칭 안 겹치게

    List<Member> result = queryFactory
            .selectFrom(member)
            .where(member.age.in(
                    JPAExpressions
                            .select(memberSub.age)
                            .from(memberSub)
                            .where(memberSub.age.gt(10)) // 여러개 반환(20, 30, 40)
            ))
            .fetch();

    assertThat(result).extracting("age")
            .containsExactly(20, 30, 40);
}
  • select 절
    • hibernate 지원
@Test
public void selectSubquery() {
    QMember memberSub = new QMember("memberSub"); // 별칭 안 겹치게

    List<Tuple> result = queryFactory
            .select(member.username,
                    select(memberSub.age.avg()) // static import
                            .from(memberSub))
            .from(member)
            .fetch();

    for (Tuple tuple : result) {
        System.out.println("tuple = " + tuple);
    }
}
  • from 절
    • JPA JPQL의 한계로 from 절에서는 서브쿼리 불가능
    • 1. 서브쿼리를 join으로 변경 (불가능한 경우도 있음)
    • 2. 애플리케이션 쿼리를 2번 분리해서 실행
    • 3. 네이티브 SQL 사용
  • 화면에서 필요한 데이터에 딱 맞게 DB에서 조회하려다 보면 너무 복잡한 쿼리가 나오는 경우가 많으므로, DB에서는 최소한의 필수적인 필터링&그루핑하고 나머지는 애플리케이션이나 프레젠테이션 단에서 해결

 

case 문

  • 단순: when(), then(), otherwise()
  • 복잡: CaseBuilder() 생성
  • select 절, 조건절(where), order by에서 사용 가능
  • 웬만하면 애플리케이션이나 프레젠테이션 단에서 해결하길 권장
//단순 조건
@Test
public void basicCase() {
    List<String> result = queryFactory
            .select(member.age
                    .when(10).then("열살")
                    .when(20).then("스무살")
                    .otherwise("기타"))
            .from(member)
            .fetch();

    for (String s : result) {
        System.out.println("s = " + s);
    }
}

//복잡 조건
@Test
public void complexCase() {
    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();

    for (String s : result) {
        System.out.println("s = " + s);
    }
}
  • orderBy 예제
    • rankPath 처럼 복잡한 조건을 변수로 선언해서 select 절, orderBy 절에서 사용 가능
/**
 * 회원 조회 우선순위
 * 1. 0 ~ 30살이 아닌 회원
 * 2. 0 ~ 20살인 회원
 * 3. 21 ~ 30살인 회원
 */
@Test
public void OrderByCase() {
    NumberExpression<Integer> rankPath = new CaseBuilder()
            .when(member.age.between(0, 20)).then(2) // 2순위
            .when(member.age.between(21, 30)).then(1) // 3순위
            .otherwise(3); // 1순위

    List<Tuple> result = queryFactory
            .select(member.username, member.age, rankPath)
            .from(member)
            .orderBy(rankPath.desc())
            .fetch();

    for (Tuple tuple : result) {
        String username = tuple.get(member.username);
        Integer age = tuple.get(member.age);
        Integer rank = tuple.get(rankPath);
        System.out.println("username = " + username + "age = " + age + "rank = " + rank);
    }
}

 

상수, 문자 더하기

  • 상수 필요할 때: Expressions.constant() 사용
  • 문자 더하기: concat()
@Test
public void constant() {
    List<Tuple> result = queryFactory
            .select(member.username, Expressions.constant("A")) //SQL문에 해당 상수 안 넘어감
            .from(member)
            .fetch();

    for (Tuple tuple : result) {
        System.out.println("tuple = " + tuple);
    }
}

@Test
public void concat() {
    String result = queryFactory
            .select(member.username.concat("_").concat(member.age.stringValue())) // 나이는 숫자이므로 형 변환
            .from(member)
            .where(member.username.eq("1"))
            .fetchOne();

    assertThat(result).isEqualTo("1_10");
}
  • stringValue()를 문자로 형변환 할 때 자주 사용하며, 특히 ENUM 처리할 때 유용하다