JPA는 자바 객체만으로 DB 작업을 처리할 수 있도록 도와주는 편리한 기술이다.
특히 저장(insert)이나 단순 조회(select) 정도는 별다른 쿼리 없이도 쉽게 구현된다.
하지만 JPA를 사용하는 프로젝트에서는 조회 성능 문제가 자주 언급된다.
특히 연관된 엔티티를 함께 조회해야 할 때, 개발자가 의도하지 않은 수많은 쿼리가 실행되거나 응답 속도가 느려지는 문제가 생길 수 있다.
그래서 실무에서는 아래와 같은 순서로 단계적으로 성능을 개선해가는 방식이 추천된다고 한다.
엔티티 직접 반환 → DTO 변환 → fetch join → DTO 직접 조회
이 글에서는 각 방식의 구조와 특징, 그리고 어떤 상황에서 사용되는지를 예제와 함께 정리해본다.
예제: 주문(Order)과 회원(Member), 배송(Delivery)
예제로는 아래와 같이 Order가 Member와 Delivery를 참조하는 구조를 가정한다.
@Entity
public class Order {
@Id @GeneratedValue
private Long id;
@ManyToOne(fetch = LAZY)
private Member member;
@OneToOne(fetch = LAZY)
private Delivery delivery;
private LocalDateTime orderDate;
private OrderStatus status;
}
관계형 테이블로 보면 다음과 같다.
| 1 | 1 | 1 |
| 2 | 2 | 2 |
1단계: 엔티티를 직접 반환 (간단하지만 문제 발생 가능)
가장 단순한 방법은 엔티티를 그대로 컨트롤러에서 반환하는 방식이다.
@GetMapping("/api/v1/simple-orders")
public List<Order> ordersV1() {
List<Order> orders = orderRepository.findAll();
for (Order order : orders) {
order.getMember().getName(); // 지연 로딩 강제 초기화
order.getDelivery().getAddress(); // 지연 로딩 강제 초기화
}
return orders;
}
이처럼 Order를 그대로 JSON으로 반환하는 방식은 간단한 테스트나 학습 단계에서 종종 사용된다.
하지만 실무에서는 여러 가지 문제가 보고되곤 한다.
- 연관된 엔티티(Member, Delivery)는 지연 로딩(LAZY)이기 때문에, 실제 객체 대신 프록시가 반환된다.
- Jackson이 프록시 객체를 직렬화하지 못해 예외가 발생하는 경우가 많다.
- 양방향 연관관계가 있는 경우, JSON 변환 과정에서 무한 루프에 빠지기도 한다.
이런 이유로, 실무에서는 엔티티를 직접 API 응답으로 사용하는 방식은 지양해야 한다고 권장된다.
2단계: DTO로 변환 (구조 분리, 그러나 N+1 문제)
실무에서는 보통 DTO(Data Transfer Object)를 따로 만들어, API 응답에 필요한 정보만 골라서 전달하는 방식을 사용한다고 한다.
//Controller
@GetMapping("/api/v2/simple-orders")
public List<SimpleOrderDto> ordersV2() {
List<Order> orders = orderRepository.findAll();
return orders.stream()
.map(SimpleOrderDto::new)
.collect(Collectors.toList());
}
//DTO
@Data
static class SimpleOrderDto {
private Long orderId;
private String name;
private LocalDateTime orderDate;
private OrderStatus orderStatus;
private Address address;
public SimpleOrderDto(Order order) {
this.orderId = order.getId();
this.name = order.getMember().getName(); // 지연 로딩 발생
this.orderDate = order.getOrderDate();
this.orderStatus = order.getStatus();
this.address = order.getDelivery().getAddress(); // 지연 로딩 발생
}
}
이 방식은 구조 분리가 명확하고, API 응답에 어떤 필드가 포함되는지도 쉽게 알 수 있다.
하지만 이 상태에서는 여전히 member, delivery 정보를 꺼내는 순간마다 쿼리가 실행되기 때문에,
N+1 문제가 발생할 수 있다.
예를 들어 주문이 5건이면:
- 주문 조회 쿼리 1번
- 회원 조회 쿼리 5번
- 배송 조회 쿼리 5번
→ 총 11번의 쿼리가 실행됨
JPA는 order.getMember()나 order.getDelivery()처럼 연관된 객체를 호출하는 시점에 지연 로딩을 수행하기 때문에,
주문 수만큼 반복적으로 쿼리가 발생하게 된다.
처음엔 잘 작동하는 것처럼 보여도, 주문 수가 100건, 1000건으로 늘어나면
쿼리 수도 함께 늘어나기 때문에 성능 저하가 바로 체감될 수 있다.
특히 이런 방식은 목록 조회 API처럼 반복적인 엔티티 접근이 많은 구조에서 성능 이슈를 유발하기 쉽다.
그래서 실무에서는 이 단계에서 fetch join을 적용하거나, 아예 DTO를 직접 조회하는 방식으로 넘어가는 경우가 많다고 한다.
따라서 우리는 fetch join을 사용해 이를 해결한다.
3단계: fetch join 사용 (N+1 문제 해결)
N+1 문제를 해결하는 대표적인 방법으로 fetch join이 있다.
이는 JPA에서 제공하는 기능으로, 연관된 엔티티를 한 번의 쿼리로 함께 조회하는 방식이다.
//Controller
@GetMapping("/api/v3/simple-orders")
public List<SimpleOrderDto> ordersV3() {
List<Order> orders = orderRepository.findAllWithMemberDelivery();
return orders.stream()
.map(SimpleOrderDto::new)
.collect(Collectors.toList());
}
//repository
public List<Order> findAllWithMemberDelivery() {
return em.createQuery(
"select o from Order o" +
" join fetch o.member" +
" join fetch o.delivery", Order.class)
.getResultList();
}
이렇게 하면 JPA가 아래처럼 SQL을 만들어 준다.
select
o.*, m.*, d.*
from
orders o
join
member m on o.member_id = m.id
join
delivery d on o.delivery_id = d.id
즉, 쿼리 1번으로 모든 데이터를 한 번에 가져올 수 있다.
Lazy 로딩도, N+1 문제도 발생하지 않는다.
이렇게 하면 JPA는 SQL 한 줄로 Order, Member, Delivery를 모두 가져온다.
실제로 실무에서는 이 방식으로 N+1 문제를 해결하는 경우가 많다고 한다.
다만, fetch join에도 몇 가지 제한 사항이 존재한다.
- @OneToMany처럼 컬렉션을 조인하면 페이징이 불가능해진다.
- 너무 많은 테이블을 fetch join 하면 중복 데이터가 발생할 수 있다.
그래서 fetch join은 보통 @ManyToOne, @OneToOne처럼 단건 연관관계에서 자주 사용된다고 한다.
4단계: DTO를 직접 조회 (쿼리에서 DTO로 바로 매핑)
필요한 필드가 명확하고, 엔티티 전체를 가져올 필요가 없을 경우에는
JPA에서 DTO를 직접 조회하는 방식도 많이 쓰인다고 한다.
//Controller
@GetMapping("/api/v4/simple-orders")
public List<OrderSimpleQueryDto> ordersV4() {
return orderRepository.findOrderDtos();
}
//repository
public List<OrderSimpleQueryDto> findOrderDtos() {
return em.createQuery(
"select new jpabook.jpashop.repository.OrderSimpleQueryDto(" +
"o.id, m.name, o.orderDate, o.status, d.address) " +
"from Order o " +
"join o.member m " +
"join o.delivery d", OrderSimpleQueryDto.class)
.getResultList();
}
//DTO
package jpabook.jpashop.repository;
import jpabook.jpashop.domain.Address;
import jpabook.jpashop.domain.OrderStatus;
import lombok.Data;
import java.time.LocalDateTime;
@Data
public class OrderSimpleQueryDto {
private Long orderId;
private String name;
private LocalDateTime orderDate; //주문시간
private OrderStatus orderStatus;
private Address address;
public OrderSimpleQueryDto(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;
}
}
select new 구문을 사용하면 JPA가 쿼리 결과를 DTO 생성자로 바로 매핑해준다.
따로 엔티티를 꺼내고 변환하는 과정 없이, 필요한 데이터만 바로 사용할 수 있다.
이 방식은 API 응답에 꼭 필요한 필드만 선택적으로 조회할 수 있어
네트워크나 메모리 낭비를 줄이는 데 도움이 된다고 한다.
다만, DTO 구조가 바뀌면 JPQL 쿼리도 함께 수정해야 하고,
API 전용 쿼리가 Repository 내부로 들어가기 때문에 재사용성이 낮아지는 단점도 있다.
또한, 실무에서는 이런 방식으로 select 항목을 몇 개 줄인다고 해도
눈에 띄는 성능 향상이 체감되는 경우는 많지 않다고 한다.
대부분의 경우에는 3단계(fetch join 후 DTO 변환) 방식만으로도 충분히 성능이 확보되며,
DTO 직접 조회 방식은 정말 필요한 일부 API에만 제한적으로 사용하는 경우가 많다.
조회 방식 선택 정리
실무에서는 다음과 같은 순서로 조회 방식을 선택하는 것이 좋다고 알려져 있다.
- 엔티티를 조회하고, DTO로 변환하는 방법을 먼저 고려한다.
구조가 명확하고, Repository 코드도 재사용 가능하다. - N+1 문제가 발생하면 fetch join을 사용해 최적화한다.
대부분의 성능 이슈는 이 단계에서 해결된다고 한다. - 필요한 필드가 명확하거나, 성능이 중요한 경우 DTO를 직접 조회하는 방식도 사용된다.
- 이후에도 성능 한계가 있다면 native SQL, JDBC Template 등을 고려하게 된다.
마무리하며
JPA는 구조적으로 편리한 도구지만, 성능 문제를 방치하면 예상하지 못한 지점에서 시간과 자원을 소모하게 된다.
그래서 실무에서는 엔티티를 어디까지 노출하고, 언제 DTO로 분리하고, 쿼리를 어떻게 최적화할지에 대한 기준을 잘 세우는 것이 중요하다고 한다.
특히 fetch join이나 DTO 직접 조회는 "어느 순간부터 성능이 급격히 나빠지는 현상" 을 예방하거나 해결할 때 유용하게 쓰인다고 하며,
단순한 기능 구현을 넘어서, 구조 설계의 일부로서 이해하는 것이 바람직하다고 한다.
이번 글에서는 ToOne 관계(예: 주문 → 회원, 배송)에 대한 최적화 흐름을 다뤘고,
다음 글에서는 실제로 더 복잡하게 얽히는 ToMany 관계,
즉 컬렉션(예: 주문 → 주문상품 목록) 조회를 어떻게 최적화할 수 있는지 이어서 정리해보려고 한다.
감사합니다.
'스프링' 카테고리의 다른 글
| [스프링] 스프링 MVC - 기본기능 (1) 로깅 (0) | 2025.09.03 |
|---|---|
| [스프링] 컬렉션 조회 최적화 (4) | 2025.07.31 |
| [스프링] DTO가 필요한 이유 (3) | 2025.07.27 |
| [스프링] JPA에서 제공하는 쿼리 방법 (1) | 2025.07.26 |
| [스프링] JPA 상속관계 매핑 (7) | 2025.07.25 |
