본문 바로가기

개발기술/ORM

JPA 데이터 조회 구현 ; EntityFetch JPA Method, JPQL, QueryDSL, 네이티브SQL

JPA에서 제공하는 주요 조회 쿼리 생성 방법

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

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

 

JPA Method 생성시 Method Naming 룰 in Spring Data JPA

  • findbyId : Id를 기준으로 해당하는 엔티티를 반환
  • save() : PK에 해당하는 엔티티가 존재하지 않으면 create, 존해자면 update 함.
    • 업데이트 : jpa에서는 업데이트문이 존재하지 않고  find()로 엔티티를 조회하고 필요할 필드를 수정후 save()진행함. 그러면 JPA는 조회된 엔티티의 필드를 변경하고 save를 호출할 때 이를 자동으로 변경사항으로 인식하여 update 쿼리를 생성합니다. 이 방식은 JPA의 변경 감지 기능을 이용합니다. 
  • saveAll() : Entity가 List형태로 보관되어 있을 때 전부 저장한다.
  • existById() : Id로 해당 record가 존재하는지 검색하여 boolean값을 반환한다. Custom property를 사용하여 구현도가능
  • findAll() : 
  • findByEntityProperty() : 맵핑된 엔티티의 특정 속성값을 지정하여 조회에 활용 할 수 있다.
  • findAllById:
  • deleteById:
  • deleteAll:

 

JPA Method 생성시 Method Naming 룰 in Spring Data JPA

  • findAllByIdStartingWithIgnoreCase :  SQL문으로 WHERE UPPER(Id) LIKE UPPER('sam%')로 대소문자 구분없이 특정 키워드로 시작하는 ID값을 전체 조회하여 List 형태로 받는다.
  • findAllByCommunityEntity_CommunityId : communityEntity를 부모로 두는 entity를 communityId로 데이터를 찾는다.

  메소드 이름을 설정할때 아래와 같은 규칙을 따라서 생성해야 정확한 sql문 생성하는 메소드를 정의할 수있다.

  • Start with a Prefix:  such as (find...By = read...By =query...By= get...By), count...By, and. findfirstb
  • Specify the Criteria: After the prefix, continue with a property name of the entity you are querying.
  • Conditions and Keywords: . Some common keywords include Containing, Between, LessThan, GreaterThan, Like, In, StartsWith, EndsWith, and more. 
  • Connecting Keywords : For multiple properties, you can use connecting keywords like And and Or to combine conditions.
  • Handling Collections: If you are dealing with collections, you can use expressions like IsEmpty, IsNotEmpty, In, etc., to express conditions on the collections.
  • Ordering Results: You can specify the order of the results by appending OrderBy followed by the property name and direction (Asc or Desc) at the end of the method name..
Optional<User> findByEmailAddressAndLastName(String email, String lastName);
Optional<User> findByAgeGreaterThanEqual(int age);
Optional<User> findByLastNameIgnoreCase(String lastName);
Optional<User> findByStartDateBetween(Date start, Date end);
Optional<User> findByActiveTrue();
Optional<User> findByAgeIn(Collection<Integer> ages);
Optional<User> findByLastNameOrderByFirstNameAsc(String lastName);
  •   쿼리시에 값이 존재하지 않아서 DB에서 아무 응답도 없는 경우도 있기 때문에 이 예외처리를 위해서 Optional으로 값을 받아주고 optional.else로 예외처리를 해준다
  • 엔티티 필드 이름은 카멜 케이스로 작성하지만, 데이터베이스의 컬럼명은 보통 **스네이크 케이스(snake_case)**로 작성됩니다.

 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문을 줄여주는 역할함

 

JPA 커스텀 메소드 정의 시 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 (타입 안전한 동적 쿼리 빌더)

  QueryDSL은 SQL/JPQL 쿼리를 자바 코드로 안전하게 작성할 수 있도록 도와주는 도메인 특화 언어(DSL, Domain-Specific Language)입니다. QueryDSL을 사용하면 JPA가 실행할 때 QueryDSL → JPQL → SQL 변환 과정을 거칩니다.

  • JPQL을 Java 코드로 표현 → 컴파일 시점에 문법 오류 감지 가능
  • 타입 안전성 보장 → IDE 자동완성 지원
  • 유지보수성 향상 → 쿼리 변경이 코드 수준에서 가능
  • 동적 쿼리 작성이 편리함  (사용자 입력이나 조건에 따라 where절에 들어가는 조건이 달라지는 쿼리)
List<String> result = queryFactory
    .select(user.name)
    .from(user)
    .where(
        JPAExpressions
            .select(count())
            .from(order)
            .where(order.user.eq(user))
            .goe(3)  // >= 3
    )
    .fetch();

 

QueryDSL의 설계 철학

  • "모든 SQL 구성 요소를 Java 객체로 모델링한다." 쿼리의 각 구성 요소 (SELECT, FROM, WHERE, GROUP BY 등)를 객체로 다뤄서, 동적 쿼리 빌딩이 가능하고, 타입 안정성도 확보됨.

