본문 바로가기

SpringFramework/JPA

JPA - QueryDSL

Spring Boot 2.5.5 + QueryDSL 5.0.0 세팅 및 사용법 관련 설명 참고

=> https://xggames.tistory.com/57

 

(본문은 김영한님의 자바 ORM 표준 JPA 프로그래밍 서적을 참고하여 정리한 내용이며, QueryDSL의 상위 버전의 경우에는 적용되지 않을 수 있습니다)

 

QueryDSL은 JPA의 표준이 아닌 오픈소스 프로젝트이다. 따라서 Spring-Data-JPA에는 포함이 되어있지않고, 별도의 라이브러리 DI를 추가해줘야 한다. Criteria처럼 코드기반이면서도 단순하고 사용하기가 쉽다.

Criteria는 너무 복잡하고 어렵다는 단점이 있지만 QueryDSL은 쉽고 간결하며, 모양도 쿼리와 비슷하게 개발할 수 있어서 가독성을 높여주는 효과는 덤.

gradle QueryDSL 설정

// -- gradle 7.2, spring boot 2.5.x, java 11기준 --

plugins {
    ...
	id "com.ewerk.gradle.plugins.querydsl" version "1.0.10" // 플러그인 추가
    ...
}

dependencies { // dependency 추가
    ...
    implementation 'com.querydsl:querydsl-jpa' // QueryDSL JPA 라이브러리
    implementation 'com.querydsl:querydsl-apt' // 쿼리 타입 생성에 필요한 라이브러리
    ...
}

def querydslDir = "$buildDir/generated/querydsl" // querydsl에서 사용할 경로 설정

querydsl { // querydsl 설정 추가. (JPA사용여부, 경로)
    library = "com.querydsl:querydsl-apt"
    jpa = true
    querydslSourcesDir = querydslDir
}

sourceSets { // build시 사용할 sourceSet
    main {
        java {
            srcDirs = ['src/main/java', querydslDir]
        }
    }
}

compileQuerydsl { // querydsl 컴파일시 사용할 옵션
    options.annotationProcessorPath = configurations.querydsl
}

configurations { // querydsl이 compileClasspath를 상속하도록 설정
    querydsl.extendsFrom compileClasspath
}

- 기본 Q 생성

build.gradle에서 설정한 경로로 사용할 기본 QClass가 생성됨

public class QMember extends EntityPathBase<Member> {
    public static final QMember member = new QMember("m");
    ...
}

 

- 예제코드

EntityManagerFactory emf = Persistence.createEntityManagerFactory("QueryDSL_Start");
EntityManager em = emf.createEntityManager();

// 준비
JPAQuery query = new JPAQuery(em);
QMember qMember = new QMember("m"); // 쿼리타입 - 직접 지정
// QMember qMember = new QMember.member; // 기본 인스턴스 사용


// 쿼리, 결과조회
List<Member> members = query.from(qMember)
    .where(qMember.username.eq("xggames"))
    .orderBy(qMember.name.desc())
    .list(qMember);

쿼리 메소드

- 검색 조건

// 조건절 예제
JPAQuery query = new JPAQuery(em);
QItem item = QItem.item;
List<Item> list = query.from(item)
    .where(item.name.eq("치토스").and(item.price.gt(1500)))
    .list(item);

where() 메소드내 and() [.where(item.name.eq("치토스"), item.price.gt(1500)) 로도 사용 가능], or()를 사용할 수 있다.

    - between(1000, 2000) // 1000~2000원 사이

    - contains("치토스") // "치토스"를 포함하는.. (SQL의 LIKE '%치토스%' 와 같음)

    - startsWith("어니언") // "어니언"으로 시작하는.. (SQL의 LIKE '어니언%' 와 같음)

 

- 결과 조회

    - uniqueResult() : 조회 결과가 한 건일때 사용. 조회 결과가 없으면 null을 리턴하고, 2개이상이면 [com.mysema.query.NonUniqueResultException] 예외가 발생한다.

    - singleResult() : uniqueResult()메소드와 같지만, 결과가 2개이상일 때 예외가 아닌 첫번째 데이터를 반환한다.

    - list() : 결과가 하나 이상일 때 사용. 결과가 없으면 빈 컬렉션 리턴

 

- 페이징과 정렬

    - offset(int num), limit(int num) : offset(시작페이지), limit(노출 데이터 수)

    - restrict() : com.mysema.query.QueryModifiers 클래스를 파라미터로 사용

