[JPA] 객체지향 쿼리 언어(Query DSL)

Updated:

들어가며

해당 글은 자바 ORM 표준 프로그래밍을 정리한 글입니다.

JPA에서 사용할 수 있는 객체지향 쿼리 종류에 대해 소개하고 Query DSL에서 어떤 식으로 쿼리를 사용하는지 알아본다.

객체 지향 쿼리 소개

JPQL

  • 엔티티 객체를 조회하는 객체지향 쿼리이다.
  • JPQL은 SQL을 추상화해서 특정 데이터베이스에 의존하지 않는다.
  • SQL 보다 간결하다.
  • 오타가 발생시에 컴파일 시점에 알 수가 없다.

Criteria Query

  • Criteria의 장점은 문자가 아닌 query.select(m).where(…) 처럼 프로그래밍 코드로 JPQL을 작성할 수 있다.
  • 컴파일 시점에 오류를 발견할 수 있다.
  • IDE를 사용하면 코드 자동완성을 지원한다.
  • 동적 쿼리를 작성하기 편하다.
  • 사용하기 불편하고, 작성한 코드도 한눈에 들어오지 않는 단점이 있다.

Native SQL

  • 데이터베이스에 의존하는 기능을 사용해야 할 때 사용 가능하지만 데이터베이스를 변경하게 되면 네이티브 SQL도 변경해야 한다.
  • em.createNativeQuery()를 사용한다.

Query DSL

  • 코드 기반이면서 단순하고 사용하기 쉽다.
  • 어노테이션 프로세서를 사용해서 쿼리 전용 클래스를 만들어야 한다.
  • JDBC 직접 사용, Mybatis SQL 매퍼 프레임워크 사용
  • JDBC나 마이바티스를 JPA와 함께 사용하면 영속성 컨텍스트를 적절한 시점에 강제로 플러시해야 한다.
  • JPA를 우회해서 SQL을 실행하기 직전에 영속성 컨텍스트를 수동으로 플러시해서 데이터베이스와 영속성 컨텍스트를 동기화하면 된다.

Query DSL

문자가 아닌 코드로 작성하면서 쉽고 간결하며 그 모양도 쿼리와 비슷하게 개발할 수 있는 오픈 소스 프로젝트이다. Query DSL을 사용하면 Q로 시작하는 쿼리 타입의 엔티티를 생성해야 한다.

셋팅

검색 조건 쿼리

@Transactional
@AutoConfigureTestEntityManager
@SpringBootTest
class UserServiceTest {
    @Autowired
    private TestEntityManager em;
   
    @Test
    void queryDslTest() {
        User user = new User();
        em.persist(user); em.flush();

        JPAQuery<User> query = new JPAQuery(em.getEntityManager());

        query.from(QUser.user)
             .where(QUser.user.id.eq(user.getId()));

        User actual = query.fetchOne();

        assertThat(actual).isNotNull();
    }
}
Hibernate: 
    select
        user0_.id as id1_10_ 
    from
        user user0_ 
    where
        user0_.id=?

결과 조회

  • fetchOne()
    • 조회 결과가 한 건일 때 사용한다. 조회 결과가 없으면 null을 반환하고 결과가 하나 이상이면 NonUniqueResultException 예외가 발생한다.
  • fetch()
    • 결과가 하나 이상일 때 사용한다. 결과가 없으면 빈 컬렉션을 반환한다.
  • fetchResults()
    • 전체 데이터 조회를 위한 count 쿼리가 한 번 더 실행하고, QueryResults 반환하는데 이 객체에서 전체 데이터 수를 조회 할 수 있다.
  • fetchCount()
    • 해당 쿼리의 count를 구할 때 사용한다.

페이징과 정렬

  • orderBy
    • 해당 키워드를 사용해 정렬
JPAQuery<User> query = new JPAQuery(em.getEntityManager());

query.from(QUser.user)
     .where(QUser.user.id.eq(1L))
     .orderBy(QUser.user.id.desc());
  • offset, limit
    • 해당 키워드를 사용해 제한적인 데이터를 가져온다.
JPAQuery<User> query = new JPAQuery(em.getEntityManager());

query.from(QUser.user)
     .where(QUser.user.id.eq(1L))
     .limit(100)
     .offset(0)
     .orderBy(QUser.user.id.desc());
  • restrict()
    • QueryModifiers 파라메터를 이용해서 offset, limit을 대체할 수 있다.
JPAQuery<User> query = new JPAQuery(em.getEntityManager());

query.from(QUser.user)
     .restrict(new QueryModifiers(100L, 0L));

QueryResults<User> result = query.fetchResults();

그룹

  • groupby을 사용하고 그룹화된 결과를 제한하려면 having을 사용 하면 된다.
QUser user = QUser.user;
query.from(user)
     .groupBy(user.name, user.id)
     .having(user.price.sum().gt(10));

조인

  • JPQL에서도 마찬가지지만 해당 엔티티의 참조 객체를 사용해서 join을 해야 올바른 문법으로 인식한다.
  • innerJoin(join), leftJoin, rightJoin, fullJoin을 사용할 수 있고 성능 최적화를 위한 fetch 조인도 사용할 수 있다.
  • 조인의 기본 문법은 첫 번째 파라미터에 조인 대상을 지정하고, 두 번째 파라미터에 별칭으로 사용할 쿼리 타입을 지정하면 된다.
