[자바 ORM 표준 JPA 프로그래밍] 10주차 스터디

박서영·2026년 5월 31일

10장. 객체지향 쿼리 언어

10.4 QueryDSL

JPA Criteria는 문자가 아닌 코드로 JPQL을 작성하기에 문법 오류를 컴파일 단계에서 잡을 수 있고, IDE 자동완성 기능의 도움을 받을 수 있다는 장점이 존재하지만, 단점은 너무 복잡하고 어렵다는 점이다.

쿼리를 문자가 아닌 코드로 작성해도 쉽고, 간결하며 그 모양도 쿼리와 비슷하게 개발할 수 있는 프로젝트가 QueryDSL.

(1) QueryDSL 설정

필요 라이브러리

  • querydsl-jpa: QueryDSL JPA 라이브러리
  • querydsl-apt: 쿼리 타입 생성 시 필요한 라이브러리

환경설정

QueryDSL을 사용하기 위해서는 Criteria 메타 모델처럼 엔티티 기반의 쿼리 타입이라는 쿼리용 클래스를 생성해야함.

쿼리 타입 생성용 플러글인은 pom.xml에 추가해야함.

(2) 시작

public void queryDSL() {
	EntityManager em = emf.createEntityManager();
    
    JPAQuery query = new JPAQuery(em);
    QMember qMember = new QMember("m");
    List<Member> members = query.from(qMember)
    	.where(qMember.name.eq("회원1"))
        .orderBy(qMember.name.desc())
        .list(qMember);

}

QueryDSL 사용을 위해서는 JPAQuery 객체를 생성해야하는데, 이때 엔티티매니저를 생성자에게 넘겨준다.

기본 Q 생성

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

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

QMember qMember = new QMember("m"); //직접 지정
QMember qMember = QMember.member; //기본 인스턴스 사용

쿼리 타입의 기본 인스턴스를 사용하면 import static을 활용해 코드를 더 간결하게 작성할 수 있음.

import static jpabook.jpashop.domain.QMember.member; //기본 인스턴스

public void basic() {
	EntityManager em = emf.createEntityManager();
    ...
}

(3) 검색 조건 쿼리

QueryDSL의 기본 쿼리기능

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); //조회할 프로젝션 지정

QueryDSL의 where절에는 and나 or을 사용할 수 있음. 또한 다음처럼 여러 검색 조건을 사용해도 되며 이때에는 and 연산이 가능하다.

쿼리 타입의 필드는 필요한 대부분의 메소드를 명시적으로 제공한다.

(4) 결과 조회

쿼리 작성 이후 결과 조회 메소드를 호출하면 실제 데이터베이스를 조회한다. 보통 uniqueResult()list()를 사용하고 파라미터로 프로젝션 대상을 넘겨줌.

  • uniqueResult(): 조회 결과가 한 건일 때 사용. 조회 결과가 없으면 null을 반환하고 결과가 하나 이상이면 예외 발생
  • singleResult(): uniqueResult()와 같지만 결과가 하나 이상이면 처음 데이터를 반환
  • list(): 결과가 하나 이상일 때 사용하면, 없을 때는 빈 컬렉션을 반환.

(5) 페이징과 정렬

QItem item = QItem.item;

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

정렬은 orderBy를 사용하는데 쿼리 타입이 제공하는 asc(), desc()를 사용. 페이징의 경우는 offsetlimit을 적절히 조합해서 사용하면 됨.

페이징은 restrict() 메소드에 QueryModifiers를 파라미터로 사용해도 됨.

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

실제 페이징 처리를 위해서는 검색된 전체 데이터의 수를 알아야함. 이때는 list() 대신 listResults()를 사용함.

SearchResults<Item> result = 
	query.from(item)
    .where(item.price.gt(10000))
    .offset(10).limit(20)
    .listResults(item);
    
long total = result.getTotal(); //검색된 전체 데이터 수
long limit = result.getLimit();
long offset = result.getOffset();
List<Item> results = result.getResults(); //조회된 데이터

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

(6) 그룹

그룹은 groupBy를 사용하고, 그룹화된 결과를 제한하기 위해서는 having을 사용하면 됨.

query.from(item)
	.groupBy(item.price)
    .having(item.price.get(1000))
    .list(item);

(7) 조인

조인은 innerJoin(join), leftJoin, rightJoin, fullJoin을 사용할 수 있고 추가로 JPQL의 on과 성능 최적화를 위한 fetch 조인 역시 사용할 수 있음.

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