QueryModifiers queryModifiers = new QueryModifiers(20L, 10L); // limit, offset (순서에 유의)
List<Item> list = query.from(item)
    .restrict(queryModifiers)
    .list(item);

    - listResults() : 전체 데이터 조회를 위한 count 쿼리를 한번 더 실행하고, SearchResults 객체를 리턴하는데, 이 객체에서 각종 정보들을 조회할 수 있다.

SearchResults<Item> result = query.from(item)
    .where(item.price.gt(1500))
    .offset(10).limit(20)
    .listResults(item);

long total = result.getTotal(); // 검색된 전체 데이터 수
long limit = result.getLimit(); // 적용된 limit
long offset = result.getOffset(); // 적용된 Offset

List<Item> results = result.getResults(); // 조회된 데이터

 

- 그룹

    - groupBy(대상 객체 필드) : GROUP

    - having(조건절) : GROUP 조건절

 

- 조인

    - join(조인 대상, 별칭으로 사용할 쿼리 타입): (=innerJoin) 기본적인 내부 조인

    - leftJoin(): 외부조인(왼쪽)

    - rightJoin(): 외부조인(오른쪽)

    - on(): join on절. 조건절이 들어간다.

    - fetch(): 페치조인을 사용한다. 내부, 외부조인 뒤에 추가로 붙는다. [ join(order.member, member).fetch() ]

QOrder order = QOrder.order;
QMember member = QMember.member;
QOrderItem orderItem = QOrderItem.orderItem;

// 기본 조인
List<Order> order = query.from(order)
    .join(order.member, member)
    .leftJoin(order.orderItems, orderItem)
    .list(order);
    
// ON절 사용
List<Order> order = query.from(order)
    .leftJoin(order.orderItems, orderItem)
    .on(orderItem.count.gt(2))
    .list(order);
    
// 페치 조인
List<Order> order = query.from(order)
    .innerJoin(order.member, member).fetch()
    .leftJoin(order.orderItems, orderItem).fetch()
    .list(order);
    
// 세타 조인
query.from(order, member)
    .where(order.member.eq(member))
    .list(order);

 

- 서브 쿼리

: JPASubQuery 클래스를 생성하여 사용

    - 단건: unique()

    - 여러 건: list()

QItem item = QItem.item;
QItem itemSub = new QItem("itemSub");

// 단건
List<Item> list = query.from(item)
    .where(item.price.eq(
        new JPASubQuery().from(itemSub).unique(itemSub.price.max())
    )).list(item);

// 여러건
List<Item> list = query.from(item)
    .where(item.in(
        new JPASubQuery().from(itemSub)
        .where(item.name.eq(itemSub.name))
        .list(itemSub)
    )).list(item);

프로젝션과 결과 반환

: select 절에 조회 대상을 지정하는 것을 프로젝션이라 한다

- 프로젝션 대상이 하나

List<String> result = query.from(item).list(item.name);

 

- 프로젝션이 여러개 (Tuple 사용)

List<Tuple> result = query.from(item).list(item.name, item.price);

    => result[0].get(item.name); result[0].get(item.price);

    => list()안에 new QTuple()을 생성하여 사용가능

 

- 빈 생성

쿼리 결과를 엔티티가 아닌 특정 객체로도 받을 수 있다.

1] 프로퍼티 접근

    - Projections.bean() : 수정자(Setter)를 사용해서 값을 채워준다. (as()를 통해 별칭을 지정하여 필드와 프로퍼티명을 매핑해줄 수 있다.)

2] 필드 직접 접근

    - Projections.fields() : 필드에 직접 접근해서 값을 채워준다. 필드가 private 접근제어자여도 동작

3] 생성자 사용

    - Projections.constructor() : 생성자를 사용한다. 지정한 프로젝션과 파라미터 순서가 같은 생성자가 필요

@Entity
@Getter
@Setter
@AllArgsConstructor
public class ItemDto {
    private String username;
    private int price;
}

// 1] 프로퍼티 접근
QItem item = QItem.item;
List<ItemDto> result = query.from(item).list(
    Projections.bean(ItemDTO.class, item.name.as("username"), item.price)
);

// 2] 필드 직접 접근
List<ItemDTO> result = query.from(item).list(
    Projections.fields(ItemDTO.class, item.name.as("username"), item.price)
);

// 3] 생성자 사용
List<ItemDTO> result = query.from(item).list(
    Projections.constructor(ItemDTO.class, item.name.as("username"), item.price)
);

