개요
JPA를 실무에서 사용하다 보면 대표적으로 거론되는 문제 중 하나가 바로 N+1 문제이다. 필자 또한 JPA를 이용해 직접 쿼리를 짜지 않고도 객체 지향적으로 매핑할 수 있다는 점에서 큰 생산성 향상을 느꼈다. 하지만 여러 개발자들의 코드를 리뷰하면서, 실무에서 성능 최적화를 충분히 고민하지 않는 경우가 의외로 많다는 사실을 알게 되었다.
이 글에서는 JPA로 개발할 때 명심해야 할 성능 최적화 포인트들을 중심으로 설명하고자 한다.
내용
Order 엔티티를 직접 노출할 때
JPA에서는 엔티티 간 연관관계를 설정할 때, 지연 로딩(Lazy)과 즉시 로딩(Eager) 중 하나를 선택하게 된다. 예를 들어 Order 테이블이 있고, 여기서 Delivery(OneToOne), Member(ManyToOne) 관계를 각각 Lazy 로딩으로 설정했다고 가정해보자.
엔티티를 그대로 JSON으로 변환하여 외부에 노출하는 경우, 순환 참조로 인해 무한 루프가 발생하기 쉽다. 예를 들어 Order 안에 Member가 있고, Member가 다시 Order를 참조하는 식이라면, 지연 로딩이 무한 반복되면서 순환 호출이 일어날 수 있다.
이를 막기 위해서는 순환 관계가 있는 필드에 @JsonIgnore를 사용하여 직렬화를 차단해야 한다. 하지만 이로 인해 또 다른 문제가 생길 수 있다. Order 엔티티 안의 Member 필드가 Lazy 로딩된 상태로 프록시(ByteBuddyInterceptor)가 걸려 있을 때, Jackson 라이브러리는 이 프록시 타입을 직렬화하지 못해 500 에러를 발생시킨다.
해결 방안 1) Hibernate5Module 사용
Hibernate5Module을 빈(Bean)으로 등록해주면, 지연 로딩 프록시를 무시하고 null로 처리하게 만들 수 있다. 또한 FORCE_LAZY_LOADING 설정을 통해 모든 지연 로딩 관계를 강제로 가져오도록 설정할 수도 있다.
@Bean
Hibernate5Module hibernate5Module() {
Hibernate5Module hibernate5Module = new Hibernate5Module();
hibernate5Module.configure(Hibernate5Module.Feature.FORCE_LAZY_LOADING, true);
return hibernate5Module;
}
혹은 컨트롤러에서 직접 order.getMember().getName()처럼 값을 호출하여 Lazy 객체를 강제 초기화할 수도 있다. 하지만 이 방식은 “꼼수”에 가깝기 때문에 유지보수성 측면에서 추천하기 어렵다
엔티티 대신 DTO 노출
API 응답에 엔티티를 그대로 노출하는 것은 불필요한 연관관계 정보까지 드러나고, 앞서 언급한 Lazy 프록시 직렬화 문제를 야기할 수 있다. 따라서 적절한 DTO(Data Transfer Object)를 만들어 필요한 데이터만 추려서 반환하는 방식이 흔히 권장된다.
특히 규모가 커질수록 엔티티의 변경으로 인해 API 스펙이 함께 흔들릴 수 있는데, DTO를 사용하면 이러한 문제를 최소화할 수 있다.
주의: 지연 로딩에서 발생하는 N+1 문제를 피하기 위해 즉시 로딩(EAGER)으로 설정하는 경우가 있는데, 이는 오히려 연관관계를 전부 긁어오느라 쿼리 수가 과도하게 늘어날 수 있다. 따라서 기본적으로 Lazy 로딩을 적용하고, 상황에 따라 페치 조인(fetch join) 같은 방법으로 필요한 부분만 한 번에 가져오는 것이 바람직하다.
엔티티를 DTO로 변환
다음은 엔티티를 조회한 뒤, 간단한 DTO(SimpleOrderDto)로 변환하여 반환하는 예시이다. java 코드 복사
@GetMapping("/api/orders")
public List<OrderDto> getOrders() {
List<Order> orders = orderRepository.findAllByString(new OrderSearch());
return orders.stream()
.map(OrderDto::new)
.collect(Collectors.toList());
}
@Data
static class OrderDto {
private Long orderId;
private String name;
private LocalDateTime orderDate;
private OrderStatus orderStatus;
private Address address;
public OrderDto(Order order) {
orderId = order.getId();
name = order.getMember().getName(); // Lazy 로딩 발생
orderDate = order.getOrderDate();
orderStatus = order.getStatus();
address = order.getDelivery().getAddress(); // Lazy 로딩 발생
}
}
이 방식은 로직이 간결하다는 이점이 있다. 그러나 Order 엔티티가 여러 건(N건)일 경우, 각 엔티티마다 Member와 Delivery를 별도 쿼리로 가져오기 때문에 최악의 경우 1 + N + N = 1 + 2N번의 쿼리가 발생한다. 이를 흔히 “N+1 문제”라고 부른다. 작은 규모의 프로젝트에서는 크게 체감되지 않을 수 있지만, 트래픽이 많은 서비스나 데이터량이 많은 상황에서는 DB 부하와 응답 지연으로 이어지기 쉽다.
패치 조인(fetch join) 최적화
페치 조인은 N+1 문제를 해결하는 대표적 방법 중 하나이다. JPQL에서 join fetch 키워드를 사용하면, 연관된 엔티티를 미리 한 번에 불러올 수 있으므로 추가 쿼리 호출이 대폭 줄어든다.
@GetMapping("/api/orders")
public List<OrderDto> getOrders() {
List<Order> orders = orderRepository.findAllWithMemberDelivery();
return orders.stream()
.map(OrderDto::new)
.collect(Collectors.toList());
}
// OrderRepository
public List<Order> findAllWithMemberDelivery() {
return em.createQuery(
"select o from Order o" +
" join fetch o.member m" +
" join fetch o.delivery d", Order.class
).getResultList();
}
이처럼 join fetch를 통해 Member와 Delivery를 미리 한꺼번에 불러오면, 추가 쿼리 없이도 연관 데이터가 초기화되어 있다.
JPA에서 DTO로 직접 조회
쿼리 결과를 곧바로 DTO 형태로 매핑해 가져오는 방법도 있다. JPQL의 new 문법을 활용해 필요한 필드만 선택함으로써, DB ↔ 애플리케이션 간 데이터 전송을 최소화할 수 있다.
@GetMapping("/api/orders")
public List<OrderQueryDto> getOrders() {
return orderQueryRepository.findOrderDtos();
}
@Repository
@RequiredArgsConstructor
public class OrderQueryRepository {
private final EntityManager em;
public List<OrderQueryDto> findOrderDtos() {
return em.createQuery(
"select new com.example.OrderQueryDto(" +
" o.id, m.name, o.orderDate, o.status, d.address)" +
" from Order o" +
" join o.member m" +
" join o.delivery d",
OrderQueryDto.class
).getResultList();
}
}
@Data
public class OrderQueryDto {
private Long orderId;
private String name;
private LocalDateTime orderDate;
private OrderStatus orderStatus;
private Address address;
public OrderQueryDto(Long orderId, String name,
LocalDateTime orderDate, OrderStatus orderStatus,
Address address) {
this.orderId = orderId;
this.name = name;
this.orderDate = orderDate;
this.orderStatus = orderStatus;
this.address = address;
}
}
이렇게 DTO로 직접 조회하면 필요한 데이터만 선별해서 가져올 수 있어 네트워크와 메모리 사용량을 절약할 수 있다. 하지만 API 스펙과 밀접히 연관된 로직이 레포지토리에 포함된다는 점에서, 재사용성이 떨어질 수 있다는 단점이 있다.
정리
- 엔티티 직접 노출은 순환 참조, Lazy 프록시 직렬화 문제 등을 일으킬 위험이 크므로, 대체로 DTO 변환을 권장한다.
- N+1 문제로 인해 쿼리 수가 급증하는 상황에서는 페치 조인을 사용할 수 있으며 이밖에도 배치 사이즈(BatchSize) 설정, 2차 캐시(Second-level Cache) 등을 활용하여 성능을 튜닝할 수 있다.
- 무턱대고 즉시 로딩(EAGER)으로 전환하기보다는 Lazy 로딩을 기본으로 하고, 필요한 경우에만 페치 조인, DTO 직접 조회 등을 통한 최적화를 진행한다.
- 그래도 해결되지 않는다면 네이티브 SQL이나 JDBC Template 등을 사용하여 직접 쿼리를 작성하는 방식을 고려할 수도 있다.
참고