실무에서 바로 쓰는 JPA (3편) - 쿼리 최적화 실전
TL;DR
- 문제: 복잡한 조건의 동적 쿼리, 컬렉션 2개 이상 Fetch Join, 페이징 + 조인 등 실무 쿼리가 어려움
- 원인: N+1, 영속성 컨텍스트 해결했지만 복잡한 쿼리는 별도 최적화 필요
- 해결: QueryDSL + Projection (DTO 직접 조회), Batch Size 조합, 필요한 컬럼만 조회
- 효과: 응답 시간 3초 → 300ms, 데이터 전송량 50% 감소, 동적 쿼리 깔끔
- 한계: QueryDSL 학습 곡선, 빌드 설정 복잡, DTO Projection은 영속성 컨텍스트 미적용, 코드량 증가
환경
- 프레임워크: Spring Boot 3.x + JPA + QueryDSL 5.x
- DB: MySQL 8.0, InnoDB
- 데이터 규모: 복잡한 조건의 동적 쿼리, 컬렉션 2개 이상 조회
- 성능 목표: 응답 시간 3초 → 300ms 개선
시리즈 안내
이 글은 “실무에서 바로 쓰는 JPA” 시리즈의 세 번째 편입니다.
1편: N+1 문제 해결
연관관계 조회 시 발생하는 성능 문제와 해결 방법을 다룹니다.
2편: 영속성 컨텍스트와 흔한 실수들
JPA의 내부 동작 원리와 실수하기 쉬운 포인트들을 짚어봅니다.
3편 (현재 글): 쿼리 최적화 실전
복잡한 쿼리를 효율적으로 작성하는 노하우를 공유합니다.
4편: 엔티티 설계 마스터하기
유지보수하기 좋은 엔티티 설계 원칙을 다룹니다.
5편: 실무 안티패턴 총정리
실무에서 자주 발생하는 실수와 개선 방법을 총정리합니다.
이전 편 요약:
1편에서 N+1 문제와 Fetch Join을, 2편에서 영속성 컨텍스트와 페이징 문제를 다뤘습니다. 기본적인 문제들은 해결했는데, 실무에서는 더 복잡한 상황이 기다리고 있더군요. 컬렉션을 2개 이상 조회해야 하거나, 복잡한 조건의 동적 쿼리를 작성해야 하는 경우들이죠.
이번 편에서는 이런 복잡한 쿼리 최적화 문제들과 해결 방법을 다룹니다.
서론
1편에서 N+1 문제를, 2편에서 영속성 컨텍스트와 Fetch Join의 함정을 다뤘습니다.
근데 문제는 거기서 끝이 아니더군요. Fetch Join으로 N+1을 해결하고, 페이징 문제도 @BatchSize로 넘겼는데, 이번엔 또 다른 문제가 터졌습니다.
org.hibernate.loader.MultipleBagFetchException:
cannot simultaneously fetch multiple bags“뭐야 이게?”
구글에 검색해보니 “컬렉션 2개 이상 fetch join하면 안 된다”는 답변이 나왔습니다. 그럼 어떻게 하라는 건데요?
이번 포스팅에서는 제가 실무에서 마주친 JPA 쿼리 최적화 문제들과 해결 방법을 공유합니다.
fetch join 제대로 쓰기
MultipleBagFetchException의 함정
주문과 주문 상품, 배송 정보를 한 번에 조회하려고 했습니다.
@Entity
public class Order {
@Id
private Long id;
@OneToMany(mappedBy = "order")
private List<OrderItem> items; // 컬렉션 1
@OneToMany(mappedBy = "order")
private List<Delivery> deliveries; // 컬렉션 2
}
@Query("SELECT o FROM Order o " +
"JOIN FETCH o.items " +
"JOIN FETCH o.deliveries")
List<Order> findAllWithItemsAndDeliveries();결과:
org.hibernate.loader.MultipleBagFetchException:
cannot simultaneously fetch multiple bagsHibernate는 컬렉션(List) 2개 이상을 동시에 Fetch Join할 수 없습니다. 이를 시도하면 MultipleBagFetchException이 발생합니다.
왜 이런 제약이 있을까?
graph TB
Order[Order 1건]
Items[OrderItem 3건]
Deliveries[Delivery 2건]
Order --> Items
Order --> Deliveries
Result[카르테시안 곱<br/>3 x 2 = 6건]
Items -.-> Result
Deliveries -.-> Result
style Result fill:#ffebee컬렉션 2개를 fetch join하면 카르테시안 곱이 발생합니다. 주문 1건에 주문상품 3건, 배송 2건이면 결과는 6건이 됩니다. 중복 데이터가 엄청나게 생기는 거죠.
해결 방법 1: @BatchSize 사용
가장 간단한 해결 방법입니다.
@Entity
public class Order {
@Id
private Long id;
@BatchSize(size = 100)
@OneToMany(mappedBy = "order")
private List<OrderItem> items;
@BatchSize(size = 100)
@OneToMany(mappedBy = "order")
private List<Delivery> deliveries;
}
// Repository
List<Order> findAll(); // fetch join 없이 그냥 조회실행되는 쿼리:
-- 1번: 주문 조회
SELECT * FROM orders; -- 10건
-- 2번: 주문 상품 일괄 조회
SELECT * FROM order_item WHERE order_id IN (1,2,3,...10);
-- 3번: 배송 정보 일괄 조회
SELECT * FROM delivery WHERE order_id IN (1,2,3,...10);
-- 총 3개 쿼리로 해결N+1 문제도 없고, MultipleBagFetchException도 안 납니다.
해결 방법 2: 하나는 fetch join, 나머지는 @BatchSize
// 더 중요한 데이터(items)는 fetch join
@Query("SELECT DISTINCT o FROM Order o JOIN FETCH o.items")
List<Order> findAllWithItems();
// deliveries는 @BatchSize로 처리
@Entity
public class Order {
@BatchSize(size = 100)
@OneToMany(mappedBy = "order")
private List<Delivery> deliveries;
}items는 즉시 로딩, deliveries는 지연 로딩 + BatchSize 전략입니다.
distinct의 함정
fetch join 쓸 때 DISTINCT를 추가하곤 합니다.
@Query("SELECT DISTINCT o FROM Order o JOIN FETCH o.items")
List<Order> findAllWithItems();근데 이게 생각만큼 간단하지 않습니다.
실행되는 SQL:
SELECT DISTINCT
o.id, o.order_date,
i.id, i.product_name, i.quantity
FROM orders o
INNER JOIN order_item i ON o.id = i.order_idDB에 DISTINCT가 날아갑니다. 근데 order_item이 다르기 때문에 DB 입장에서는 모든 row가 다릅니다. DISTINCT가 소용없는 거죠.
그럼 왜 쓰나요?
Hibernate가 애플리케이션 레벨에서 중복을 제거해줍니다.
sequenceDiagram
participant App as 애플리케이션
participant JPA as Hibernate
participant DB as 데이터베이스
App->>JPA: SELECT DISTINCT ... JOIN FETCH
JPA->>DB: SELECT DISTINCT ...<br/>(DB에도 전달)
DB-->>JPA: 중복 포함 결과<br/>(DB는 중복 제거 못함)
Note over JPA: 애플리케이션 레벨에서<br/>Order 엔티티 중복 제거
JPA-->>App: 중복 제거된 결과DB의 DISTINCT는 조인으로 인해 효과가 없을 수 있지만, Hibernate가 애플리케이션 레벨에서 엔티티의 중복을 추가로 제거해줍니다.
실무 팁: 언제 뭘 쓸까?
저는 이런 기준으로 선택합니다.
| 상황 | 추천 방법 | 이유 |
|---|---|---|
| 컬렉션 1개만 | fetch join + DISTINCT | 가장 효율적 |
| 컬렉션 2개 이상 | 전부 @BatchSize | MultipleBagFetchException 회피 |
| 컬렉션 1개 + 단건 | fetch join 조합 가능 | @ManyToOne은 여러 개 가능 |
| 페이징 필요 | @BatchSize 필수 | fetch join은 메모리 페이징 |
Projection - 필요한 것만 가져오기
Entity 조회의 문제점
이런 코드를 짜본 적 있으시죠?
@GetMapping("/orders")
public List<OrderDto> getOrders() {
List<Order> orders = orderRepository.findAll();
return orders.stream()
.map(order -> new OrderDto(
order.getId(),
order.getCustomer().getName(),
order.getTotalAmount()
))
.collect(Collectors.toList());
}Order 엔티티를 전부 조회하지만, 실제로 쓰는 건 3개 필드뿐입니다.
문제점:
- 불필요한 필드까지 모두 조회
- 엔티티 전체를 메모리에 적재
- 연관된 엔티티도 프록시로 초기화
graph LR
Entity[Order 엔티티<br/>전체 조회]
Use[사용: id, name, amount<br/>미사용: 나머지 20개 필드]
Waste[낭비]
Entity --> Use
Entity --> Waste
style Waste fill:#ffebeeProjection으로 해결
필요한 것만 조회합니다.
public interface OrderSummary {
Long getId();
String getCustomerName();
BigDecimal getTotalAmount();
}
public interface OrderRepository extends JpaRepository<Order, Long> {
@Query("SELECT o.id as id, c.name as customerName, o.totalAmount as totalAmount " +
"FROM Order o JOIN o.customer c")
List<OrderSummary> findOrderSummaries();
}실행되는 쿼리:
SELECT o.id, c.name, o.total_amount
FROM orders o
INNER JOIN customer c ON o.customer_id = c.id딱 필요한 3개 컬럼만 조회합니다.
DTO 직접 조회
인터페이스 대신 DTO 클래스를 직접 사용할 수도 있습니다.
public class OrderDto {
private Long id;
private String customerName;
private BigDecimal totalAmount;
public OrderDto(Long id, String customerName, BigDecimal totalAmount) {
this.id = id;
this.customerName = customerName;
this.totalAmount = totalAmount;
}
}
@Query("SELECT new com.example.dto.OrderDto(o.id, c.name, o.totalAmount) " +
"FROM Order o JOIN o.customer c")
List<OrderDto> findOrderDtos();장단점:
| 방법 | 장점 | 단점 |
|---|---|---|
| 인터페이스 | 간결함, 타입 안전 | 프록시 오버헤드 |
| DTO 클래스 | 성능 좋음, 명확함 | JPQL에 패키지명 필요 |
| Entity | 영속성 컨텍스트 활용 | 불필요한 데이터 조회 |
저는 보통 DTO 클래스를 씁니다. 조금 번거롭지만 성능이 좋고 명확하거든요.
성능 비교
실제로 얼마나 차이 날까요?
테스트 환경:
- 주문 1,000건 조회
- Order 엔티티: 15개 필드
- Projection: 3개 필드만
결과:
Entity 조회 vs DTO Projection
4배 빠르고, 메모리는 5분의 1 수준입니다.
물론 데이터양과 필드 수에 따라 다르지만, 차이는 확실합니다.
QueryDSL 실전 활용
JPQL의 한계
복잡한 동적 쿼리를 JPQL로 짜면 이렇게 됩니다.
public List<Order> findOrders(OrderSearchCondition condition) {
String jpql = "SELECT o FROM Order o JOIN o.customer c WHERE 1=1 ";
if (condition.getCustomerName() != null) {
jpql += "AND c.name LIKE :customerName ";
}
if (condition.getMinAmount() != null) {
jpql += "AND o.totalAmount >= :minAmount ";
}
if (condition.getStatus() != null) {
jpql += "AND o.status = :status ";
}
TypedQuery<Order> query = em.createQuery(jpql, Order.class);
if (condition.getCustomerName() != null) {
query.setParameter("customerName", "%" + condition.getCustomerName() + "%");
}
// ... 파라미터 설정 반복
return query.getResultList();
}이거 보고 “이건 아니다” 싶었습니다.
QueryDSL로 깔끔하게
@RequiredArgsConstructor
public class OrderRepositoryImpl implements OrderRepositoryCustom {
private final JPAQueryFactory queryFactory;
@Override
public List<Order> findOrders(OrderSearchCondition condition) {
return queryFactory
.selectFrom(order)
.join(order.customer, customer).fetchJoin()
.where(
customerNameLike(condition.getCustomerName()),
totalAmountGoe(condition.getMinAmount()),
statusEq(condition.getStatus())
)
.fetch();
}
private BooleanExpression customerNameLike(String customerName) {
return customerName != null ? customer.name.contains(customerName) : null;
}
private BooleanExpression totalAmountGoe(BigDecimal minAmount) {
return minAmount != null ? order.totalAmount.goe(minAmount) : null;
}
private BooleanExpression statusEq(OrderStatus status) {
return status != null ? order.status.eq(status) : null;
}
}깔끔합니다. null 체크도 자연스럽고, 재사용 가능한 조건을 메서드로 분리할 수 있습니다.
복잡한 조인 쿼리 리팩토링
실무에서 작성했던 쿼리입니다. 주문 통계를 조회하는 쿼리였는데, JPQL로 짜니까 너무 복잡했습니다.
Before - JPQL:
@Query("SELECT new com.example.dto.OrderStatDto(" +
"FUNCTION('DATE_FORMAT', o.orderDate, '%Y-%m'), " +
"COUNT(o), " +
"SUM(o.totalAmount), " +
"AVG(o.totalAmount)) " +
"FROM Order o " +
"WHERE o.orderDate BETWEEN :startDate AND :endDate " +
"AND o.status IN :statuses " +
"GROUP BY FUNCTION('DATE_FORMAT', o.orderDate, '%Y-%m') " +
"HAVING COUNT(o) > :minCount " +
"ORDER BY FUNCTION('DATE_FORMAT', o.orderDate, '%Y-%m') DESC")
List<OrderStatDto> findOrderStatistics(
@Param("startDate") LocalDateTime startDate,
@Param("endDate") LocalDateTime endDate,
@Param("statuses") List<OrderStatus> statuses,
@Param("minCount") Long minCount
);이거 디버깅하기 정말 힘들었습니다.
After - QueryDSL:
public List<OrderStatDto> findOrderStatistics(OrderStatSearch search) {
StringTemplate yearMonth = Expressions.stringTemplate(
"DATE_FORMAT({0}, {1})",
order.orderDate,
ConstantImpl.create("%Y-%m")
);
return queryFactory
.select(Projections.constructor(OrderStatDto.class,
yearMonth,
order.count(),
order.totalAmount.sum(),
order.totalAmount.avg()
))
.from(order)
.where(
order.orderDate.between(search.getStartDate(), search.getEndDate()),
order.status.in(search.getStatuses())
)
.groupBy(yearMonth)
.having(order.count().gt(search.getMinCount()))
.orderBy(yearMonth.desc())
.fetch();
}훨씬 읽기 쉽고, 타입 안전합니다.
JPQL vs QueryDSL 선택 기준
제 경험상 이런 기준으로 선택합니다.
graph TD
Start{쿼리 작성}
Simple{단순 조회?}
Dynamic{동적 조건?}
Complex{복잡한 조인/집계?}
SpringDataJPA[Spring Data JPA<br/>메서드 쿼리]
JPQL[JPQL<br/>@Query]
QueryDSL[QueryDSL]
Start --> Simple
Simple -->|Yes| SpringDataJPA
Simple -->|No| Dynamic
Dynamic -->|Yes| QueryDSL
Dynamic -->|No| Complex
Complex -->|Yes| QueryDSL
Complex -->|No| JPQL
style QueryDSL fill:#e8f5e9
style SpringDataJPA fill:#e1f5fe
style JPQL fill:#fff3e0기준:
- 단순 조회 → Spring Data JPA 메서드 쿼리
- 동적 쿼리 → QueryDSL
- 복잡한 집계/조인 → QueryDSL
- 고정된 단순 쿼리 → JPQL도 OK
인덱스와 JPA
JPA가 만드는 쿼리 분석하기
JPA가 어떤 쿼리를 만드는지 확인하는 게 중요합니다.
# application.yml
spring:
jpa:
show-sql: true
properties:
hibernate:
format_sql: true
use_sql_comments: true로그 예시:
/*
select
order0_.id as id1_0_,
order0_.total_amount as total_am2_0_,
order0_.customer_id as customer3_0_
from
orders order0_
where
order0_.customer_id=?
*/
select order0_.id, order0_.total_amount, order0_.customer_id
from orders order0_
where order0_.customer_id = ?주석으로 어떤 엔티티의 어떤 필드를 조회하는지 알 수 있습니다.
실행계획(EXPLAIN) 보는 법
쿼리가 느리면 실행계획을 확인합니다.
EXPLAIN
SELECT * FROM orders
WHERE customer_id = 1 AND status = 'PENDING';결과:
+----+-------------+--------+------+---------------+------+---------+------+------+-------------+
| id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra |
+----+-------------+--------+------+---------------+------+---------+------+------+-------------+
| 1 | SIMPLE | orders | ALL | NULL | NULL | NULL | NULL | 1000 | Using where |
+----+-------------+--------+------+---------------+------+---------+------+------+-------------+주의할 점:
type: ALL→ 전체 테이블 스캔 (느림)key: NULL→ 인덱스 안 탐 (문제)rows: 1000→ 1000건 스캔
이 쿼리는 인덱스를 안 타고 있습니다.
인덱스 타지 않는 쿼리 패턴
1. 함수 사용
// Bad - 인덱스 안 탐
@Query("SELECT o FROM Order o WHERE LOWER(o.customerName) = LOWER(:name)")
List<Order> findByCustomerName(@Param("name") String name);컬럼에 함수를 적용하면 인덱스를 못 탑니다.
// Good - 인덱스 탐
@Query("SELECT o FROM Order o WHERE o.customerName = :name")
List<Order> findByCustomerName(@Param("name") String name);2. LIKE의 앞 와일드카드
// Bad - 인덱스 안 탐
@Query("SELECT o FROM Order o WHERE o.customerName LIKE %:name%")
List<Order> findByCustomerName(@Param("name") String name);%가 앞에 있으면 인덱스를 못 탑니다.
// Good - 인덱스 탐
@Query("SELECT o FROM Order o WHERE o.customerName LIKE :name%")
List<Order> findByCustomerName(@Param("name") String name);3. OR 조건
// Bad - 인덱스 안 탈 수 있음
@Query("SELECT o FROM Order o WHERE o.customerId = :id OR o.status = :status")
List<Order> find(@Param("id") Long id, @Param("status") String status);OR 조건은 인덱스를 제대로 못 탈 수 있습니다.
// Good - 쿼리 분리하거나 IN 사용 고려
@Query("SELECT o FROM Order o WHERE o.customerId = :id")
List<Order> findByCustomerId(@Param("id") Long id);실무 팁
**쿼리 성능 체크 리스트:**
1. show-sql: true로 실제 쿼리 확인
2. EXPLAIN으로 실행계획 분석
3. type이 ALL이면 인덱스 추가 고려
4. rows가 크면 조건 개선
5. Extra에 "Using filesort"면 정렬 개선 필요저는 성능 이슈가 생기면 이 순서대로 체크합니다.
실무에서 배운 교훈
“이런 경우엔 그냥 Native Query 쓰세요”
복잡한 통계 쿼리는 Native Query가 답입니다.
// JPA로 이걸 짜려면...
@Query(value = "SELECT " +
" DATE_FORMAT(o.order_date, '%Y-%m') as month, " +
" COUNT(*) as order_count, " +
" SUM(o.total_amount) as revenue, " +
" AVG(o.total_amount) as avg_amount " +
"FROM orders o " +
"WHERE o.order_date BETWEEN :startDate AND :endDate " +
" AND o.status IN :statuses " +
"GROUP BY DATE_FORMAT(o.order_date, '%Y-%m') " +
"HAVING COUNT(*) > :minCount " +
"ORDER BY month DESC",
nativeQuery = true)
List<Object[]> findMonthlyStatistics(
@Param("startDate") LocalDateTime startDate,
@Param("endDate") LocalDateTime endDate,
@Param("statuses") List<String> statuses,
@Param("minCount") Long minCount
);이런 건 Native Query가 편합니다. JPA로 억지로 짤 필요 없습니다.
“QueryDSL도 만능이 아닙니다”
QueryDSL의 한계:
1. 학습 곡선
- Q클래스 생성 설정
- Gradle/Maven 설정
- 팀원 전체가 익숙해져야 함
2. 빌드 시간 증가
- Q클래스 생성에 시간 소요
- 엔티티 변경마다 재생성
3. 디버깅 어려움
- 복잡한 쿼리는 생성된 SQL 확인 필요
- 에러 메시지가 불친절
언제 QueryDSL을 도입할까?
저희 팀은 이런 기준으로 결정했습니다:
- 동적 쿼리가 5개 이상
- 복잡한 조인 쿼리가 많음
- 팀원 전체가 학습 의지 있음
- 초기 설정 비용 감수 가능
실무 비율 (저희 팀)
- 60%: Spring Data JPA 메서드 쿼리
- 20%: QueryDSL
- 15%: JPQL (@Query)
- 5%: Native Query생각보다 Spring Data JPA 메서드 쿼리만으로 해결되는 게 많습니다.
시스템 점검 체크리스트
저도 배포 전에 이 항목들을 꼭 확인합니다. JPA 쿼리 최적화를 고려한다면 참고하시면 좋을 것 같습니다.
- Projection 적용: 전체 엔티티 대신 필요한 컬럼만 조회(DTO)하는가?
- QueryDSL 사용: 동적 쿼리가 많다면 QueryDSL로 타입 안전하게 작성했는가?
- 컬렉션 Fetch 제한: 컬렉션 2개 이상을 Fetch Join하지 않았는가? (MultipleBagFetchException 방지)
- 쿼리 복잡도: 복잡한 집계는 JPQL보다 Native Query나 DB View를 고려했는가?
- 성능 측정: Explain Plan으로 실행 계획을 확인했는가? 인덱스가 제대로 사용되는가?
결론
2년간 JPA 쿼리 최적화를 하면서 느낀 점이 있습니다.
“최적화의 80%는 올바른 전략 선택입니다.”
fancy한 기술을 쓰는 것보다, 상황에 맞는 방법을 선택하는 게 중요합니다.
- 컬렉션 2개 이상? @BatchSize 쓰세요
- 필드 몇 개만 필요? Projection 쓰세요
- 동적 쿼리 많음? QueryDSL 고려하세요
- 복잡한 통계? Native Query가 답입니다
그리고 꼭 기억하세요:
“빠른 쿼리보다 이해하기 쉬운 코드가 더 중요합니다.”
저희 팀에서 가장 문제가 많았던 코드는 과도하게 최적화한 코드였습니다. 1초짜리 쿼리를 0.8초로 줄이려다가 코드가 너무 복잡해져서 유지보수가 힘들어졌죠.
성능과 가독성의 균형, 그게 진짜 실력입니다.
다음 편에서는 JPA 유지보수에 대해 다룹니다. 엔티티 설계 원칙, 연관관계 매핑 전략, 그리고 제가 실제로 겪은 엔티티 설계 실패 사례를 공유하겠습니다.
참고 :
https://docs.spring.io/spring-data/jpa/docs/current/reference/html/
http://querydsl.com/static/querydsl/latest/reference/html/
https://www.baeldung.com/jpa-queries
자바 ORM 표준 JPA 프로그래밍 (김영한)
https://vladmihalcea.com/