QueryDSL 내에서의 SQL 구성 요소

구분 SQL 개념 역할 QueryDSL 상 대응 설명
Clause SQL 절
(SELECT, FROM 등)
메서드(Method) 쿼리의 골격이 되는 부분.
예: .select(), .from(), .where()
Expression 값, 수식, 컬럼 참조 등 메서드의 파라미터(Parameter) 값을 만들어내는 모든 요소.
예: user.name, age.add(5)
Predicate 조건식 (Boolean 결과) where()에 들어가는 Parameter Expression<Boolean> 타입.
예: user.age.gt(18)→ 논리 연산도 가능: .and(), .or()

 

QueryDSL의 구성 객체

구성 객체 역할
JPAQueryFactory 쿼리 빌더의 시작점. .select(), .selectFrom() 등 메서드 제공
JPAQuery<T> 쿼리의 중간 상태를 나타냄. 체이닝을 통해 .where(), .groupBy(), .fetch() 등 연결
Expression<T> 컬럼, 상수, 수식, SQL 함수 등을 표현하는 값 표현식
Predicate Expression<Boolean> 형태. 조건식이며, .where() 절에 들어감

 

  • 실행 메서드 (terminal operation)  : 지금까지 구성한 쿼리를 DB에 날리고 결과를 받는 트리거 메서드
    • .fetch() 결과 리스트 반환 (List<T>)
    • .fetchOne() 단일 결과 반환 (T)
    • .fetchFirst() 첫 번째 결과만 반환
    • .fetchCount() COUNT 쿼리 수행
    • .execute() INSERT/UPDATE/DELETE 같은 실행

3. 쿼리 구성 요소별 분류

3.1 SQL 절 기반 (Clause)

절 (Clause) QueryDSL 메서드
SELECT .select() : 어떤 값을 선택할지 expression이나 projection dto를 명확하게 지정,
.selectFrom() : entity 전체선택
FROM .from()
WHERE .where(Predicate...)
GROUP BY .groupBy()
ORDER BY .orderBy()
JOIN .join(), .leftJoin(), .fetchJoin()
HAVING .having() (거의 안 씀)
 

3.2 Expression (표현식)

SQL에서 "값을 만들어내는 요소"를 말함. (컬럼, 상수, 계산식, 함수 등)

분류 예시 QueryDSL 표현
Column reference user.name user.name (e.g., StringPath)
Constant/literal 'John', 5 .eq("John"), .add(5)
연산식 (Operation) price * 1.1 product.price.multiply(1.1)
SQL 함수 UPPER(user.name) Expressions.stringTemplate("UPPER({0})", user.name)
서브쿼리 (SELECT ...) JPAExpressions.select(...)
Boolean 연산 AND, OR .and(), .or(), .not()
 

3.3 Predicate (조건식)

WHERE 절에 들어가는 불리언 조건.

예시 QueryDSL 표현
age > 18 user.age.gt(18)
name = '홍길동' user.name.eq("홍길동")
age between 20 and 30 user.age.between(20, 30)
name like '김%' user.name.like("김%")
복합 조건 .and(), .or(), .not()

 

주요 구성요소

JPAQueryFactory 

  • QueryDSL 쿼리의 시작점이 되는 SQL 쿼리 빌더 객체로 내부적으로 JPAQuery<T> 객체를 생성함
  • JPAQuery<T>는 selectFrom(), where(), fetch() 체이닝을 지원하여 결과적으로 쿼리 실행하고 결과 반환

JPAQuery<T>

  • SQL 쿼리 조립의 중간 결과물이자 실행 주체
  • JPAQuery<T>는 selectFrom(), where(), fetch() 체이닝을 지원하여 .fetch(), .fetchOne(), .execute() 등을 만나는 시점에결과적으로 쿼리 실행하고 결과 반환  
JPAQueryFactory
→ selectFrom(...) → JPAQuery<T>
    → where(...) → JPAQuery<T>
        → fetch() → List<T>

 

  • SQL내에서 절 (Clause) ; SELECT, FROM, WHERE, GROUP BY, ORDER BY과 구분되는 개념으로 값 자체나 값을 생성하는 것으로 테이블필드, 값을 생성하는 함수 및 계산식등을 지칭한다. 

 

QueryDSL에서의 표현식(Expression)은 계산식, 상수, SQL 함수, 서브쿼리, Q타입 객체  등이 존재한다

  • Column reference ; user.name → user.name :   Q타입의 클래스 필드 중 하나로 StringPath 타입으로 표현됨
  • Constant/literal (e.g., "John")

Operation/function

  • NumberExpression 타입 ;
    • age + 5  user.age.add(5) :   
    • price - 100 → product.price.subtract(100)
    • amount * 1.1 → order.amount.multiply(1.1)
    • age / 2 → user.age.divide(2)
  • Expressions.stringTemplate("UPPER({0})", user.name) :  QueryDSL에서 지원하지 않는 SQL 함수를 직접 사용하기 위해,
    문자열 템플릿 형식으로 SQL을 작성하는 표현식(Expression)
