자바 백엔드 입문 49편. JPA의 쿼리 작성 3가지 방식 — 메서드 이름 쿼리·@Query 어노테이션·QueryDSL을 자동완성 검색창 비유로 풀어쓴 학습 노트.
이 글은 자바 백엔드 입문 시리즈 59편 중 49편이에요. 45편에서 JpaRepository 의 16개 기본 CRUD를 봤다면, 이번 49편은 "기본 CRUD로 안 되는 커스텀 조회" 를 어떻게 짜는지 — JPA의 쿼리 작성 3가지 방식.
JPA 쿼리가 헷갈리는 이유
findByStatus("ACTIVE") 만 박으면 — JPA가 알아서 WHERE status = ? SQL을 만들어줘요. 어떻게? 그리고 더 복잡한 쿼리는 어떻게 짜나? "메서드 이름이 길어지면 어디까지 자동 처리되나" 가 막막.
이 글에서는 자동완성 검색창 비유로 풀어요. 메서드 이름 쿼리 = "검색창에 키워드 박으면 자동완성", @Query = "수동 검색식 직접 입력", QueryDSL = "검색 빌더 도구로 조건 조립". 세 방식이 한 그림에 들어와요.
방식 1 — 메서드 이름 쿼리 (Query Methods)
가장 마법 같은 기능. JpaRepository 인터페이스에 메서드 이름만 정의하면 — Spring Data JPA가 시작 시 메서드 이름을 분석해서 자동으로 SQL을 생성·실행해줘요.
public interface OrderRepository extends JpaRepository<Order, Long> {
// 1. 단일 조건
List<Order> findByStatus(String status);
// 2. 복합 조건
List<Order> findByStatusAndAmountGreaterThan(String status, int amount);
// 3. 정렬
List<Order> findByStatusOrderByCreatedAtDesc(String status);
// 4. 카운트
long countByStatus(String status);
// 5. 존재 여부
boolean existsByOrderNumber(String orderNumber);
// 6. 첫 N개
List<Order> findTop5ByOrderByAmountDesc();
// 7. Optional 반환
Optional<Order> findByOrderNumber(String orderNumber);
// 8. 삭제
void deleteByStatusAndCreatedAtBefore(String status, LocalDateTime cutoff);
}
이 8개 메서드 — 본문 한 줄도 안 짰는데 Spring Data JPA가 시작 시 메서드 이름 분석해서 SQL 자동 생성. 정말 마법.
메서드 이름 키워드 표준
메서드 이름에 박을 수 있는 키워드들.
비교 연산
| 키워드 | SQL |
|---|---|
Is·Equals (생략 가능) |
= |
Not |
<> |
LessThan·Before |
< |
LessThanEqual |
<= |
GreaterThan·After |
> |
GreaterThanEqual |
>= |
Between |
BETWEEN ? AND ? |
Like·StartingWith·EndingWith·Containing |
LIKE |
In |
IN (?, ?, ?) |
IsNull·IsNotNull |
IS NULL·IS NOT NULL |
True·False |
boolean 비교 |
정렬·페이징
| 키워드 | 역할 |
|---|---|
OrderBy{Field}Asc/Desc |
ORDER BY |
Top<N>·First<N> |
LIMIT |
논리 결합
| 키워드 | 역할 |
|---|---|
And |
AND |
Or |
OR |
조합 예:
// status = ? AND amount > ? ORDER BY created_at DESC
List<Order> findByStatusAndAmountGreaterThanOrderByCreatedAtDesc(String status, int amount);
// product_id IN (?, ?, ?)
List<Order> findByProductIdIn(List<Long> productIds);
// title LIKE '%검색어%'
List<Product> findByTitleContaining(String keyword);
// created_at BETWEEN ? AND ?
List<Order> findByCreatedAtBetween(LocalDateTime start, LocalDateTime end);
메서드 이름이 길어지면 가독성 낮아져요. 보통 "조건 3~4개까지가 한계", 그 이상은 @Query 방식으로.
방식 2 — @Query 어노테이션
메서드 이름이 너무 길어지거나 복잡한 조인이 필요하면 — @Query 에 SQL을 직접 작성.
public interface OrderRepository extends JpaRepository<Order, Long> {
// JPQL — 객체 지향 쿼리 언어
@Query("SELECT o FROM Order o WHERE o.status = :status AND o.amount > :min")
List<Order> findActiveOrdersAbove(@Param("status") String status, @Param("min") int min);
// 조인
@Query("SELECT o FROM Order o JOIN o.user u WHERE u.email = :email")
List<Order> findByUserEmail(@Param("email") String email);
// 네이티브 SQL — DB 고유 함수 사용
@Query(value = "SELECT * FROM orders WHERE created_at > NOW() - INTERVAL '7 days'",
nativeQuery = true)
List<Order> findRecent();
// UPDATE·DELETE는 @Modifying 필요
@Modifying
@Query("UPDATE Order o SET o.status = 'EXPIRED' WHERE o.expiresAt < CURRENT_TIMESTAMP")
int expireOldOrders();
}
JPQL vs 네이티브 SQL
| JPQL | 네이티브 SQL | |
|---|---|---|
| 대상 | Entity 클래스·필드 이름 | DB 테이블·컬럼 이름 |
| DB 호환성 | DB 바꿔도 동작 | DB 종속 |
| 학습 곡선 | JPQL 문법 익혀야 | 일반 SQL 그대로 |
| 사용 빈도 | 기본 (90%) | 복잡 쿼리·DB 고유 기능 시 |
JPQL은 "Order"·"u.email" 처럼 Entity 클래스·필드 이름을 쓰는 객체 지향 쿼리 언어. DB 테이블·컬럼 이름은 안 써요. Spring Data JPA가 시작 시 JPQL → 실제 SQL 변환.
방식 3 — QueryDSL (보너스)
@Query 도 한계가 있어요 — 문자열이라 컴파일 시점에 오타·타입 오류 검출 X. 큰 시스템에서는 QueryDSL 같은 타입 안전 빌더를 추가로 씀.
QOrder order = QOrder.order;
List<Order> result = queryFactory
.selectFrom(order)
.where(
order.status.eq("ACTIVE"),
order.amount.gt(10000)
)
.orderBy(order.createdAt.desc())
.limit(10)
.fetch();
코드처럼 생긴 빌더로 쿼리 작성. 컴파일 시점에 오타 잡힘, IDE 자동완성 동작. 한국 회사 시스템 절반 이상이 "기본 Spring Data JPA + 복잡 쿼리에 QueryDSL" 조합.
QueryDSL은 별도 의존성 + 어노테이션 프로세서 설정 필요. 이 시리즈에서는 깊이 다루지 않지만 "이런 게 있다" 만 알아두세요.
페이징·정렬 — Pageable·Sort
JPA에는 페이징·정렬을 위한 표준 객체 두 개.
public interface OrderRepository extends JpaRepository<Order, Long> {
Page<Order> findByStatus(String status, Pageable pageable);
List<Order> findByStatus(String status, Sort sort);
}
// Service
public Page<Order> search(String status, int page, int size) {
Pageable pageable = PageRequest.of(
page,
size,
Sort.by(Sort.Direction.DESC, "createdAt")
);
return orderRepo.findByStatus(status, pageable);
}
Page<T> 가 페이징 결과 — 본문(getContent()) + 메타(getTotalElements()·getTotalPages()·hasNext()) 한 객체. 클라이언트가 "3페이지의 20개, 전체 100개 중 60~80번째" 정보를 한 번에 받기 쉬워요.
컨트롤러에서 매개변수로 받기:
@GetMapping
public Page<OrderResponse> list(
@RequestParam(required = false) String status,
@PageableDefault(size = 20, sort = "createdAt", direction = Direction.DESC)
Pageable pageable) {
return orderService.search(status, pageable);
}
// GET /orders?status=ACTIVE&page=0&size=20&sort=createdAt,desc
Spring Data가 ?page=0&size=20&sort=... 쿼리 스트링을 자동으로 Pageable 객체로 변환. 한국 회사 REST API의 거의 표준 페이징 패턴.
Projection — 일부 필드만 조회
SELECT * 는 성능 부담. Projection 으로 일부 필드만 가져오기.
인터페이스 기반
public interface OrderSummary {
Long getId();
String getStatus();
int getAmount();
}
public interface OrderRepository extends JpaRepository<Order, Long> {
List<OrderSummary> findByStatusOrderByCreatedAtDesc(String status);
}
JPA가 자동으로 — "OrderSummary 인터페이스에 박힌 getter 3개" 에 해당하는 컬럼만 SELECT.
DTO 클래스 기반 (생성자 표현식)
@Query("SELECT new com.example.OrderSummary(o.id, o.status, o.amount) FROM Order o WHERE o.status = :status")
List<OrderSummary> findSummariesByStatus(@Param("status") String status);
JPQL 안에서 DTO 생성자를 직접 호출. 결과를 DTO 리스트로 직접 받음.
Projection 활용하면 — 큰 Entity의 일부 필드만 조회해 메모리·네트워크 부담 절감.
N+1 함정 — JPA의 가장 큰 위험
JPA가 자동으로 처리하는 만큼, "한 쿼리" 가 "수십·수백 쿼리" 로 늘어나는 함정이 있어요. 대표가 N+1 문제.
List<Order> orders = orderRepo.findAll(); // 1번 쿼리: SELECT * FROM orders
for (Order order : orders) {
System.out.println(order.getUser().getName()); // N번 쿼리: SELECT * FROM users WHERE id = ?
}
100개 주문이면 — 1번 + 100번 = 101번 쿼리. 정말 느려져요. 해결법 = Fetch Join.
@Query("SELECT o FROM Order o JOIN FETCH o.user")
List<Order> findAllWithUser();
// 1번 쿼리: SELECT o.*, u.* FROM orders JOIN users
32편 영속성 컨텍스트에서 더 자세히 다룰 예정. "JPA 잘하는 사람 = N+1 알고 회피하는 사람" 이라는 농담이 있을 만큼 핵심 함정.
단순 조건 1~3개 → 메서드 이름 쿼리. 조인·복잡 조건 → @Query JPQL. DB 고유 함수·튜닝 → @Query 네이티브 SQL. 동적 조건 조합 많음 → QueryDSL. 큰 시스템은 4가지 다 혼용.
한 줄 정리 — JPA 쿼리 3방식 = 메서드 이름 쿼리·@Query·QueryDSL. 단순은 메서드 이름, 복잡은 @Query, 동적은 QueryDSL. 페이징은 Pageable+Page<T> 표준. N+1 함정 회피 = Fetch Join.
시험 직전 한 번 더 — JPA 쿼리 입문자가 매번 헷갈리는 것
- 쿼리 작성 3방식 = 메서드 이름 쿼리 /
@Query/ QueryDSL - 메서드 이름 쿼리 =
findByStatus(...)같이 메서드 이름으로 자동 SQL - 비교 키워드 =
LessThan·GreaterThan·Between·Like·In·IsNull등 - 논리 결합 =
And·Or - 정렬 =
OrderBy{Field}Asc/Desc - 제한 =
Top<N>·First<N> - 조건 3~4개까지가 메서드 이름 한계 — 그 이상은
@Query @Query= JPQL 또는 네이티브 SQL 직접 작성- JPQL = Entity 클래스·필드 이름 (
Order·o.amount) - 네이티브 SQL = 실제 테이블·컬럼 이름.
nativeQuery = true옵션 - JPQL = DB 호환성 유지, 네이티브 = DB 고유 기능
- 매개변수 =
@Param("name")+ JPQL의:name @Modifying= UPDATE·DELETE 쿼리 필수 어노테이션- QueryDSL = 타입 안전 쿼리 빌더 (컴파일 시 검증)
- 한국 회사 = Spring Data JPA + QueryDSL 조합 흔함
Pageable+Page<T>= 페이징 표준- 컨트롤러 매개변수로
Pageable받으면 자동 변환 @PageableDefault= 기본 size·sort 지정- Projection = 일부 필드만 조회 (성능 최적화)
- 인터페이스 기반 Projection 또는 DTO 생성자 표현식
- N+1 문제 = 1번 쿼리가 N+1번으로 폭발하는 함정
- 해결 = Fetch Join (
JOIN FETCH) - 32편 영속성 컨텍스트에서 깊이 다룸
시리즈 다른 편 (앞뒤 글 모음)
이전 글:
- 44편 — JPA Hibernate Spring Data JPA 셋의 관계
- 45편 — @Entity Repository JPA 두 축
- 46편 — JPA 연관관계 @OneToMany @ManyToOne
- 47편 — JPA @Embedded @Embeddable 값 객체
- 48편 — JPA Auditing @CreatedDate @LastModifiedDate
다음 글: