본문 바로가기
JPA/자바 ORM 표준 JPA 프로그래밍

Chap.10 객체지향 쿼리 언어 - 2

by devwari 2021. 7. 5.

10.3 Criteria

- Criteria 쿼리는 JPQL을 자바 코드로 작성하도록 도와주는 빌더 클래스 API다.

- Criteria를 사용하면 문자가 아닌 코드로 JPQL을 작성하므로 문법 오류를 컴파일 단계에서 잡을 수 있고 문자 기반의 JPAL보다 동적 쿼리를 안전하게 생성할 수 있는 장점이 있다. 하지만 실제 Criteria를 사용해서 개발해보면 코드가 복잡하고 장황해서 직관적으로 이해가 힘들다는 단점도 있다.

- Criteria는 결국 JPQL의 생성을 돕는 클래스 모음이다. 따라서 내용 대부분이 JPQL과 중복되므로 사용법 위주로 알아보자.

// JPQL: select m from Member m

CriteriaBuilder cb = em.getCriteriaBuilder(); // Criteria 쿼리 빌다

// Criteria 생성, 반환 타입 지정
CriteriaQuery<Mmeber> cq = cb.CREATEqUERY(Member.class);

// m이 쿼리 루트, 쿼리 루트는 조회의 시작점
Root<Member> m = cq.from(Member.class); // FROM 절

// 검색 조건 정의
Predicate usernameEqual = cb.equal(m.get("username"), "회원1");

// 정렬 조건 정의
javax.persistence.criteria.Order ageDesc = cb.desc(m.get("age"));

// 쿼리 생성
cq.select(m).where(usernameEqual).orderBy(ageDesc);

TypeQuery<Member> query = em.createQuery(cq).getResultList();

 

10.4 QueryDSL

- JPA Criteria는 문자가 아닌 코드로 JPQL을 작성하므로 문법 오류를 컴파일 단계에서 잡을 수 있고 IDE 자동완성 기능의 도움을 받을 수 있는 등 여러 가지 장점이 있다. 하지만 Criteria의 가장 큰 단점은 너무 복잡하고 어렵다는 것이다. 작성된 코드를 보면 그 복잡성으로 인해 어떤 JPQL이 생성될지 파악하기가 쉽지 않다. 쿼리를 문자가 아닌 코드로 작성해도, 쉽고 간결하며 그 모양도 쿼리와 비슷하게 개발할 수 있는 프로젝트가 QueryDSL이다. QueryDSL도 Criteria처럼 JPQL 빌더 역할을 하는데 JPA Crtieria를 대체할 수 있다.

 

- QueryDSL은 오픈 소스 프로젝트다. 처음에는 HQL(하이버네이트 쿼리언어)을 코드로 작성할 수 있도록 해주는 프로젝트로 시작해서 지금은 JPA, JDO, JDBC, Lucene, Hibernate Search, 몽고DB, 자바 컬렉션 등을 다양하게 지원한다. 참고로 QueryDSL은 이름 그대로 쿼리 즉 데이터를 조회하는 데 기능이 특화되어 있다.

 

- 필요 라이브러리

    // QueryDSL
    implementation 'com.querydsl:querydsl-core'
    implementation 'com.querydsl:querydsl-jpa'
    // querydsl JPAAnnotationProcessor 사용 지정, 쿼리 타입(Q)을 생성할 때 필요한 라이브러리
    annotationProcessor "com.querydsl:querydsl-apt:${dependencyManagement.importedProperties['querydsl.version']}:jpa"
    // java.lang.NoClassDefFoundError(javax.annotation.Entity) 발생 대응
    annotationProcessor 'jakarta.persistence:jakarta.persistence-api'
    // java.lang.NoClassDefFoundError (javax.annotation.Generated) 발생 대응
    annotationProcessor 'jakarta.annotation:jakarta.annotation-api'

 

- 시작

public void queryDSL() {
	EntityManger em = emf.createEntityManger();
    
    JPAQuery query = new JPAQuery(em);
    QMember qMember = new QMember("m"); // 생성되는 JPQL의 별칭이 m
    List<Member> members = 
    	query.from(qMember).where(qMember.name.eq("회원1")).orderBy(qMember.name.desc()).list(qMember);
}

// 실행된 JPQL
select m from Member m
where m.name = ?1
order by m.name desc

 

- 기본 Q 생성

: 쿼리 타입(Q)은 사용하기 편리하도록 기본 인스턴스를 보관하고 있다. 하지만 같은 엔티티를 조인하거나 같은 엔티티를 서브쿼리에서 사용하면 같은 별칭이 사용되므로 이때는 별칭을 직접 지정해서 사용해야 한다.

/**
 * QCompany is a Querydsl query type for Company
 */
@Generated("com.querydsl.codegen.EntitySerializer")
public class QCompany extends EntityPathBase<Company> {

    private static final long serialVersionUID = 1227526672L;

    private static final PathInits INITS = PathInits.DIRECT2;

    public static final QCompany company = new QCompany("company");

: 쿼리 타입

QMember qMember = new QMember("m"); // 직접 지정
QMember qMember = QMember.member; // 기본 인스턴스 사용 -> import static을 활용해서 코드를 더 간결하게 작성할 수 있다.

 

- 검색 조건 쿼리

JPAQuery query = new JPAQuery(em);
QItem item = QItem.item;
List<Item> list = query.from(item).where(item.name.eq("좋은상품").and(item.price.gt(20000))).list(item); // 조회할 프로젝션 지정

// 실행된 JPQL
select item from Item item where item.name = ?1 and item.price >?2

 

- 결과 조회

: uniqueResult(): 조회 결과가 한 건일 때 사용한다. 조회 결과가 없으면 null을 반환하고 결과가 하나 이상이면 com.mysema.query.NonUniqueResultException 예외가 발생한다.

: singelResult(): uniqueResult()와 같지만 결과가 하나 이상이면 처음 데이터를 반환한다.

: list(): 결과가 하나 이상일 때 사용한다. 결과가 없으면 빈 컬렉션을 반환한다.

 

- 페이징과 정렬

: listResults()를 사용하면 전체 데이터 조회를 위한 count 쿼리를 한 번 더 실행한다. 그리고 SearchResults를 반환하는데 이 객체에서 전체 데이터 수를 조회할 수 있다.

// 페이징과 정렬
QItem item = QItem.item;

query.from(item)
.where(item.price.gt(20000)).orderBy(item.price.desc(), item.stockQuantity.asc())
.offset(10).limit(20)
.list(item);

// 페이징과 정렬 QueryModifiers 사용
QueryModifiers queryModifiers = new QueryModifiers(20L, 10L); // limit, offset
List<Item> list = query.from(item).restrict(queryModifiers).list(item);

// 페이징과 정렬 listResults() 사용
// 실제 페이징 처리를 하려면 검색된 전체 데이터 수를 알아야 하기 때문에 list() 대신에 listResults() 사용
SearchResults<Item> result =
query.from(item).where(item.price.gt(10000)).offset(10).limit(20).listResults(item);

ling total = result.getTotal(); // 검색된 전체 데이터 수
long limit = result.getLimit();
long offset = result.getOffset();
List<Item> results = result.getResults(); // 조회된 데이터

 

- 그룹

: groupBy, having

 

- 조인

: innerJoin(join), leftJoin, rigjtJoin, fullJoin, JPQL의 on과 성능 최적화를 위한 fetch 조인 사용할 수 있다.

: 조인의 기본 문법은 첫 번쨰 파라미터에 조인 대상을 지정하고, 두 번째 파라미터에 별칭으로 사용할 쿼리 타입을 지정하면 된다. ex) join(조인 대상, 별칭으로 사용할 쿼리 타입)

// 기본 조인
QOrder order = QOrder.order;
QMember member = QMember.member;
QOrderItem orderItem = QOrderItem.orderItem;

query.from(order)
	.join(order.member, member)
    .leftJoin(order.orderItems, orderItem)
    .list(order);
   
