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();
'개발기술 > ORM' 카테고리의 다른 글
Hibernate Persistence Context, EntityManager (0) | 2025.03.18 |
---|---|
JPA 데이터 맵핑방식 : Entity 기반, DTO 기반 조회 (0) | 2025.03.05 |
JPA 기타기능 (Pageable, auditing, data.sql 기능) (0) | 2025.02.28 |
JPA 활용의 필요성과 활용법 (0) | 2025.02.27 |
Spring JPA 트랜잭션 (0) | 2024.12.22 |