@Test
void queryDslJoinTest() {
  User user = new User();
  UserInventory userInventory = new UserInventory();
  user.setUserInventory(userInventory);
  em.persist(user);
  em.persist(userInventory);
  em.flush();

  JPAQuery<User> query = new JPAQuery(em.getEntityManager());
  
  QUser qUser = QUser.user;
  QUserInventory qUserInventory = QUserInventory.userInventory;
  
  query.from(qUser)
            .join(qUser.userInventory, qUserInventory)
            .where(qUser.userInventory.id.eq(userInventory.getId()));
  
  User result = query.fetchOne();
  assertThat(result).isNotNull();
}
Hibernate: 
    select
        user0_.id as id1_10_,
        user0_.name as name2_10_,
        user0_.price as price3_10_,
        user0_.user_inventory_id as user_inv4_10_ 
    from
        user user0_ 
    inner join
        user_inventory userinvent1_ 
            on user0_.user_inventory_id=userinvent1_.id 
    where
        user0_.user_inventory_id=?

서브쿼리

  • JPAExpressions를 생성해서 사용하고 SubQueryExpression으로 변환해야 사용 가능하다.
@Test
void queryDslSubQueryTest() {
  User user = new User();
  em.persist(user);
  em.flush();

  JPAQuery<User> query = new JPAQuery(em.getEntityManager());
  QUser qUser = QUser.user;
  SubQueryExpression subQueryExpression = JPAExpressions.select(qUser)
                                                 .from(qUser)
                                                 .where(qUser.name.eq("woojin"));        
  query.from(qUser)
       .where(qUser.id.in(subQueryExpression));
  List<User> result = query.fetch();

  assertThat(result).isNotNull();
}
Hibernate: 
    select
        user0_.id as id1_10_,
        user0_.name as name2_10_,
        user0_.price as price3_10_,
        user0_.user_inventory_id as user_inv4_10_ 
    from
        user user0_ 
    where
        user0_.id in (
            select
                user1_.id 
            from
                user user1_ 
            where
                user1_.name=?
        )

프로젝션과 결과 반환

  • Tuple을 이용해서 Map과 비슷한 내부 타입으로 조회가 가능하다.

수정, 삭제 배치 쿼리

  • JPQL 배치 쿼리와 같이 영속성 컨텍스트를 무시하고 데이터베이스를 직접 쿼리한다는 점에 유의 해야 한다.
  • JPAUpdateClause, JPADeleteClause를 사용한다.

동적 쿼리

  • BooleanBuilder를 사용하면 특정 조건에 따른 동적 쿼리를 편리하게 생성할 수 있다.
@Test
void dynamicQueryTest() {
  User user = new User();
  em.persist(user);
  em.flush();

  JPAQuery<User> query = new JPAQuery(em.getEntityManager());
  QUser qUser = QUser.user;
  query.from(qUser);

  BooleanBuilder builder = new BooleanBuilder();

  String name = "woojin";
  if (name != null) {
    builder.and(qUser.name.eq(name));
  }

  query.where(builder);

  List<User> result = query.fetch();

  assertThat(result).isNotNull();
}
Hibernate: 
    select
        user0_.id as id1_10_,
        user0_.name as name2_10_,
        user0_.price as price3_10_,
        user0_.user_inventory_id as user_inv4_10_ 
    from
        user user0_ 
    where
        user0_.name=?

객체 지향 쿼리 심화

벌크 연산시 주의점

  • 벌크 연산이 영속성 컨텍스트를 무시하고 데이터베이스에 직접 쿼리 한다는 점에 주의해야 한다.
  • 업데이트 벌크 연산을 한 후에 영속성 컨텍스트를 통해서 조회하게 되면 영속성 컨텍스트에 이미 존재했던 엔티티의 경우 변경되지 않은 값으로 조회가 될 수 있다.

해결 방법

  • em.refresh() 사용
  • 벌크 연산 먼저 실행 후 조회
  • 벌크 연산 수행 후 영속성 컨텍스트 초기화(em.clear())

영속성 컨텍스트와 JPQL

  • select m from Member m // 엔티티 조회(관리O)
  • select o.address from Order o //임베디드 타입 조회(관리X)
  • select m.id, m.username from Member m // 단순 필드 조회 (관리X)
  • JPQL로 조회한 엔티티는 영속상태이다.
  • 영속성 컨텍스트에 이미 존재하는 엔티티가 있으면 기존 엔티티를 반환한다.
    • 같은 식별자를 가진 엔티티는 영속성 컨텍스트에 중복해서 존재할 수 없다
    • 영속성 컨텍스트에 있는 엔티티가 수정 중일 수 있으니 조회해서 가져온 것과 대체할 수 없다
    • 영속성 컨텍스트에서 조회한 데이터는 동일성을 보장해야 하기에 새로 검색한 엔티티를 버리고 기존 엔티티를 반환한다.

find() vs JPQL

  • find()의 경우 영속성 컨텍스트에 존재하면 영속성 컨텍스트의 엔티티를 반환하지만 JPQL은 실제 데이터베이스에서 조회를 한 후에 영속성 컨텍스트에 엔티티가 존재하는지 비교 후 엔티티를 반환한다.
  • 따라서 JPQL로 조회하는 경우 SQL이 매번 실행 된다.

JPQL과 플러시 모드

  • AUTO
    • JPQL 쿼리가 실행되기 전과 커밋이 실행 될 때 플러시가 동작한다.
  • Commit
    • 트랜잭션 커밋이 실행 될 때 플러시가 동작하므로 성능의 이점을 가져올 수 있으나 사용시 주의 해야 한다.

마치며

객체지향 쿼리 언어는 여러가지 종류가 존재하지만 현재 개발조직에서는 Query DSL을 다루고 있어 Query DSL 부분에 대해서만 정리하였다.

각 라이브러리 마다 장단점이 존재하겠지만 객체로 쿼리를 작성하면서 실제 쿼리가 어떻게 동작하겠는지 쉽게 추측할 수 있는게 Query DSL의 장점 인것 같다.👍



Leave a comment