//조인 join에 사용
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);

(8) 서브쿼리

서브쿼리는 JPASubQuery를 생성해서 사용함. 서브 쿼리의 결과가 하나이면 unique(), 여러 건이면 list()를 사용.

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

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

(9) 프로젝션과 결과반환

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

프로젝션 대상이 하나일 때

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

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

여러 컬럼 반환과 튜플

프로젝션 대상으로 여러 필드를 선택하면 QueryDSL은 기본으로 Tuple이라는 Map과 비슷한 내부 타입을 사용함. 조회 결과는 tuple.get() 메소드에 조회한 쿼리 타입을 지정하면 됨.

QItem item = QItem.item;

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

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

빈 생성

쿼리 결과를 엔티티가 아닌 특정 객체로 받고 싶으면 빈 생성(bean population) 기능을 사용. QueryDSL은 아래와 같은 방법들을 제공함.

  • 프로퍼티 접근
  • 필드 접근
  • 생성자 사용

원하는 방법의 지정을 위해서는 Projections를 사용하면 됨.

(10) 수정, 삭제 배치 쿼리

QueryDSL도 수정, 삭제 같은 배치 쿼리를 지원함. JPQL 배치 쿼리 같이 영속성 컨텍스트를 무시하고 데이터베이스를 직접 쿼리하는 부분에 유의해야함.

수정 배치 쿼리

QItem item = QItem.item;
JPAUpdateClause updateClause = new JPAUpdateClause(em, item);
long count = updateClause.where(item.name.eq("책"))
	.set(item.price, item.price.add(100))
    .execute();

삭제 배치 쿼리

QItem item = QItem.item;
JPADeleteClause deleteClause = new JPADeleteCluase(em, item);
long count = deleteClause.where(item.name.eq("책"))
	.execute();

(11) 동적 쿼리

BooleanBuilder를 사용하면 특정 조건에 따라 동적 쿼리를 편하게 생성할 수 있음.

SearchParam param = new SearchParam();
param.setName("개발자");
param.setPrice(10000);

QItem item = QItem.item;

BooleanBuilder builder = new BooleanBuilder();
if (StringUtils.hasText(param.getName())) {
	builder.and(item.name.contains(param.getName())));
...
}

(12) 메소드 위임

메소드 위임 기능 사용 시 쿼리 타입에 검색 조건을 직접 정의할 수 있음

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

메소드 위임 기능을 사용하기 위해서는 우선 정적 메소드를 만들고 @QueryDelegate 어노테이션에 속성으로 이 기능을 적용할 엔티티를 지정해야함.

정적 메소드의 첫 번째 파라미터에는 대상 엔티티의 쿼리 타입을 지정하고, 나머지는 필요한 파라미터를 정의함.

10.5 네이티브 SQL

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

  • 특정 데이터베이스만 지원하는 함수, 문법, SQL 쿼리 힌트
  • 인라인 뷰, UNION, INTERSECT
  • 스토어드 프로시저

때로는 특정 데이터베이스에 종속적인 기능이 필요하기에 JPA는 특정 데이터베이스 종속적인 기능ㅇ르 사용할 수 있는 다양한 방법을 열어둠.

(1) 네이티브 SQL 사용

네이티브 쿼리의 API는 다음 3가지가 존재

//결과 타입 정의
public Query createNativeQuery (String sqlString, Class resultClass);

//결과 타입을 정의할 수 없을 때
public Query createNativeQuery(String sqlString);

//결과 매핑 사용
public Query createNativeQuery(String sqlString, String resultSetMapping); 

엔티티 조회

네이티브 SQL은 em.createNativeQuery(SQL, 결과 클래스)를 사용함. 첫 번째 파라미터에 네이티브 SQL을 입력하고, 두 번째는 조회할 엔티티 클래스의 타입을 입력함.

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

값 조회

String sql = "SELECT ID, AGE, NAME, TEAM_ID "+
			"FROM MEMBER WHERE AGE > ?";
Query nativeQuery = em.createNativeQuery(sql).setParameter(1,10);

List<Object[]> resultList = nativeQuery.getResultList();
for (Object[] row : resultList) {
	...
}

위에서는 엔티티로 조회하지 않고 단순히 값으로 조회함. 이렇게 여러 값으로 조회하기 위해서는 em.createNativeQuery(SQL)의 두 번째 파라미터를 사용하지 않으면 됨. JPA는 조회한 값들을 Object[]에 담아서 반환함. 여기서는 스칼라 값들을 조회했을 뿐이기에 영속성 컨텍스트가 관리하지 않음. JDBC로 데이터를 조회한 것과 같음.

