프로젝트를 진행하다 보니 주문(Order)과 주문메뉴(OrderMenu) 그리고 메뉴(Menu) 관계를 기반으로 다음과 같은 API가 필요해졌다.
여러 개의 주문을 한 번에 페이징으로 조회하고, 각 주문에 포함된 메뉴까지 함께 조회하고 싶다!
테이블 구성 및 문제 상황
현재 나의 도메인은 다음과 같이 구성되어있다.
- Order (주문)
- OrderMenu (주문 상세 / 다대일 관계로 주문 참조)
- Menu (메뉴)
이 중 OrderMenu가 Order와 Menu를 참조하는 단방향 구조이다.
처음에 내가 착각한 것: 페치 조인은 @OneToMany 전용이다?
@OneToMany(fetch = FetchType.LAZY)
private List<OrderMenu> orderMenus;
보통 위와 같이 @OneToMany로 선언된 리스트에만 JOIN FETCH를 사용하는 줄 알았다. 하지만 이건 오해였다.
페치 조인도 결국 조인
페치 조인(Fetch Join)은 SQL의 조인과 동일하게 외래 키(FK) 를 기준으로 연관된 데이터를 한 번에 가져오는 것일 뿐, @OneToMany, @ManyToOne 같은 방향성과는 무관하다.
예를 들어 다음 쿼리는 OrderMenu -> Order, Menu 단방향 관계에서 모두 페치 조인을 사용하고 있다.
SELECT om
FROM OrderMenu om
JOIN FETCH om.order o
JOIN FETCH om.menu m
WHERE o.user.id = :userId
단 하나의 쿼리로 연관 객체까지 한 번에 조회할 수 있다. 즉, 페치 조인은 JPA가 연관된 객체를 즉시 조회하는 것 뿐이었다.

문제 발생: 페이징
페치조인의 가장 큰 단점은 페이징과 호환이 어렵다는 것이다. 왜냐하면
SELECT * FROM orders o
LEFT JOIN order_menu om ON om.order_id = o.id
이런 식으로 조인을 할 경우, 주문 1개당 메뉴 N개로 인해 row가 중복되고 Pageable에서 개수를 세는 기준이 row가 되기 때문이다.
그 이유는 JPA가 페이징을 처리하는 방식이 다음과 같기 때문이다.
JPA에서 Paging을 사용하면 다음 두 쿼리를 실행한다.
-- 실제 데이터 가져오기
SELECT * FROM orders LIMIT ?, ?
--
전체 개수 세기 SELECT COUNT(*) FROM orders
해결: 2쿼리 전략
1. 먼저, Order만 페이징 쿼리
@Query("SELECT o FROM Order o WHERE o.user.id = :userId")
Page<Order> findByUserId(@Param("userId") Long userId, Pageable pageable);
2. 그 다음, orderIds로 OrderMenu + Menu 페치 조인
@Query("SELECT om FROM OrderMenu om JOIN FETCH om.menu WHERE om.order.id IN :orderIds")
List<OrderMenu> findByOrderIdInWithMenu(@Param("orderIds") List<Long> orderIds);
문제 발생2: 쿼리 4개 발생
페이징 쿼리와, 페치 조인 단 2개만 기대했는데, 4개의 쿼리가 발생했다.
살펴보니, 페이징할 때 가져오는 Order에서 연관된 User를 지연 로딩으로 참조하고 있어서 추가 쿼리가 발생했다.
해결2: 페이징에서 페치조인
페이징에서 페치조인을 할 때, @countQuery로 count는 어떤 쿼리를 기준으로 할 건지 명시해주면 의도한대로 Order를 기준으로 원하는 갯수만큼 조회할 수 있다.
@Query(
value = "SELECT o FROM Order o JOIN FETCH o.store WHERE o.user.id = :userId",
countQuery = "SELECT COUNT(o) FROM Order o WHERE o.user.id = :userId"
)
Page<Order> findAllByUserId(@Param("userId") long userId, Pageable pageable);
쿼리 결과: 단 2개
Hibernate:
select
o1_0.`id`,
o1_0.`created_at`,
o1_0.`created_by`,
o1_0.`modified_at`,
o1_0.`modified_by`,
o1_0.`order_status`,
o1_0.`request_comment`,
o1_0.`store_id`,
o1_0.`user_id`,
u1_0.`id`,
u1_0.`created_at`,
u1_0.`email`,
u1_0.`is_deleted`,
u1_0.`modified_at`,
u1_0.`name`,
u1_0.`password`,
u1_0.`phone`,
u1_0.`user_role`
from
`orders` o1_0
join
`users` u1_0
on u1_0.`id`=o1_0.`user_id`
where
o1_0.`store_id`=?
limit
?
Hibernate:
select
om1_0.`id`,
om1_0.`count`,
om1_0.`created_at`,
om1_0.`created_by`,
om1_0.`menu_id`,
m1_0.`id`,
m1_0.`category`,
m1_0.`description`,
m1_0.`is_deleted`,
m1_0.`is_option`,
m1_0.`name`,
m1_0.`price`,
m1_0.`store_id`,
om1_0.`modified_at`,
om1_0.`modified_by`,
om1_0.`order_id`
from
`orders_menus` om1_0
join
`menu` m1_0
on m1_0.`id`=om1_0.`menu_id`
where
om1_0.`order_id` in (?, ?, ?, ?, ?, ?, ?, ?)
최종 코드
public PageResponseDto<OrderHistoryUserResponseDto> getOrdersForUser(long userId, Pageable pageable) {
// order만 페이징 조회
Page<Order> orders = orderRepository.findAllByUserId(userId, pageable);
List<Long> orderIds = orders.getContent().stream()
.map(Order::getId)
.toList();
// orderIds로 orderMenus 조회(메뉴까지 페치조인)
List<OrderMenu> orderMenus = orderMenuRepository.findByOrderIdInWithMenu(orderIds);
Map<Long, List<OrderMenu>> orderMenuMap = orderMenus.stream()
.collect(Collectors.groupingBy(om -> om.getOrder().getId()));
// Dto에 저장
Page<OrderHistoryUserResponseDto> result = orders.map(order ->
OrderHistoryUserResponseDto.from(
order,
orderMenuMap.getOrDefault(order.getId(), List.of()),
false
)
);
return PageResponseDto.toDto(result);
}
'Coding > 사이드 플젝' 카테고리의 다른 글
| [CtrlU] 아키텍처 (feat. Docker, Redis, 테스트 인스턴스) (0) | 2025.05.03 |
|---|---|
| [뚝배기] 동시성 제어 (비관적 락, 낙관적 락) (2) | 2025.04.27 |
| [뚝배기] 테스트코드 (feat.Reflection, Clock, @ParameterizedTest, 테스트 내 변수 공유) (1) | 2025.04.25 |
| [뚝배기] JPA 벌크연산 (feat.ID 생성 전략) (0) | 2025.04.24 |
| [뚝배기] RESTful한 API 작성하기 (0) | 2025.04.23 |