동적 쿼리

: com.mysema.query.BooleanBuilder 클래스를 사용하면 특정 조건에 따른 동적 쿼리를 생성할 수 있다.

// 검색 Class를 임의로 생성
SearchParam param = new SearchParam();
param.setName("닌텐도스위치");
param.setPrice(2500);

QItem item = QItem.item;
// BooleanBuilder 클래스 생성
BooleanBuilder builder = new BooleanBuilder();

if (StringUtils.hasText(param.getName())) { // 문자열인지 확인
    builer.and(item.name.contains(param.getName())); // param에 정의한 문자열 포함조건 추가
}

if (param.getPrice() != null) { // 가격정보 존재여부 확인
    builder.and(item.price.gt(param.getPrice())); // param에 정의한 가격조건 추가
}

// where절에 BooleanBuilder 변수 추가
List<Item> result = query.from(teim).where(builder).list(item);

메소드 위임

: 특정 조건 기능을 메소드로 구현하여 사용

    - 이 기능을 사용하려면 정적 메소드(static)를 사용하고, @QueryDelegate를 선언하여 적용할 엔티티를 파라미터에 지정한다.

public class ItemExpression {
    @QueryDelegate(Item.class)
    public static BooleanExpression isExpensive(QItem item, Integer price) {
        return item.price.gt(price);
    }
}

// Item.class에 정의한 isExpensive를 조건절로 사용
List<Item> list = query.from(item).where(item.isExpensive(25000)).list(item);

네이티브 SQL

JPQL은 표준 SQL이 지원하는 대부분의 문법과 SQL함수를 지원하면서도, 특정 DB에 종속적인 기능은 지원하지 않는다. (특정 DB 함수, 문법, UNION, INTERSECT, 프로시저 등) 많은 이유들로 표준화를 지원하지 못하는 경우도 있는데, 이때 네이티브 SQL를 사용한다.

해당되는 특정 DB에 맞게 SQL을 직접 작성하여 구현한다. 이 네이티브 SQL을 사용하면 엔티티를 조회할 수 있고, JPA가 지원하는 영속성 컨텍스트의 기능을 그대로 사용할 수 있다.

 

- 네이티브 SQL 사용

1] createNativeQuery(String sqlString, Class resultClass) : 결과 타입 정의

    - JPA는 공식적으로 네이티브 SQL에서는 이름 기반 파라미터를 지원하지 않고, 위치 기반 파라미터만 지원한다. (단, 하이버네이트 구현체를 사용했을 때 이름 기반파라미터를 사용할 수 있다)

2] createNativeQuery(String sqlString) : 결과 타입을 정의할 수 없는경우

3] createNativeQuery(String sqlString, String resultSetMapping) : 결과 매핑 사용

    - 여러 엔티티와 여러 컬럼을 매핑할 수 있다.

// SQL 정의
String sql = "SELECT id, age, name, team_id FROM Member WHERE age > ?";

// 결과 타입 정의
Query nativeQuery = em.createNativeQuery(sql, Member.class).setParameter(1, 20);
List<Member> resultList = nativeQuery.getResultList();

// 결과 타입을 정의할 수 없는경우
Query nativeQuery = em.createNativeQuery(sql).setParameter(1, 10);
List<Object[]> resultList = nativeQuery.getResultList();

// 결과 매핑 사용
String sql2 = 
    "SELECT m.id, age, name, team_id, i.order_count" +
    "FROM Member m " +
    "LEFT JOIN " +
    "    (SELECT im.id, COUNT(*) AS order_count " +
    "     FROM orders o, member im " +
    "     WHERE o.member_id = im.id) i" +
    "ON m.id = i.id";
    
Query nativeQuery = em.createNativeQuery(sql2, "memberWithOrderCount");
List<Object[]> resultList = nativeQuery.getResultList();

Member member = (Member)row[0];
BigInteger orderCount = (BigInteger)row[1];

@Entity
@SqlResultSetMapping(name = "memberWithOrderCount",
    entities = {@EntityResult(entityClass = Member.class)},
    columns = {@ColumnResult(name = "order_count")}
)
public class Member {
    // ...
}

Named 네이티브 SQL

: QueryDSL도 JPQL처럼 Named 정적 SQL을 작성할 수 있다.