결과 매핑 사용

엔티티와 스칼라 값을 함께 조회하는 것처럼 매핑이 복잡해지면 @SqlResultSetMapping을 정의해서 결과 매핑을 사용해야함.

String sql = "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(sql, "memberWithOrderCount");

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

결과 매핑 정의

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

memberWithOrderCount의 결과 매핑을 보면 회원 엔티티와 ORDER_COUNT 컬럼을 매핑함. entities, columns라는 이름에서 알 수 있듯, 여러 엔티티와 여러 컬럼을 매핑할 수 있음.

@FieldResult를 사용해 컬럼명과 필드명을 직접 매핑하며, 해당 설정은 엔티티 필드에 정의한 @Column보다 앞섬. 불편한 점은 해당 어노테이션을 한 번이라도 사용하면, 전체 필드를 해당 어노테이션을 사용해 매핑해야한다.

두 엔티티 조회의 컬럼명이 중복될 때에도 역시 @FieldResult를 사용해야함.

(2) Named 네이티브 SQL

JPQL처럼 네이티브 SQL도 Named 네이티브 SQL을 사용해 정적 SQL을 작성할 수 있음.

@Entity
@NamedNativeQuery (
	name = "Member.memberSQL",
    query = "SELECT ID, AGE, NAME, TEAM_ID" +
    		"FROM MEMBER WHERE AGE > ?",
            resultClass = Member.class
)
public class Member {...}
)

@NamedNativeQuery로 Named 네이티브 SQL을 등록.

TypedQuery<Member> nativeQuery = 
	em.createNamedQuery("Member.memberSQL", Member.class)
    .setParameter(1, 20L);

@NamedNativeQuery

  • name: 네임드 쿼리 이름 (필수)
  • query: SQL 쿼리(필수)
  • hints: 벤더 종속적인 힌트
  • resultClass: 결과 클래스
  • resultSetMapping: 결과 매핑 사용

(3) 네이티브 SQL XML에 정의

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

(5) 스토어드 프로시저

JPA 2.1부터 스토어드 프로시저를 지원함.

스토어드 프로시저 사용

: 단순히 입력값을 두 배로 증가시켜주는 proc_multiply라는 스토어드 프로시저가 있을 때, 해당 프로시저는 첫 번째 파라미터로 값을 입력받고, 두 번째 파라미터로 결과를 반환함.

JPA로 해당 프로시저를 호출하기 위해서는 아래와 같다.

StoredProcedureQuery spq = em.createStoredProcedureQuery("proc_multiply");

spq.registerStoredProcedureParameter(1, Integer.class, ParameterMode.IN);
spq.registerStoredProcedureParameter(2, Integer.class, ParameterMode.OUT);

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

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

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

사용 가능한 파라미터 모드는 아래와 같음.

public enum ParameterMode {
	IN, //INPUT 파라미터
    INOUT, //INPUT, OUTPUT 파라미터
    OUT, //OUTPUT 파라미터
    REF_CURSOR //CURSOR 파라미터
}

파라미터 순서 대신 이름을 사용하는 방법 역시 존재함.

Named 스토어드 프로시저 사용

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

@NamedStoredProcedureQuery (
	name = "multiply",
    procedureName = "proc_multiply",
    parameters = {
    	@StoredProcedureParameter(name = "inParam", mode = ParameterMode.IN, type = Integer.class),
        @StoredProcedureParameter(naem = "outParam", mode = ParameterMode.OUT, type = Integer.class)
    }
)
@Entity
public class Member {...}

XML에 정의한 Named 스토어드 프로시저는 em.createNamedStoredProcedureQuery() 메소드에 등록한 Named 스토어드 프로시저 이름을 파라미터로 사용해 찾아올 수 있음.


10.6 객체지향 쿼리 심화

(1) 벌크 연산

엔티티 수정을 위해서는 영속성 컨텍스트의 변경 감지 기능이나 병합을 사용하며, 삭제를 위해서는 EntityManager.remove() 메소드를 사용함. 다만, 이 방법으로 수 백개 이상의 엔티티를 하나씩 처리하기에는 시간이 너무 오래 걸림.

이럴 때에 여러 건을 한 번에 수정하거나 삭제하는 벌크 연산을 사용하면 됨.

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

벌크연산은 executeUpdate() 메소드를 사용함. 해당 메소드는 벌크 연산으로 영향을 받은 엔티티 건수를 반환한다. 삭제 연산 역시 같은 메소드를 사용한다.