// 조인 on 사용
query.from(order)
	.leftJoin(order.orderItems, orderItem)
    .on(orderItem.count.gt(2))
    .list(order);
    
// 페치 조인 사용
query.from(order)
	.innerJoin(order.member, member).fetch()
    .leftJoin(order.orderItems, orderItem).fetch()
    .list(order);

// from 절에 여러 조건 사용
QOrder order = QOrder.order;
QMember member = QMember.member;

query.from(order, member)
	.where(order.member.eq(member))
    .list(order);

 

- 서브 쿼리

: com.mysema.query.jpa.JPASubQuery를 생성해서 사용한다. 서브 쿼리의 결과가 하나면 unique(), 여러 건이면 list()를 사용할 수 있다.

// 서브 쿼리 예제 - 한건
QItem item = QItem.item;
QItem itemSub = new QItem("itemSub");

query.from(item)
	.where(item.price.eq(
    			new JPASubQuery().from(itemSub).unique(itemSub.price.max())
          ))
          .list(item);

// 서브 쿼리 예제 - 여러 건
QItem item = QItem.item;
QItem itemSub = new QItem("itemSub");

query.from(item)
	.where(item.in(
    	new JPASubQuery().from(itemSub)
        	.where(item.name.eq(itemSub.name))
            .list(itemSub)
      ))
      .list(item);

 

- 프로젝션과 결과 반환

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

// 프로젝션 대상이 하나면 해당 타입으로 반환한다.
QItem item = QItem.item;
List<String> result = query.form(item).list(item.name);

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

// 여러 컬럼 반환과 튜플
// 프로젝션 대상으로 여러 필드를 선택하면 QueryDSL은 기본으로 com.mysema.query.Tuple이라는 Map과 비슷한 내부 타입을 사용한다.
/// 조회 결과는 tuple.get() 메소드에 조회한 쿼리 타입을 지정하면 된다.
QItem item = QItem.item;

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

for (Tupe tupe : result) {
	System.out.println("name = " + tuple.get(item.name));
    System.out.pprintln("price = " + tuple.get(item.price));
}

 

- 빈 생성

: 쿼리 결과를 엔티티가 아닌 특정 객체로 받고 싶으면 빈 생성 기능을 사용한다. QueryDSL은 객체를 생성하는 다양한 방법을 제공한다.

1) 프로퍼티 접근

2) 필드 직접 접근

3) 생성자 사용

// 프로퍼티 접근(Setter)
QItem item = QItem.item;
List<ItemDTO> result = query.from(item).list(
	Projections.bean(ItemDTO.class, item.name.as("username"), item.price));
    
// 필드 직접 접근
QItem item = QItem.item;
List<ItemDTO> result = query.from(item).list(
	Projecctsion.fields(ItemDTO.class, item.name.as("username"), item.price)):

// 생성자 사용
QItem item = QItem.item;
List<ItemDTO> reuslt = query.from(item).list(
	Proejections.constructor(ItemDTO.class, item.name, item.price)
   );

 

- DISTINCT

query.distinct().from(item)...

 

- 수정, 삭제 배치 쿼리

: QueryDSL도 수정, 삭제 같은 배치 쿼리를 지원한다. JPQL 배치 쿼리와 같이 영속성 컨텍스트를 무시하고 데이터베이스를 직접 쿼리한다는 점에 유의하자.

: com.mysema.query.jpa.impl.JPAUpdateClause, com.mysema.query.jpa.impl.JPADeleteClause 사용

 

- 동적 쿼리

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

 

- 메소드 위임

: 메소드 위임 기능을 사용하면 쿼리 타입에 검색 조건을 직접 정의할 수 있다.

// 검색 조건 정의
public class ItemExpression {
	@QueryDelegate(Item.class)
    public static BooleanExpression isExpensive(QItem item, Integer price) {
    	return item.price.gt(price);
    }
}

query.form(item).where(item.isExpensive(30000)).list(item);

