본문 바로가기

개발기술/ORM

JPA 쿼리기능 JPQL, QueryDSL, 네이티브SQL

JPA에서 제공하는 주요 조회 방법

JPA는 객체를 기반으로 검색할 수 있도록 여러 가지 방법을 제공합니다.

조회  방법설명
JPQL (Java Persistence Query Language) 엔티티 객체를 대상으로 SQL과 유사한 문법으로 조회
네이티브 SQL JPA에서 직접 SQL을 사용할 수 있도록 지원
QueryDSL JPQL을 타입 안전하게 편리하게 작성할 수 있도록 도와주는 빌더 클래스
JDBC 직접 사용 / MyBatis 같은 SQL 매퍼 SQL을 직접 사용하여 조회 성능 최적화 가능

 

객체지향쿼리의 필요성

A를 조회할 때 단순한 EntityManager.find() 또는 객체 그래프 탐색 (a.getB().getC()) 방식으로만 데이터를 조회하는 것은 어려움.

  • 모든 회원 엔티티를 메모리에 올려두고 나서 조건에 맞추어 검색하는 것은 비현실적
    • 데이터베이스 상에서 sql로 최대한 내용을 필터링하여 가져오는 것이 성능에서 유리함.
    • 객체지향성을 유지하면서 필터링을 하는 기능이 필요함.
  •  복잡한 작업 처리: JPA의 기본 메서드만으로는 해결하기 어려운 복잡한 데이터 처리 작업을 수행해야 할 때, JPQL 또는 Native Query가 필요합니다.

 

객체지향 쿼리의 장점

  • 데이터베이스 테이블이 아니라 엔티티를 대상으로 검색
    • SQL처럼 테이블을 다루지 않고, 객체를 대상으로 쿼리를 작성할 수 있음.
    • 따라서 DB에 종속적인 코드가 줄어들어 유지보수가 편리함.
  • 컴파일 시점에 문법 오류를 발견 가능
    • JPQL은 문자열 기반이므로 런타임에서 오류가 발생할 수 있음.
    • 하지만 QueryDSL은 컴파일 시점에 문법 오류를 잡을 수 있어 안정성이 높음.
  • 동적 쿼리 작성이 쉬움
    • 동적 쿼리는 사용자의 입력값이나 특정 조건에 따라 SQL이 변경되는 쿼리를 의미합니다.
    • 검색 기능에서 "사용자가 입력한 값에 따라 WHERE 절을 다르게 적용" 하는 경우가 해당됩니다. 
      • BooleanBuilder, where() 조건 조합 등을 활용하여 가독성 높은 코드를 작성할 수 있음.

 

 Repository method 커스텀 정의

커스텀 필요성

JPA의 method를 통해서 많은 부분들이 추상화되었지만 여전히 SQL작성은 필요함. 아래와 같은 케이스가 그 케이스임.

 

커스텀 쿼리 주요 어노테이션

  • @Query : hibernate method 외에  JPQL 쿼리를 커스텀으로 작성할 수 있다. 
    • nativeQuery = true :  SQL문을 작성가능(DB 종속적임)
  • @Modifying : INSERT, UPDATE, and DELETE문으로 해당 쿼리가 단순 조회가 아닌 데이터를 수정하는 쿼리임을 표시하며, 수정된 행(row)의 수를 반환합니다.
  • @Param (쿼리 파라미터 매핑) : JPQL/Native Query 내부의 :parameter과 메소드의 parameter 변수를 매핑
  • @EntityGraph(attributePaths = {"relatedEntity"}) : Select 문으로 entity를 가져올 때 연관된 entity를 joinfetch 하여 N+1문제를 예방한다. 
    • BestPractie : JPA 내장 메소드에 사용시에는 동작을 변경시키기때문에 커스텀 메소드에 적합하며 Join문을 줄여주는 역할함

 

쿼리DSL 설정

 

Dependency 설정

 

// QueryDSL
implementation 'com.querydsl:querydsl-jpa:5.1.0:jakarta'
annotationProcessor "com.querydsl:querydsl-apt:5.1.0:jakarta"
annotationProcessor "jakarta.annotation:jakarta.annotation-api"
annotationProcessor "jakarta.persistence:jakarta.persistence-api"

 

