Contents
see List개요
Spring Data JPA는 개발 생산성을 높여주지만, N+1 문제, 불필요한 전체 컬럼 조회, 비효율적 페이징 등 성능 함정이 곳곳에 숨어 있습니다. 서비스가 성장하면서 이러한 문제는 DB 부하와 응답 지연으로 이어집니다. 이 글에서는 실무에서 자주 마주하는 JPA 성능 문제와 그 해결 기법을 체계적으로 정리합니다.
핵심 개념
N+1 문제는 JPA에서 가장 흔한 성능 이슈입니다. 연관 엔티티를 Lazy Loading으로 설정했을 때, 부모 엔티티 N개를 조회하면 연관 엔티티 조회를 위해 N번의 추가 쿼리가 발생합니다.
Projection(프로젝션)은 필요한 컬럼만 선택적으로 조회하는 기법입니다. 엔티티 전체를 로딩하는 것보다 네트워크 전송량과 영속성 컨텍스트 메모리 사용량을 크게 줄일 수 있습니다.
Batch Size와 Fetch Join은 N+1 문제를 해결하는 대표적 전략입니다. 상황에 따라 적합한 방식이 다르므로 트레이드오프를 이해해야 합니다.
실전 예제
N+1 문제의 발생과 해결을 단계별로 살펴봅니다.
문제 상황 (N+1 발생):
@Entity
public class Order {
@Id @GeneratedValue
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
private Member member;
@OneToMany(mappedBy = "order", fetch = FetchType.LAZY)
private List<OrderItem> orderItems = new ArrayList<>();
}
// 이 코드는 N+1을 유발합니다
List<Order> orders = orderRepository.findAll();
for (Order order : orders) {
// member 프록시 초기화 -> 주문 수만큼 쿼리 발생!
System.out.println(order.getMember().getName());
}
해결 1: Fetch Join
public interface OrderRepository extends JpaRepository<Order, Long> {
@Query("SELECT DISTINCT o FROM Order o " +
"JOIN FETCH o.member " +
"JOIN FETCH o.orderItems")
List<Order> findAllWithMemberAndItems();
}
해결 2: EntityGraph
public interface OrderRepository extends JpaRepository<Order, Long> {
@EntityGraph(attributePaths = {"member", "orderItems"})
@Query("SELECT o FROM Order o")
List<Order> findAllWithGraph();
}
해결 3: Batch Size (application.yml + 어노테이션)
# application.yml - 글로벌 설정
spring:
jpa:
properties:
hibernate:
default_batch_fetch_size: 100
@Entity
public class Order {
@BatchSize(size = 100) // 엔티티별 개별 설정
@OneToMany(mappedBy = "order")
private List<OrderItem> orderItems;
}
Projection으로 필요한 데이터만 조회:
// Interface Projection
public interface OrderSummary {
Long getId();
String getMemberName();
int getTotalAmount();
LocalDateTime getCreatedAt();
}
public interface OrderRepository extends JpaRepository<Order, Long> {
@Query("SELECT o.id as id, m.name as memberName, " +
"o.totalAmount as totalAmount, o.createdAt as createdAt " +
"FROM Order o JOIN o.member m " +
"WHERE o.status = :status")
Page<OrderSummary> findOrderSummaries(
@Param("status") OrderStatus status, Pageable pageable);
}
// DTO Projection (더 빠름)
@Query("SELECT new com.example.dto.OrderDto(o.id, m.name, o.totalAmount) " +
"FROM Order o JOIN o.member m WHERE o.status = :status")
List<OrderDto> findOrderDtos(@Param("status") OrderStatus status);
No-Offset 페이징 (커서 기반):
// OFFSET 기반 (느림 - 페이지가 깊어질수록 성능 저하)
// SELECT * FROM orders ORDER BY id DESC LIMIT 10 OFFSET 100000
// No-Offset 기반 (빠름 - 항상 일정한 성능)
@Query("SELECT o FROM Order o WHERE o.id < :lastId ORDER BY o.id DESC")
List<Order> findNextPage(@Param("lastId") Long lastId, Pageable pageable);
활용 팁
- 쿼리 로그 활성화: 개발 환경에서
spring.jpa.show-sql=true와hibernate.format_sql=true로 실제 발생하는 쿼리를 항상 확인하세요. - p6spy 도입: 바인딩된 파라미터 값까지 확인하려면 p6spy 라이브러리를 사용하세요.
- 벌크 연산: 대량 업데이트/삭제 시
@Modifying @Query로 JPQL 벌크 연산을 사용하고, 이후clearAutomatically=true로 영속성 컨텍스트를 초기화하세요. - 읽기 전용 최적화: 조회 전용 트랜잭션에
@Transactional(readOnly=true)를 설정하면 스냅샷 저장을 생략하여 메모리를 절약합니다. - 2차 캐시: 변경이 드문 코드 테이블 등에는 Hibernate 2차 캐시(EhCache, Caffeine)를 적용하세요.
마무리
JPA의 편리함 뒤에는 성능 함정이 숨어 있습니다. N+1 문제는 Fetch Join이나 Batch Size로, 불필요한 데이터 로딩은 Projection으로, 깊은 페이징은 No-Offset 방식으로 해결할 수 있습니다. 중요한 것은 항상 실제 발생하는 SQL을 확인하고, 병목 구간을 측정한 뒤 최적화를 적용하는 습관입니다.