// 자바 기본 내장 타입에도 메소드 위임 기능 사용 가능
@QueryDelegate(String.class)
public static BooleanExpression isHelloStart(StringPath stringPath) {
	return stringPath.startsWith("Hello");
}

 

10.5 네이티브 SQL

- JPQL은 표준 SQL이 지원하는 대부분의 문법과 SQL 함수들을 지원하지만 특정 데이터베이스에 종속적인 기능은 지원하지 않는다.

: 특정 데이터베이스만 지원하는 함수, 문법, SQL 쿼리 힌트

: 인라인 뷰(From 절에서 사용하는 서브쿼리), UNION, INTERSECT

: 스토어드 프로시저

- 네이티브 SQL을 사용하면 엔티티를 조회할 수 있고 JPA가 지원하는 영속성 컨텍스트의 기능을 그대로 사용할 수 있다.

 

- 네이티브 SQL 사용

: 엔티티 조회

em.createNativeQuery(SQL, 결과 클래스);

위치 기반 파라미터만 지원함

네이티브 SQL로 SQL만 직접 사용할 뿐이지 나머지는 JPQL을 사용할 떄와 같다. 조회한 엔티티 영속성 컨텍스트에서 관리됨

: 값 조회

em.createNativeQuery(SQL); 두번째 파라미터 사용하지 않으면 됨

JPA는 조회한 값들을 Object[]에 담아서 반환

스칼라 값들을 조회했을 뿐이므로 결과를 영속성 컨텍스트가 관리하지 않음. JDBC로 데이터를 조회한 것과 비슷

: 결과 매핑 사용

em.createNativeQuery(sql, "memberWithOrderCount")의 두 번째 파라미터에 결과 매핑 정보의 이름이 사용됨

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