Spring에서 기본지원하지 않기 때문에 Bean을 생성해서 주입해줘야한다.

  • JPAQueryFactory Configuration (Q Factory)
import com.querydsl.jpa.impl.JPAQueryFactory;
import jakarta.persistence.EntityManager;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class QueryDslConfig {

    @Bean
    public JPAQueryFactory jpaQueryFactory(EntityManager entityManager) {
        return new JPAQueryFactory(entityManager);
    }
}

 

  • Define a Custom Interface
public interface UserRepositoryCustom {
    List<User> findUsersByName(String name);
}
  • Implement the Custom Interface
    • QueryDSL Q-type objects : QueryDSL-generated classes that map to your JPA entity classes.
import com.querydsl.jpa.impl.JPAQueryFactory;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Repository;
import java.util.List;

import static com.example.entity.QUser.user;  // Import the generated Q-class

@Repository
@RequiredArgsConstructor
public class UserRepositoryCustomImpl implements UserRepositoryCustom {

    private final JPAQueryFactory queryFactory; // Injected via Spring

    @Override
    public List<User> findUsersByName(String name) {
        return queryFactory
            .selectFrom(user)
            .where(user.name.eq(name))
            .fetch();
}
}
  • Extend Your JPA Repository
public interface UserRepository extends JpaRepository<User, Long>, UserRepositoryCustom {
}

QueryDSL 문법 정리

기능 QueryDSL SQL Equivalent
전체 조회 selectFrom(auth).fetch() SELECT * FROM auth;
특정 컬럼 조회 select(auth.name).fetch() SELECT name FROM auth;
조건 조회 where(auth.name.eq("ADMIN")) WHERE name = 'ADMIN'
AND / OR where(a.eq(1).and(b.eq(2))) WHERE a = 1 AND b = 2
정렬 orderBy(auth.name.asc()) ORDER BY name ASC
그룹핑 groupBy(auth.name) GROUP BY name
조인 join(userAuth).on(userAuth.auth.eq(auth)) JOIN user_auth ON user_auth.auth_id = auth.id
COUNT select(auth.count()) SELECT COUNT(*)
EXISTS selectOne().from(auth).where(...).fetchFirst() != null SELECT 1 WHERE EXISTS(...)
서브쿼리 where(auth.id.in(JPAExpressions.select(...))) WHERE id IN (SELECT ...)
페이징 limit(10).offset(5) LIMIT 10 OFFSET 5

 

JPQL vs Native SQL vs QueryDSL 비교 및 설명

 Native SQL

  • 네이티브 SQL은 JPQL과 달리 데이터베이스의 테이블을 직접 대상으로 함
    • 결과가 엔티티 매핑이 보장되지 않음
  • 특정 데이터베이스의 고유 기능(예: JSON 함수, 윈도우 함수, InsertIgnore, OnduplicateUpdate 등)을 사용 가능
@Modifying
@Query(value = "INSERT INTO heritage_entity"
    + " (heritage_Id, heritage_Name, location, heritage_Grade, basic_Description)" 
    + " VALUES (:heritageId, :heritageName, :location, :heritageGrade, :basicDescription)" 
    + " ON DUPLICATE KEY UPDATE" 
    + " heritage_Name = :heritageName, location = :location, heritage_Grade =" 
    + " :heritageGrade, basic_Description = :basicDescription", nativeQuery = true)
int insertOrUpdate(String heritageId, String heritageName, Point location,
    String heritageGrade, String basicDescription);

 

JPQL

  • SQL과 달리 테이블이 아니라 엔티티(Entity)를 대상으로 쿼리를 작성하여 Select *이 아니라 select entity(or alias) 사용
  • 유지보수성 : DB가 엔티티로 추상화되어있기때문에 DB 엔진이나 테이블 구조가 바뀌어도 엔티티 구조에따라 JPQL을 그대로 사용가능
  • JOIN FETCH : 테이블을 Join 시키면서 Join하는 테이블을 eager fetch하도록 한다.
@Query("select v.heritageEntity from VisitedHeritageEntity v join fetch v.heritageEntity where v.memberEntity.memberId = :memberId")
List<HeritageEntity> findAllVisitedHeritageByMemberId(String memberId);
    // Custom query to calculate the average rating for a specific store
    @Query("SELECT AVG(r.rating) FROM ReviewEntity r WHERE r.storeEntity = :storeEntity")
    Double findAverageRatingByStore(@Param("storeEntity") StoreEntity storeEntity);
}

 