벌크 연산의 주의점

벌크 연산 사용 시, 해당 연산이 영속성 컨텍스트를 무시하고 데이터베이스에 직접 쿼리한다는 점에 주의해야함.

이런 문제를 해결하기 위한 방법으로는 아래의 방법들이 존재한다.

  • em.refresh() 사용:
    벌크 연산을 수행한 직후 정확한 엔티티를 사용하기 위해서는 em.refresh()를 사용해 데이터베이스에서 해당 엔티티를 다시 조회한다.
  • 벌크 연산 먼저 실행
    가장 실용적인 해결책은 벌크 연산을 가장 먼저 실행하는 것. 이 경우에는 벌크 연산 실행 후, 엔티티를 조회하기에 이미 벌크 연산으로 변경된 엔티티를 조회하게 되기에 괜찮다.
  • 벌크 연산 수행 후 영속성 컨텍스트 초기화
    영속성 컨텍스트 초기화를 통해 남아있는 엔티티를 제거하는 것 역시 좋은 방법이다. 초기화 이후에는 데이터베이스에서 엔티티를 조회해 온다.

(2) 영속성 컨텍스트와 JPQL

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

JPQL의 조회 대상으로는 엔티티, 임베디드 타입, 값 타입 등 다양한 종류가 있다. JPQL로 엔티티 조회 시, 영속성 컨텍스트에서 관리되지만, 만약 엔티티가 아니라면 영속성 컨텍스트에서 관리되지 않는다.

예로는 임베디드 타입의 경우 조회해 값을 변경해도 영속성 컨텍스트가 관리하지 않기에 변경 감지에 의한 수정이 발생하지 않는다. 만약 엔티티를 조회하게 되면, 엔티티가 가지고 있는 임베디드 타입은 함께 수정되기는 한다.

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

  • JPQL로 조회한 엔티티는 영속 상태
  • 영속성 컨텍스트에 이미 존재하는 엔티티가 있으면 기존 엔티티를 반환함.

find() vs JPQL

em.find() 메소드는 엔티티를 영속성 컨텍스트에서 먼저 찾고 없으면 데이터베이스에서 찾음. 따라서 해당 엔티티가 영속성 컨텍스트에 있으면 메모리에서 바로 찾기에 성능상 이점이 존재한다.

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

JPQL의 특징

  • JPQL은 항상 데이터베이스를 조회
  • JPQL로 조회한 엔티티는 영속 상태
  • 영속성 컨텍스트에 이미 존재하는 엔티티가 있으면 기존 엔티티를 반환함.

(3) JPQL과 플러시 모드

플러시: 영속성 컨텍스트의 변경 내역을 데이터베이스에 동기화하는 것. JPA는 플러시가 일어날 때 영속성 컨텍스트에 등록, 수정, 삭제한 엔티티를 찾아 INSERT, UPDATE, DELETE SQL을 만들어 데이터베이스에 반영함.

플러시 호출을 위해서는 em.flush() 메소드를 직접 사용해도 되지만 보통 플러시 모드에 따라 커밋하기 직전이나 쿼리 실행 직전에 자동으로 플러시를 호출됨.

쿼리와 플러시 모드

JPQL은 영속성 컨텍스트에 있는 데이터를 고려하지 않고 데이터베이스에서 데이터를 조회함. 따라서 JPQL을 실행하기 전에 영속성 컨텍스트의 내용을 데이터베이스에 반영해야함. 그렇지 않을 경우 의도치 않은 결과 발생 가능.

setFlushMode()를 통해 플러시 모드를 설정할 수 있음.

플러시 모드 최적화

FlushModeType.COMMIT 모드는 트랜잭션을 커밋할 때만 플러시하고 쿼리를 실행할 때는 플러시하지 않음. 따라서 JPA 쿼리를 사용할 때 영속성 컨텍스트에는 있지만, 아직 데이터베이스에 반영하지 않은 데이터를 조회할 수 없다. 이런 상황은 잘못하면 데이터 무결성에 심각한 피해를 줄 수 있음.

JPA를 사용하지 않고 JDBC를 직접 사용해 SQL을 실행할 때에도 플러시 모드를 고민해야함. 즉, 쿼리 실행 직전에 em.flush()를 통해 영속성 컨텍스트의 내용을 데이터베이스에 동기화하는 것이 안전함.

profile
이불 밖은 위험해.

0개의 댓글