@SqlResultSetMapping(name="OrderResults", entities={
	@EntityResult(entityClass=com.acme.Order.class, fields{
    	@FieldResult(name="id", column="order_id"),
        @FieldsResult(name="quantity", column="order_quantity"),
        @FieldResult(name="item", column="order_item")})},
     columns={
     	@ColumnResult(name="item_name)}
)

: 결과 매핑 어노테이션

@SqlResultSetMapping, @EntityResult, @FieldResult, @ColumnResult

 

- Named 네이티브 SQL

: JPQ처럼 네이티브 SQL도 Named 네이티브 SQL을 사용해서 정적 SQL을 작성할 수 있다.

// @NamedNativeQuery로 Named 네이티브 SQL 등록
@Entity
@NamedNativeQuery(
	name = "Member.memberSQL",
    query = "SELECT ID, AGE, NAME, TEAM_ID " + "FROM MEMBER WHERE AGE > ?", resultClass = Member.class)
public class Member {...}

// 사용 예제
TypeQuery<Member> nativeQuery = 
	em.createNamedQuery("Member.memberSQL", Member.class)
		.setParameter(1, 20);

 

- 네이티브 SQL XML에 정의

: XML에 정의할 때는 순서를 지켜야 하는데 <named-native-query>를 먼저 정의하고 <sql-result-set-mapping>를 정의해야 한다.

 

- 네이티브 SQL 정의

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

// 네이티브 SQL과 페이징 처리
String sql = "SELECT ID, AGE, NAME, TEAM_ID FROM MEMBER";
Query nativeQuery = 
	em.createNativeQuery(sql, Member.class)
    	.setFirstResult(10)
        .setMaxResult(20)

: 네이티브 SQL은 JPQL이 자동 생성하는 SQL을 수동으로 직접 정의하는 것이다. 따라서 JPA가 제공하는 기능 대부분을 그대로 사용할 수 있다.

: 네이티브 SQL은 관리하기 쉽지 않고 자주 사용하면 특저어 데이터베이스에 종속적인 쿼리가 증가해서 이식성이 떨어진다. 그렇다고 현실적으로 네이티브 SQL을 사용하지 않을 수는 없다. 될 수 있으면 표준 JPAL을 사용하고 기능이 부족하면 차선책으로 하이버네이트 같은 JPA 구현체가 제공하는 기능을 사용하자. 그래도 안되면 마지막 방법으로 네이티브 SQL을 사용하자. 그리고 네이티브 SQL로도 부족함을 느낀다면 MyBaits나 스프링 프레임워크가 제공하는 JdbcTemplate 같은 SQL 매퍼와 JPA를 함께 사용하는 것도 고려할만하다.

 

- 스토어드 프로시저(JPA 2.1)

: 스토어드 프로시저 사용

스토어드 프로시저를 사용하려면 em.createStoredProcedureQuery() 메소드에 사용할 스토어드 프로시저 이름을 입력하면된다. 그리고 registerProcedureParameter() 메소드를 사용해서 프로시저에서 사용할 파라미터를 순서, 타입, 파라미터 모드 순으로 정의하면 된다.

: Named 스토어드 프로시저 사용

스토어드 프로시저 쿼리에 이름을 부여해서 사용하는 것을 Named 스토어드 프로시저라 한다.

 

10.6 객체지향 쿼리 심화

- 벌크 연산

: 엔티티를 수정하려면 영속성 컨텍스트의 변경 감지 기능이나 병합을 사용하고, 삭제하려면 EntityManger.remove() 메소드를 사용한다. 하지만 이 방법으로 수백개 이상의 엔티티를 하나씩 처리하기에는 시간이 너무 오래 걸린다. 이럴 때 여러 건을 한 번에 수정하거나 삭제하는 벌크 연산을 사용하면 된다.

String qlString = 
	"update Product p " + "set p.price = p.price *1.1 " +
    "where p.stockAmount < :stockAmount";
    
int resultCount = em.createQuery(qlString)
					.setParameter("stoackAmount", 10)
                    .executeUpdate();

: JPA 표준은 아니지만 하이버네이트는 INSERT 벌크 연산도 지원한다.

JdbcTemplate jdbcTemplate = new JdbcTemplate(dataSource);
jdbcTemplate.batchUpdate("insert into schema.system_bounced_email (email) values (?)", new BatchPreparedStatementSetter() {
@Override
public void setValues(PreparedStatement ps, int i) throws SQLException {
ps.setString(1, createBouncedEmailList.get(i).getEmail());
}

@Override
public int getBatchSize() {
return createBouncedEmailList.size();
}
});

- 벌크 연산의 주의점

: 벌크 연산을 사용할 때는 벌크 연산이 영속성 커텍스트를 무시하고 데이터베이스에 직접 쿼리한다는 점에 주의해야 한다. 영속성 컨텍스트에 있는 값과 데이터베이스에 있는 값이 다를 수 있다.

: 해결방법

1) em.refresh() 사용

벌크 연산 수행 후에 em.refresh()를 사용해서 데이터베이스에서 다시 조회

2) 벌크 연산 먼저 실행

가장 실용적인 해결책, JPA와 JDBC를 함께 사용할 때도 유용

3) 벌크 연산 수행 후 영속성 컨텍스트 초기화

벌크 연산을 수행한 직후에 바로 영속성 컨텍스트를 초기화해서 영속성 컨텍스트에 남아 있는 엔티티를 제거하는 것도 좋은 방법이다.

: 벌크 연산은 영속성 컨텍스트와 2차 캐시를 무시하고 데이터베이스에 직접 실행한다. 따라서 영속성 컨텍스트와 데이터베이스 간에 데이터 차이가 발생할 수 있으므로 주의해서 사용해야 한다. 가능하면 벌크 연산을 가장 먼저 수행하는 것이 좋고 상황에 따라 영속성 컨텍스트를 초기화하는 것도 필요하다.

 

- 영속성 컨텍스트와 JPQL

: 쿼리 후 영속 상태인 것과 아닌 것

JPQL로 엔티티를 조회하면 영속성 컨텍스트에서 관리되지만 엔티티가 아니면 영속성 컨텍스트에서 관리되지 않는다.

: JPQL로 조회한 엔티티와 영속성 컨텍스트

JPQL로 데이터베이스에서 조회한 엔티티가 영속성 컨텍스트에 이미 있으면 JPQL로 데이터베이스에서 조회한 결과를 버리고 대신에 영속성 컨텍스트에 있던 엔티티를 반환한다.