Expressions.stringTemplate("GROUP_CONCAT(DISTINCT {0})", aIService.name),
  • JPAExpressions.select(...) : 서브쿼리를 만들기 위한 도구입니다. → SQL의 (SELECT ...) 안쪽 쿼리를 만들고, 외부 쿼리에 끼워 넣을 수 있게 해줍니다
List<String> result = queryFactory
    .select(user.name)
    .from(user)
    .where(
        JPAExpressions
            .select(count())
            .from(order)
            .where(order.user.eq(user))
            .goe(3)  // >= 3
    )
    .fetch();

 

 

Predicate  : where() 안에 들어가는 조건절

  • age > 18 → user.age.gt(18) 
  • name = '홍길동' → user.name.eq("홍길동")
  • AND, OR, NOT  .and(), .or(), .not()

JPAQuery 구현

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 {
}

 

동적쿼리 구현

  • Every SQL component is modeled as an object: These components are fully composable and replaceable thus Dynamic Composition,각 Expression을 객체로 다루기때문에 아래처럼 동적인 쿼리빌딩이 가능함
  • where()에는 여러 조건을 동적으로 .and() / .or() 로 조합 가능
public List<BookSummaryDto> findBookSummaries(BookSearchType type, String keyword) {
    return factory
        .select(Projections.constructor(
            BookSummaryDto.class,
            book.id,
            book.title,
            book.publisher,
            Expressions.stringTemplate("GROUP_CONCAT(DISTINCT {0})", author.name),
            Expressions.stringTemplate("GROUP_CONCAT(DISTINCT {0})", genre.name)
        ))
        .from(book)
        .leftJoin(bookAuthor).on(bookAuthor.book.eq(book))
        .leftJoin(author).on(author.eq(bookAuthor.author))
        .leftJoin(bookGenre).on(bookGenre.book.eq(book))
        .leftJoin(genre).on(genre.eq(bookGenre.genre))
        .where(bookSearchCondition(type, keyword))
        .groupBy(book.id)
        .fetch();
}

 

private BooleanExpression bookSearchCondition(BookSearchType type, String keyword) {
    if (!StringUtils.hasText(keyword)) return null;

    switch (type) {
        case Title:
            return book.title.containsIgnoreCase(keyword);
        case Publisher:
            return book.publisher.containsIgnoreCase(keyword);
        case Year:
            return book.publishedYear.stringValue().contains(keyword);
        case All:
        default:
            return book.title.containsIgnoreCase(keyword)
                .or(book.publisher.containsIgnoreCase(keyword))
                .or(book.publishedYear.stringValue().contains(keyword));
    }
}

 

 

Multi-Table Joins (QueryDSL / JPQL)

  • Join Syntax Rule
    • pathToB must be a navigable field in A (from JPA mapping)
.join(A.pathToB, B)
join(company.cameraSet, camera)  // ✅ Valid if company has cameraSet
join(camera.company, company)    // ✅ Valid if camera has company
  • Manual Join is Allowed Using .on()
.leftJoin(camera).on(camera.company.eq(company)) // ✅ Valid, SQL-style

 

QueryDSL로 DTO Projection 하기

Constructor Projection를 활용

DTO에 생성자(Constructor)를 이용해 값을 주입하는 방식

.select(Projections.constructor(MemberDto.class, member.name, member.age))
  • 이 메서드는 결국 “쿼리 결과로 나온 열(값)들을 생성자 인자로 전달”하는 구조입니다. 인자는 DTO의 생성자의 파라미터로 들어가기 때문에 생성자의 순서와 타입이 동일해야함
  • Q타입 필드는 
// DTO
public class MemberDto {
    public MemberDto(String name, int age) { ... }
}

// QueryDSL
.select(Projections.constructor(MemberDto.class, member.name, member.age))

 

실전 활용

List<UserDto> result = queryFactory
    .select(Projections.constructor(UserDto.class,
        user.name,
        user.age,
        team.name,
        Expressions.constant("고정값")
    ))
    .from(user)
    .join(user.team, team)
    .where(user.age.gt(20)
        .and(team.name.startsWith("개발"))
        .or(user.status.eq(Status.ACTIVE))
    )
    .orderBy(user.age.desc())
    .fetch();

 

요약:

순서 내용 왜 필요한가
1 Q타입 사용하는 법 기본 쿼리 작성
2 select / where / join / orderBy 흐름 CRUD 구현
3 BooleanBuilder 동적 쿼리
4 DTO 조회 (Projections) Entity 말고 필요한 데이터만 조회
5 fetchJoin N+1 문제 해결 (성능)
6 서브쿼리 복잡한 조건 처리


다양한 쿼리 상황에서 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();