QueryDSL (타입 안전한 동적 쿼리 빌더)

JPQL을 Java 코드로 변환하여 더 안전하고, 유지보수하기 쉽게 만든 쿼리 빌더 프레임워크방식. QueryDSL을 사용하면 JPA가 실행할 때 QueryDSL → JPQL → SQL 변환 과정을 거칩니다.

  • 동적 쿼리 작성이 편리함 (where() 절에 조건을 동적으로 추가 가능
  • 자동 완성 지원 (user.username.eq(username)) → 유지보수성 향상

 

 

다양한 쿼리 상황에서 Native SQL, JPQL, QueryDSL 문법 비교

 

단순 조회 (단건 조회)

@Query(value = "SELECT * FROM user_table WHERE username = :username", nativeQuery = true)
Optional<UserEntity> findByUsernameNative(@Param("username") String username);
  • SQL : from 절이 전체 쿼리에 영향을 미치기에 username앞에 테이블을 생략가능. 단, 여러 테이블을 JOIN시에명시
@Query("SELECT u FROM UserEntity u WHERE u.username = :username")
Optional<UserEntity> findByUsername(@Param("username") String username);

 

QUserEntity user = QUserEntity.userEntity;

Optional<UserEntity> userEntity = queryFactory
        .selectFrom(user)
        .where(user.username.eq(username))
        .fetchOne();

 

다중 조건 필터링 조회 (동적 쿼리)

(예: username과 role이 일치하는 유저 검색, 조건이 없으면 전체 검색)

@Query(value = "SELECT * FROM user_table WHERE (:username IS NULL OR username = :username) AND (:role IS NULL OR role = :role)", nativeQuery = true)
List<UserEntity> findUsers(@Param("username") String username, @Param("role") String role);
String jpql = "SELECT u FROM UserEntity u WHERE 1=1";
if (username != null) jpql += " AND u.username = :username";
        if (role != null) jpql += " AND u.role = :role";

@Query(jpql)
List<UserEntity> findUsers(@Param("username") String username, @Param("role") String role);

 

QUserEntity user = QUserEntity.userEntity;

BooleanBuilder builder = new BooleanBuilder();
if (username != null) builder.and(user.username.eq(username));
        if (role != null) builder.and(user.role.eq(role));

List<UserEntity> users = queryFactory
        .selectFrom(user)
        .where(builder)
        .fetch();

 

다중 테이블 JOIN

  • Native SQL :
    • Since the result contains columns from both user_table and order_table, JPA does not know where to map thus object[]
    • native queries cannot directly return JPA entities with relationships. 
@Query(value = "SELECT u.*, o.* FROM user_table u JOIN order_table o ON u.id = o.user_id WHERE u.username = :username", nativeQuery = true)
List<Object[]> findUserOrdersNative(@Param("username") String username);

 

  • JPQL SQL :
    • JOIN FETCH ensures that orders are eagerly loaded into UserEntity.orders
@Query("SELECT u FROM UserEntity u JOIN FETCH u.orders WHERE u.username = :username")
List<UserEntity> findUserOrders(@Param("username") String username);
QUserEntity user = QUserEntity.userEntity;
QOrderEntity order = QOrderEntity.orderEntity;

List<Tuple> userOrders = queryFactory
        .select(user, order)
        .from(user)
        .join(user.orders, order)
        .where(user.username.eq(username))
        .fetch();

 

Native SQL :

/*
SELECT a.id AS auth_id, a.name AS auth_name, COUNT(ua.id) AS user_count
FROM auth a
LEFT JOIN user_auth ua ON ua.auth_id = a.id
GROUP BY a.id, a.name;
 */

 

QueryDSL Code : 

public List<AuthorityDto.AuthListResDao> findAllWithUserCount() {
    List<Tuple> results = queryFactory
            .select(auth.id, auth.name, userAuth.count())
            .from(auth)
            .leftJoin(userAuth).on(userAuth.auth.eq(auth))
            .groupBy(auth.id, auth.name)
            .fetch();