JPQL로 조회한 엔티티는 영속 상태다.

영속성 컨텍스트에 이미 존재하는 엔티티가 있으면 기존 엔티티를 반환한다.

1) 새로운 엔티티를 영속성 컨텍스트에 하나 더 추가한다. -> 같은 기본 키 값을 가진 엔티티 등록할 수 없음

2) 기존 엔티티를 새로 검색한 엔티티로 대체한다. -> 영속성 컨텍스트에 수정 중인 데이터가 사라질 수 있으므로 위험. 영속성 컨텍스트는 엔티티의 동일성 보장

3) 기존 엔티티는 그대로 두고 새로 검색한 엔티티를 버린다.

find() vs JPQL

em.find() 메소드는 엔티티를 영속성 컨텍스트에서 먼저 찾고 없으면 데이터베이스에서 찾는다. 따라서 해당 엔티티가 영속성 컨텍스트에 있으면 메모리에서 바로 찾으므로 성능상 이점이 있다. (1차 캐시라 부름)

JPQL은 항상 데이터베이스에 SQL을 실행해서 결과를 조회한다.

 

- JPQL과 플러시 모드

: 플러시는 영속성 컨텍스트의 변경 내역을 데이터베이스에 동기화하는 것이다. JPA는 플러시가 일어날 때 영속성 컨텍스트에 등록, 수정, 삭제한 엔티티를 찾아서 INSERT, UPDATE, DELETE SQL을 만들어서 데이터베이스에 반영한다. 플러시를 호추하려면 em.flush() 메소드를 직접 사용해도 되지만 보통 플러시 모드에 따라 커밋하기 직전이나 쿼리 실행 직전에 자동으로 플러시가 호출된다.

em.setFlushMode(FlushModeType.AUTO); // 커밋 또는 쿼리 실행 시 플러시 (기본값), JPA는 트랜잭션 커밋 직전이나 쿼리 실행 직전에 자동으로 플러시 호출

em.setFlushMode(FlushModeType.COMMIT); // 커밋시에만 플러시, 쿼리 실행 시에는 플러시 호출 안함, 이 옵션은 성능 최적화를 위해 꼭 필요할 때만 사용

쿼리에서 설정하는 플러시 모드는 엔티티 매니저에 설정하는 플러시 모드보다 우선권을 가진다.

JPA를 사용하지 않고 JDBC로 쿼리를 직접 실행하면 JPA는 JDBC가 실행한 쿼리를 인식할 방법이 없다. 따라서 별도의 JDBC의 호출은 플러시 모드를 AUTO 설정해도 플러시가 일어나지 않는다. 이때는 JDBC로 쿼리를 실행하기 직전에 em.flush()를 호출해서 영속성 컨텍스트의 내용을 데이터베이스에 동기화하는 것이 안전하다.

 

10.7 정리

- JPQL은 SQL을 추상화해서 특정 데이터베이스 기술에 의존하지 않는다.

- Criteria나 QueryDSL은 JPQL을 만들어주는 빌더 역할을 할 뿐이므로 핵심 JPQL을 잘 알아야 한다.

- Criteria나 QueryDSL을 사용하면 동적으로 변하는 쿼리를 편리하게 작성할 수 있다.

- Criteria는 JPA가 공식 지원하는 기능이지만 직관적이지 않고 사용하기에 불편하다. 반면에 QueryDSL은 JPA가 공식 지원하는 기능은 아니지만 직관적이고 편리하다.

- JPA도 네이티브 SQL을 제공하므로 직접 SQL을 사용할 수 있다. 하지만 특정 데이터베이스에 종속적인 SQL을 사용하면 다른 데이터베이스에 변경하지 쉽지 않다. 따라서 최대한 JPQL을 사용하고 그래도 방법이 없을 때 네이티브 SQL을 사용하자.

- JPQL은 대량에 데이터를 수정하거나 삭제하는 벌크 연산을 지원한다.