앞선 글에서는 JPA에서 @ManyToOne, @OneToOne 같은 ToOne 관계를 조회할 때 발생하는 성능 문제와 해결 방식을 정리했다.
이번에는 더 복잡한 일대다 관계(OneToMany), 즉 컬렉션을 포함한 엔티티를 조회할 때의 문제와 그에 대한 해결 흐름을 다뤄보려고 한다.
예제로는 주문(Order)과 주문상품(OrderItem)의 관계를 사용한다.
하나의 주문에는 여러 개의 상품이 들어갈 수 있으므로 Order → OrderItem은 OneToMany 관계다.
예제 엔티티 구조
@Entity
@Table(name = "orders")
public class Order {
@Id @GeneratedValue
private Long id;
@ManyToOne(fetch = LAZY)
private Member member;
@OneToMany(mappedBy = "order", cascade = CascadeType.ALL)
private List<OrderItem> orderItems = new ArrayList<>();
}
@Entity
public class OrderItem {
@Id @GeneratedValue
private Long id;
@ManyToOne(fetch = LAZY)
private Item item;
@ManyToOne(fetch = LAZY)
private Order order;
private int orderPrice;
private int count;
}
V1. 엔티티 직접 노출 + 강제 초기화
@GetMapping("/api/v1/orders")
public List<Order> orderV1() {
List<Order> orders = orderRepository.findAllByString(new OrderSearch());
for (Order order : orders) {
order.getMember().getName(); // Lazy 강제 초기화
order.getDelivery().getAddress();
order.getOrderItems().forEach(oi -> oi.getItem().getName());
}
return orders;
}
이 방식의 흐름
- Order 엔티티를 그대로 API 응답으로 반환한다.
- 그런데 Order는 Member, Delivery, OrderItem, Item과 모두 연관 관계가 있다.
- 이 연관 객체들은 JPA에서 기본적으로 지연 로딩(LAZY) 전략을 사용한다.
- 그래서 실제 데이터가 필요한 시점(예: JSON 직렬화)까지는 DB 쿼리를 날리지 않고,
getMember().getName() 같은 호출을 할 때 그제야 SQL이 실행된다. - 이 때문에 controller에서 강제로 getXXX() 메서드를 호출해 초기화를 해줘야 한다.
왜 이렇게 했을까?
처음엔 JPA를 사용하면서, "내가 필요한 데이터는 엔티티에 다 들어있으니까 그냥 반환하면 되겠지"라는 생각이 들기 쉽다.
게다가 연관 관계도 자동으로 매핑되어 있으니, 이대로도 충분히 API가 동작하는 것처럼 보인다.
그런데 뭐가 문제인가?
- 엔티티가 곧 API 응답 스펙이 된다
- 엔티티 구조가 바뀌면 API도 바뀌고, 반대로 API 요구 사항 때문에 엔티티를 바꿔야 할 수도 있다.
- 설계가 꼬이기 시작한다.
- 지연 로딩으로 인한 쿼리 폭발 (N+1 문제)
- Order 5건을 조회했다면?
- 주문 조회: 1번
- 각 주문의 회원(member): 5번
- 각 주문의 배송(delivery): 5번
- 각 주문의 주문상품(orderItems): 5번
- 각 주문상품의 상품(item): 5번 이상
- 총 20번 이상 쿼리가 나갈 수 있다.
- 이것도 주문이 5건일 때 이야기고, 실무에서 100건, 1000건이면 감당이 안 된다.
- Order 5건을 조회했다면?
- 무한 루프 발생 위험
- 양방향 연관 관계가 있으면 Jackson이 JSON 변환 시 순환참조로 인해 스택오버플로우가 날 수 있다.
- 이를 막기 위해 @JsonIgnore를 여기저기 붙이기 시작하면 유지보수가 어렵다.
정리하면
처음엔 간단해 보여서 이렇게 짜기 쉬운데,
실제로는 성능 문제, 구조 문제, 유지보수 문제까지 줄줄이 터지는 방식이다.
그래서 보통 실무에서는 V1 방식은 금방 한계에 부딪히고,
그 다음 단계인 DTO 변환 방식으로 넘어가게 된다.
V2 – 엔티티를 DTO로 변환
//controller
@GetMapping("/api/v2/orders")
public List<OrderDto> orderV2() {
List<Order> orders = orderRepository.findAllByString(new OrderSearch());
return orders.stream().map(OrderDto::new).collect(Collectors.toList());
}
//DTO
@Data
static class OrderDto {
private Long orderId;
private String name;
private LocalDateTime orderDate;
private OrderStatus orderStatus;
private Address address;
private List<OrderItemDto> orderItems;
public OrderDto(Order order) {
orderId = order.getId();
name = order.getMember().getName(); // Lazy
orderDate = order.getOrderDate();
orderStatus = order.getStatus();
address = order.getDelivery().getAddress(); // Lazy
orderItems = order.getOrderItems().stream()
.map(OrderItemDto::new) // 내부 DTO 변환
.collect(Collectors.toList());
}
}
@Data
static class OrderItemDto {
private String itemName;
private int orderPrice;
private int count;
public OrderItemDto(OrderItem orderItem) {
itemName = orderItem.getItem().getName(); // Lazy
orderPrice = orderItem.getOrderPrice();
count = orderItem.getCount();
}
}
이 방식의 흐름
- OrderDto는 API 응답을 위한 클래스다.
필요한 정보만 담고 있고, 내부에서 Order 엔티티로부터 값을 꺼내 사용한다. - OrderItemDto 역시 OrderItem 엔티티를 보고 필요한 필드만 뽑는다.
- 중요한 건, 엔티티에 @JsonIgnore나 @JsonManagedReference 같은 설정이 전혀 필요 없다는 점이다.
즉, API 응답 설계와 내부 도메인을 완전히 분리할 수 있게 된다.
좋아진 점
- API 스펙을 명확하게 정의
- 엔티티에 뭐가 들어있든, API 응답은 OrderDto가 결정한다.
- 필요한 필드만 선택해서 줄 수 있다. (예: internalNote 같은 민감한 필드는 아예 빠짐)
- 양방향 순환참조 문제 해결
- DTO는 단방향 구조이므로 JSON 순환 문제가 발생하지 않는다.
- @JsonIgnore를 여기저기 붙일 필요도 없다.
- 유지보수성과 확장성 향상
- 추후 API 요구사항이 바뀌더라도 DTO만 수정하면 되므로 내부 모델을 건드릴 필요 없다.
그런데 여전히 남은 문제: N+1
이 방식은 구조는 잘 분리했지만, 성능 문제는 여전히 해결되지 않았다.
왜냐하면 연관 객체들은 여전히 LAZY 로딩이라서, 값을 꺼내는 순간마다 쿼리가 나가기 때문이다.
예를 들어 주문 5건이면 다음과 같은 쿼리가 발생한다:
- 주문 조회: 1번
- 주문의 회원(Member) 조회: 5번
- 배송(Delivery) 조회: 5번
- 각 주문의 주문상품(OrderItem) 조회: 5번
- 각 주문상품의 상품(Item) 조회: 주문상품 수만큼
→ 총 쿼리 수: 1 + 5 + 5 + N + N
즉, 구조는 좋아졌지만 성능은 여전히 나빠질 수 있는 구조다.
그래서 이 문제를 해결하기 위해 fetch join이 도입된다.
V3 – 엔티티를 DTO로 변환 + fetch join 최적화
//controller
@GetMapping("/api/v3/orders")
public List<OrderDto> orderV3() {
List<Order> orders = orderRepository.findAllWithItem();
return orders.stream()
.map(OrderDto::new)
.collect(Collectors.toList());
}
// repository
public List<Order> findAllWithItem() {
return em.createQuery(
"select distinct o from Order o" +
" join fetch o.member m" +
" join fetch o.delivery d" +
" join fetch o.orderItems oi" +
" join fetch oi.item i", Order.class)
.getResultList();
}
이 방식의 흐름
- V2에서는 연관된 데이터를 꺼낼 때마다 LAZY 로딩으로 쿼리가 N+1씩 발생했다.
- 그래서 이번엔 join fetch를 이용해서 한 쿼리로 모든 연관된 데이터를 미리 다 가져온다.
- Order → Member, Order → Delivery, Order → OrderItem → Item 까지 전부 조인해서 한 번에 조회.
왜 distinct를 쓰는가?
- Order 한 건에 OrderItem이 여러 개일 경우, 조인 결과가 row 기준으로 중복된다.
- 예: 주문 2건에 각 2개의 주문상품이 있으면 4줄이 조회됨.
- 그런데 JPA는 이 4줄을 보고 서로 다른 4개의 Order로 인식할 수 있기 때문에,
- distinct를 사용해서 중복된 Order 객체를 제거해야 한다.
- 주의: 여기서의 distinct는 SQL의 distinct + JPA가 엔티티 기준으로 중복 제거까지 함께 한다.
장점
- 쿼리 수를 단 1번으로 줄임
- join fetch 덕분에 연관된 데이터들을 모두 한 번에 끌고 온다.
- 쿼리 N+1 문제를 근본적으로 해결함.
- 성능이 눈에 띄게 개선
- 중복된 쿼리 요청이 없으니 DB 부하가 줄어들고, API 응답도 빨라진다.
- 구조는 여전히 깔끔 (V2의 DTO 방식 유지)
단점: 페이징 불가능
여기서 가장 큰 문제가 발생한다.
컬렉션을 fetch join하면 JPA는 페이징 처리를 제대로 할 수 없다.
코드 예시:
em.createQuery("...")
.setFirstResult(0)
.setMaxResults(100)
이렇게 해도 실제 쿼리 실행 시 OrderItem 기준으로 row가 쪼개지기 때문에,
DB에서는 예상보다 더 많은 데이터가 조회되고,
JPA는 그걸 다 가져와서 메모리에서 잘라내는 방식으로 페이징을 처리해버린다.
→ 즉, 데이터 수가 많아질수록 메모리 사용량이 기하급수적으로 늘어난다.
→ 실무에서는 절대 이렇게 페이징하면 안 된다.
참고: 컬렉션 fetch join은 페이징이 불가능하다.
하이버네이트는 경고 로그를 출력하고, 전체 데이터를 읽은 뒤 애플리케이션 메모리에서 잘라내는 식으로 페이징을 처리한다.
→ 이는 메모리 부하가 크고, 데이터가 많아질수록 장애로 이어질 수 있다.
그래서 다음으로 가야 하는 이유
실무에서는 대부분 주문 목록 같은 데이터를 페이징해서 보여줘야 한다.
하지만 V3 방식은 페이징을 포기한 대가로 성능을 얻는 구조라, 한계가 분명하다.
그래서 이 문제를 해결한 방식이 바로 다음 버전인 V3.1 – ToOne fetch join + 컬렉션 지연 로딩 + 배치 사이즈 설정이다.
V3.1 – 페이징 한계 돌파: ToOne fetch join + 컬렉션 지연 로딩 + 배치 전략
앞에서 설명한 V3 방식은 fetch join을 활용해 성능은 개선했지만, 페이징이 불가능한 구조라는 단점이 있었다.
그 이유는 컬렉션(@OneToMany) 관계에 fetch join을 사용할 경우, DB row 수가 중복 증가하기 때문이다.
결과적으로 JPA는 쿼리에서 페이징을 적용하지 못하고, 모든 결과를 메모리에 올린 뒤 잘라낸다.
하이버네이트는 이 경우 경고 로그를 출력하고, 전체 데이터를 가져온 뒤 메모리에서 페이징을 시도한다.
→ 데이터 양이 많아지면 성능 저하나 OutOfMemoryError 같은 문제가 발생할 수 있다.
그래서 실무에서는 이 한계를 넘기기 위해 V3.1 방식이 자주 사용된다고 한다. (가장 중요)
이 방식의 흐름
- ToOne(단건 연관 관계): fetch join으로 한 번에 조회
- 예: Order → Member, Order → Delivery
- 이들은 조인해도 row 수가 늘어나지 않기 때문에 fetch join에 적합
- ToMany(컬렉션 관계): 지연 로딩 그대로 유지
- 예: Order → OrderItem
- 대신 hibernate.default_batch_fetch_size나 @BatchSize 설정을 통해 쿼리 수를 줄이고 성능을 높인다
// repository
public List<Order> findAllWithMemberDelivery(int offset, int limit) {
return em.createQuery(
"select o from Order o" +
" join fetch o.member m" +
" join fetch o.delivery d", Order.class)
.setFirstResult(offset)
.setMaxResults(limit)
.getResultList();
}
// controller
@GetMapping("/api/v3.1/orders")
public List<OrderDto> orderV3_page(
@RequestParam(value = "offset", defaultValue = "0") int offset,
@RequestParam(value = "limit", defaultValue = "100") int limit) {
List<Order> orders = orderRepository.findAllWithMemberDelivery(offset, limit);
return orders.stream()
.map(OrderDto::new)
.collect(Collectors.toList());
}
이 코드는 Order → Member, Order → Delivery는 fetch join으로 한 번에 끌고오고,
Order → OrderItem, OrderItem → Item은 LAZY 상태로 두되, 이후 getOrderItems() 호출 시 batch fetch로 최적화되도록 설계되어 있다.
왜 이 방식이 실무에서 많이 쓰일까?
- 페이징 가능
- ToMany(fetch join 불가) 관계를 건드리지 않기 때문에, DB에서 제대로 페이징 처리가 가능하다.
- 쿼리 수 최소화
- LAZY로 남긴 컬렉션도 hibernate.default_batch_fetch_size 설정을 통해 1 + 1 쿼리 구조로 바뀐다.
- 예: 주문 100건이라면 → 주문 1번 + 주문상품 한 방에 조회(100건 in 조건)
- LAZY로 남긴 컬렉션도 hibernate.default_batch_fetch_size 설정을 통해 1 + 1 쿼리 구조로 바뀐다.
- 메모리 부담 없음
- 필요한 데이터만 페이징 범위 안에서 로딩하므로, 메모리에 수천 건씩 올릴 일이 없다.
- 구조가 깔끔하고 확장 가능
- fetch join이 필요한 연관 관계만 선택적으로 조절 가능
- 향후 기능 추가나 쿼리 튜닝도 유연하게 대응 가능
설정 방법
application.yml에서 전역 설정 가능:
spring:
jpa:
properties:
hibernate:
default_batch_fetch_size: 1000
혹은 각 컬렉션 필드에 개별 설정:
@OneToMany(mappedBy = "order")
@BatchSize(size = 100)
private List<OrderItem> orderItems;
→ 두 설정 중 하나만 해도, order.getOrderItems() 호출 시 N건을 한 번의 IN 쿼리로 묶어서 가져온다.
예시 쿼리 흐름
- Order 100건 조회 (member, delivery는 fetch join으로 함께 조회됨)
→ 1번 쿼리 - OrderItem + Item 지연 로딩
→ order_id in (...) 형태로 1번에 조회 (default_batch_fetch_size 덕분)
→ 총 2번의 쿼리로 100건의 주문과 그에 따른 모든 연관 데이터를 깔끔하게 가져올 수 있다.
결론
V3.1 방식은 실무에서 가장 널리 쓰이는 이유가 분명하다.
페이징도 가능하면서, fetch join의 장점도 일부 가져올 수 있고, N+1 문제도 상당히 줄일 수 있기 때문이다.
정리하자면:
- fetch join은 ToOne 관계에만 적용
- 컬렉션은 LAZY 그대로 두고, 배치 설정으로 최적화
- 실무에선 대부분 이 패턴으로 해결하며, 이후 DTO 직접 조회가 필요한 경우에만 별도 전환을 고려한다
V4 – DTO 직접 조회: 쿼리를 DTO에 맞춰 작성하는 방식
V3.1까지는 JPA가 제공하는 fetch join, batch fetch 등을 활용해 성능을 최적화했다.
하지만 이건 기본적으로 엔티티를 기준으로 데이터에 접근하는 방식이라, API 응답 형식에 맞게 필요한 필드만 고르기는 어렵다.
그래서 나온 방식이 DTO를 위한 전용 쿼리를 따로 작성하는 방법이다.
JPA의 new 구문을 이용해 JPQL 결과를 DTO 생성자에 바로 매핑하는 구조다.
코드 흐름
//controller
@GetMapping("/api/v4/orders")
public List<OrderQueryDto> ordersV4() {
return orderQueryRepository.findOrderQueryDtos();
}
//repository
public List<OrderQueryDto> findOrderQueryDtos() {
//루트 조회(toOne 코드를 모두 한번에 조회)
List<OrderQueryDto> result = findOrders();
//루프를 돌면서 컬렉션 추가(추가 쿼리 실행)
result.forEach(o -> {
List<OrderItemQueryDto> orderItems = findOrderItems(o.getOrderId());
o.setOrderItems(orderItems);
});
return result;
}
private List<OrderItemQueryDto> findOrderItems(Long orderId) {
return em.createQuery(
"select new jpabook.jpashop.repository.query.OrderItemQueryDto(" +
"oi.order.id, i.name, oi.orderPrice, oi.count) " +
"from OrderItem oi " +
"join oi.item i " +
"where oi.order.id = :orderId",
OrderItemQueryDto.class)
.setParameter("orderId", orderId)
.getResultList();
}
public List<OrderQueryDto> findOrders() {
return em.createQuery(
"select new jpabook.jpashop.repository.query.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();
}
//DTO
@Data
@EqualsAndHashCode(of = "orderId")
public class OrderQueryDto {
private Long orderId;
private String name;
private LocalDateTime orderDate; //주문시간
private OrderStatus orderStatus;
private Address address;
private List<OrderItemQueryDto> orderItems;
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;
}
}
@Data
public class OrderItemQueryDto {
@JsonIgnore
private Long orderId; //주문번호
private String itemName;//상품 명
private int orderPrice; //주문 가격
private int count;
//주문 수량
public OrderItemQueryDto(Long orderId, String itemName, int orderPrice, int
count) {
this.orderId = orderId;
this.itemName = itemName;
this.orderPrice = orderPrice;
this.count = count;
}
}
이 방식은 주문(Order) 1건 → 주문상품(OrderItem) N건이라는 구조에서,
주문을 먼저 조회하고(OrderQueryDto), 그 후 각 주문 ID로 반복하면서 주문상품들을 별도 쿼리로 조회(OrderItemQueryDto)한다.
장점
- 필요한 필드만 선택적으로 가져오기 때문에 데이터 낭비 없음
- 엔티티를 건드리지 않으므로 구조가 깨지지 않음
- 프록시, 지연 로딩 관련 문제도 발생하지 않음
단점
- 루프를 돌면서 서브쿼리를 날리기 때문에 쿼리 수가 많아짐 (N+1 문제 재발)
예: 주문 5건이면 → 주문 1번 + 주문상품 5번 = 총 6번 쿼리 - DTO 구조가 바뀌면 JPQL도 수정해야 함 (유지보수 부담)
- API 전용 쿼리가 repository 안으로 들어오면서 재사용성이 떨어짐
예시 쿼리 흐름
- findOrders() → 주문 1번 조회 (select new OrderQueryDto(...))
- findOrderItems(orderId) → 주문상품 N번 조회 (select new OrderItemQueryDto(...) where order.id = :orderId)
결국 N+1문제가 발생한다.
왜 V4가 나왔을까?
엔티티 중심으로 데이터를 끌고 오기보다는,
처음부터 API 응답 DTO에 맞춰 쿼리도 설계하고 싶을 때 유용하다.
예를 들어 성능보다 응답 데이터의 모양이 더 중요한 상황(관리자 화면, 통계 API 등)에서는
엔티티보다는 DTO 중심으로 명확하게 쿼리를 설계하는 게 실용적일 수 있다.
V5 – 쿼리 최적화 (N+1 문제 해결)
V4의 구조를 그대로 유지하면서도 쿼리 수를 최소화한 방식이다. 핵심은 이거다:
V4에서는 주문 N건 조회 후, 각 주문마다 상품을 개별 쿼리로 조회했는데
V5에서는 주문상품도 한 번에 IN 조건으로 묶어 한 방에 조회한 뒤, 메모리에서 조립한다.
코드 흐름
//controller
@GetMapping("/api/v5/orders")
public List<OrderQueryDto> ordersV5() {
return orderQueryRepository.findAllByDto_optimization();
}
//repository
/**
* 최적화 방식
* - 쿼리 2번만 사용 (루트 + 컬렉션)
* - N+1 문제 없음
* - 컬렉션은 Map으로 매핑해서 메모리에서 조립
*/
public List<OrderQueryDto> findAllByDto_optimization() {
// 1. 루트(주문) 조회 - ToOne 관계는 fetch join처럼 1번에 가져옴
List<OrderQueryDto> result = findOrders();
// 2. 컬렉션(주문상품) 조회 - in 절로 한방에 조회
Map<Long, List<OrderItemQueryDto>> orderItemMap =
findOrderItemMap(toOrderIds(result));
// 3. 메모리에서 조립 - 쿼리 추가 발생하지 않음
result.forEach(order -> order.setOrderItems(orderItemMap.get(order.getOrderId())));
return result;
}
private List<Long> toOrderIds(List<OrderQueryDto> result) {
return result.stream()
.map(OrderQueryDto::getOrderId)
.collect(Collectors.toList());
}
private Map<Long, List<OrderItemQueryDto>> findOrderItemMap(List<Long> orderIds) {
List<OrderItemQueryDto> orderItems = em.createQuery(
"select new jpabook.jpashop.repository.query.OrderItemQueryDto(" +
"oi.order.id, i.name, oi.orderPrice, oi.count) " +
"from OrderItem oi " +
"join oi.item i " +
"where oi.order.id in :orderIds", OrderItemQueryDto.class)
.setParameter("orderIds", orderIds)
.getResultList();
return orderItems.stream()
.collect(Collectors.groupingBy(OrderItemQueryDto::getOrderId));
}
여기서 orderItemMap은 주문 ID별로 주문상품 리스트를 묶은 Map<Long, List<OrderItemQueryDto>>다.
메모리에서 한 번에 조립하면, 쿼리는 딱 2번만 실행된다.
장점
- 쿼리 수는 딱 2번: 주문 1번, 주문상품 1번
- 데이터 낭비 적고, 응답 구조는 DTO 그대로
- 실무에서 복잡하지 않으면서 성능도 확보할 수 있는 균형점
단점
- 코드 구조는 다소 복잡
- API 응답에 따라 쿼리를 일일이 설계해야 하기 때문에 유지보수 부담
- repository가 점점 API 스펙에 종속된다
V6 – 플랫 데이터 조회: 쿼리 1번 + 애플리케이션에서 그룹핑
V5는 쿼리를 2번으로 줄였지만, 여전히 메모리에서 조립하는 로직이 들어간다.
이번엔 아예 조인 결과를 단일 DTO(FlatDto) 로 한 방에 조회하고,
애플리케이션에서 주문 기준으로 그룹핑하는 방식이다.
코드 흐름
//controller
@GetMapping("/api/v6/orders")
public List<OrderQueryDto> ordersV6() {
List<OrderFlatDto> flats = orderQueryRepository.findAllByDto_flat();
// 1. flat DTO → 그룹핑 (Order 기준)
Map<OrderQueryDto, List<OrderItemQueryDto>> grouped = flats.stream()
.collect(Collectors.groupingBy(
flat -> new OrderQueryDto(
flat.getOrderId(),
flat.getName(),
flat.getOrderDate(),
flat.getOrderStatus(),
flat.getAddress()
),
Collectors.mapping(
flat -> new OrderItemQueryDto(
flat.getOrderId(),
flat.getItemName(),
flat.getOrderPrice(),
flat.getCount()
),
Collectors.toList()
)
));
// 2. 그룹핑된 결과 → 트리 형태로 재조립
return grouped.entrySet().stream()
.map(entry -> {
OrderQueryDto order = entry.getKey();
order.setOrderItems(entry.getValue());
return order;
})
.collect(Collectors.toList());
}
//repsitory
public List<OrderFlatDto> findAllByDto_flat() {
return em.createQuery(
"select new jpabook.jpashop.repository.query.OrderFlatDto(" +
" o.id, m.name, o.orderDate, o.status, d.address, " +
" i.name, oi.orderPrice, oi.count) " +
"from Order o " +
"join o.member m " +
"join o.delivery d " +
"join o.orderItems oi " +
"join oi.item i",
OrderFlatDto.class)
.getResultList();
}
장점
- 쿼리 딱 1번 → 최적화 수준은 가장 높음
- DB 부하도 낮음
단점
- 중복된 row가 많아진다 (주문 정보가 중복 전송됨)
- 애플리케이션에서 그룹핑, 중복 제거 등 추가 연산이 필요
- 페이징 불가능 (조인된 row 기준으로 잘리기 때문)
최종 정리
V1. 엔티티 직접 반환
- Order 엔티티를 그대로 반환
- 연관 객체(Member, Delivery, OrderItem)는 LAZY로 설정되어 있어, 컨트롤러에서 직접 강제 초기화
- 문제점:
- API 스펙이 도메인 모델에 그대로 묶인다
- 양방향 연관관계 → 순환 참조 문제 발생 가능
- 조회 건수가 늘어나면 N+1 문제 발생 (주문 5건이면 최소 16번 쿼리)
V2. 엔티티를 DTO로 변환
- 엔티티를 조회한 뒤 DTO로 변환해서 반환
- 구조는 분리됐지만 여전히 지연 로딩이 발생하여 N+1 문제는 그대로 존재
- 장점: API 응답 필드가 명확해짐
- 단점: 쿼리 수는 줄지 않음
V3. 페치 조인으로 최적화
- @Query에서 fetch join을 활용해 연관 객체들을 함께 한 번에 조회
- 장점:
- 연관된 객체(Member, Delivery)를 한 쿼리로 가져옴
- N+1 문제 해결
- 단점:
- 컬렉션(OrderItem)까지 페치 조인을 하면 row 수가 늘어나고, 페이징 불가
- 하이버네이트는 이 경우 전체 데이터를 메모리에 올려서 페이징 하므로 성능에 매우 악영향
V3.1. 페치 조인 + 컬렉션 지연 로딩 유지
- ToOne 관계는 fetch join
- ToMany 관계는 지연 로딩을 유지
- 대신 hibernate.default_batch_fetch_size나 @BatchSize로 컬렉션을 일괄 조회(batch fetch) 하도록 설정
- 장점: 페이징 가능 + 쿼리 수 감소
- 실무에서 가장 많이 사용되는 전략
V4. DTO 직접 조회 (쿼리 N+1)
- Order와 ToOne 관계를 DTO로 바로 조회
- 이후 루프를 돌면서 ToMany 컬렉션 조회
- 예: 주문 1000건이면 1 + 1000번 쿼리 발생
- 장점: 원하는 필드만 선택적으로 조회
- 단점: 쿼리 수가 많아짐 (N+1 그대로 존재)
V5. DTO 직접 조회 최적화 (IN 절 사용)
- Order ID 리스트를 기반으로 OrderItem을 IN 절로 한 번에 조회하고, 메모리에서 매핑
- 쿼리 수: 1 (Order) + 1 (OrderItems) = 총 2회
- 장점: 대용량 데이터에도 성능 좋음
- 단점: 코드 복잡도 증가
V6. 플랫 데이터 조회 + 애플리케이션에서 재조립
- 모든 데이터를 조인으로 한 번에 가져온 뒤, Java에서 DTO 트리 구조로 재조립
- 장점: 쿼리는 단 1번
- 단점:
- 중복 데이터가 많아져 네트워크 비용 증가
- 페이징 불가
- 애플리케이션에서 grouping + 조립 작업 필요
- 정제되지 않은 flat한 구조
추천 순서 요약
1. 엔티티 조회 방식으로 우선 접근
처음에는 JPA의 기본적인 장점을 살리는 방향으로 설계하는 것이 좋다. 즉, 엔티티를 그대로 조회하고 연관 관계를 적절히 활용해 데이터를 가져오는 방식이다.
1-1. ToOne 관계는 페치 조인으로 최적화
Member, Delivery처럼 @ManyToOne, @OneToOne 관계는 fetch join으로 한 번에 가져와도 row 수가 늘어나지 않으므로 안전하게 사용 가능하다.
1-2. ToMany 관계는 지연 로딩 유지하면서 컬렉션 최적화
OrderItem처럼 1:N 관계는 fetch join을 무조건 쓰지 않고, 대신 아래 방법을 적용한다:
- 페이징이 필요한 경우
→ @BatchSize 또는 hibernate.default_batch_fetch_size 옵션으로 일괄 조회(batch fetch) - 페이징이 필요하지 않은 경우
→ 컬렉션도 fetch join 사용 가능
참고: 컬렉션에 fetch join을 사용하면 페이징이 불가능하다. 하이버네이트는 경고 로그를 띄우고, 전체 데이터를 메모리에 올려 페이징하기 때문에 실무에서 매우 위험하다.
2. 엔티티 조회 방식으로 해결이 안 되면 DTO 조회 방식 사용
API 응답에 필요한 필드만 골라서 조회하고 싶은 경우, 혹은 성능을 좀 더 세밀하게 제어하고 싶은 경우에는 DTO로 직접 조회하는 방식으로 전환한다.
- 단순한 조회는 V4 방식으로도 충분하다
- 대량 데이터를 처리하거나 N+1 문제가 있는 경우 V5 방식(IN 절로 묶어서 컬렉션 조회) 추천
- 페이징이 필요 없다면 V6(flat 구조로 전체 조인)도 고려 가능
3. 그래도 해결되지 않는다면 Native SQL 또는 JdbcTemplate
JPA가 제공하는 ORM의 한계를 넘어서는 복잡한 쿼리 최적화가 필요한 경우엔 SQL을 직접 작성하는 방법으로 넘어간다.
이 단계에서는 쿼리 성능이 우선이기 때문에, JPA의 추상화보다는 JDBC 기반의 접근이 현실적인 선택이 될 수 있다.
마무리하며
JPA에서 연관관계를 가진 데이터를 조회할 때는 단순히 @OneToMany만 걸어두고 끝나는 것이 아니라, 쿼리 수와 성능까지 설계에 포함시켜야 한다.
특히 컬렉션을 조회할 때는 페치 조인을 무조건 쓰기보다는, 페이징 가능 여부, 쿼리 수, 네트워크 트래픽 등을 함께 고려해야 한다.
설계 초반에는 단순한 방식(V2, V3)으로 시작하되, 실제 데이터 양이 많아지고 API 응답 속도가 이슈가 된다면 V3.1, V5, 필요 시 V6 방식까지 단계적으로 최적화를 적용하는 것이 현실적인 접근이다.
감사합니다.
'스프링' 카테고리의 다른 글
| [스프링] 스프링 MVC - 기본기능 (2) 요청 파라미터 (0) | 2025.09.03 |
|---|---|
| [스프링] 스프링 MVC - 기본기능 (1) 로깅 (0) | 2025.09.03 |
| [스프링] 지연 로딩과 조회 성능 최적화 (3) | 2025.07.27 |
| [스프링] DTO가 필요한 이유 (3) | 2025.07.27 |
| [스프링] JPA에서 제공하는 쿼리 방법 (1) | 2025.07.26 |