- 네이티브 SQL도 JPQL을 사용할 때와 마찬가지로 Query, TypeQuery(Named 네이티브 쿼리의 경우에만)를 리턴한다. 따라서 JPQL API를 그대로 사용할 수 있다. (ex) 페이징처리 API 등..)

- 네이티브 SQL은 복잡한 쿼리나, 지원하지않는 방언 등.. 상황에 맞게 활용할 수 있다.

@Entity
@NamedNativeQuery(
    name = "MemberSelect",
    query = "SELECT id, age, name, team_id " +
        "FROM Member WHERE age > ?",
    resultClass = Member.class
)
public class Member {
    // ...
}

TypedQuery<Member> nativeQuery = em.createNamedQuery("MemberSelect", Member.class)
    .setParameter(1, 1001);

- NamedNativeQuery 어노테이션 속성

속성 기능
name Named 쿼리명 (필수)
query SQL 쿼리 (필수)
hints 벤더 종속적인 힌트
resultClass 결과 클래스
resultSetMapping 결과 매핑 사용

- 결과 매핑 + Named 쿼리 조합

@Entity
@SqlResultSetMapping(
    name = "memberWithOrderCount",
    entities = {@EntityResult(entityClass = Member.class)},
    columns = {@ColumnResult(name = "ORDER_COUNT")}
)
@NamedNativeQuery(
    name = "Member.memberWithOrderCount",
    query = "SELECT m.id, age, name, team_id, i.order_count " +
        "FROM Member m " +
        "LEFT JOIN " +
        "    (SELECT im.id, COUNT(*) AS order_count) " +
        "     FROM Orders o, Member im " +
        "     WHERE o.member_id = im.id) i " +
        "ON m.id = i.id",
    resultSetMapping = "memberWithOrderCount"
)
public class Member {
    // ...
}

List<Object[]> resultList = em.createNamedQuery("Member.memberWithOrderCount")
    .getResultList();

- 결과 매핑 어노테이션들 속성

속성 기능
@SqlResultSetMapping
name 결과 매핑 이름
entities @EntityResult를 사용해서 엔티티 결과로 매핑한다
columns @ColumnResult를 사용해서 컬럼을 결과로 매핑한다
@EntityResult
entityClass 결과로 사용할 엔티티 클래스를 지정한다
fields @FieldResult을 사용해서 결과 컬럼을 필드와 매핑한다
discriminatorColumn 엔티티의 인스턴스 타입을 구분하는 필드(상속에서 사용됨)
@FieldResult
name 결과를 받을 필드명
column 결과 컬럼명
@ColumnResult
name 결과 컬럼명

- 네이티브 SQL을 XML에 정의할 수도 있다. 예제쿼리는 짧지만 실제 실무쿼리는 길어서 코드가 복잡해진다. 이때 XML에 정의하여 깔끔하게 정리해서 사용할 수 있다.

프로시저 호출

// 이런 프로시저가 있다고 하자. (MySQL 기준)
/*
DELIMITER //

CREATE PROCEDURE proc_process (INOUT inParam INT, INOUT outParam INT)
BEGIN
    SET outParam = inParam * 2;
END //
*/

StoredProcedureQuery spq = em.createStoredProcedureQuery("proc_process"); // 사용할 프로시저명 입력

// registerStoredProcedureParameter(순서, 타입, 파라미터 모드) 정의
spq.registerStoredProcedureParameter(1, Integer.class, ParameterMode.IN);
spq.registerStoredProcedureParameter(2, Integer.class, ParameterMode.OUT);

/* 파라미터에 이름 사용도 가능
    spq.registerStoredProcedureParameter("inParam", Integer.class, ParameterMode.IN);
    spq.registerStoredProcedureParameter("outParam", Integer.class, ParameterMode.OUT);
    
    spq.setParameter("inParam", 100);
*/

spq.setParameter(1, 100);
spq.execute();

Integer out = (Integer)spq.getOutputParameterValue(2);

참고자료

- 서적 - 자바 ORM 표준 JPA 프로그래밍 제 10장 - 김영한 지음

- https://jaime-note.tistory.com/67 (QueryDSL gradle 설정방법)

- https://www.inflearn.com/questions/219898

'SpringFramework > JPA' 카테고리의 다른 글

JPA - N+1 문제  (0) 2021.11.29
JPA - 스프링 데이터 JPA  (0) 2021.11.23
JPA - Criteria  (0) 2021.11.19
JPA - 객체지향 쿼리 언어, JPQL  (0) 2021.11.17
JPA - 값타입  (0) 2021.11.16