findNearbyCalls가 주변 주문마다 findById를 반복하던 문제와, 이를 주문 ID 일괄 projection 조회로 바꾸기까지의 결정 기록. (이슈 #460, PR #465)
드리미가 주변 콜 목록을 볼 때 호출하는 POST /api/v1/dreami/calls/nearby는 콜 1건마다 주문 조회 SQL을 1번씩 더 실행하고 있었다.
// 개선 전 — DreamiService
@Transactional(readOnly = true)
public List<NearbyCallDto> findNearbyCalls(NearbyOrderRequest request) {
return nearbyOrderFinder.find(request).stream()
.map(this::toNearbyCallDto)
.toList();
}
private NearbyCallDto toNearbyCallDto(NearbyOrderDto nearby) {
Orders order = orderRepository.findById(nearby.orderId()) // ← 콜마다 1회
.orElseThrow(() -> new BusinessException(OrderErrorCode.ORDER_NOT_FOUND));
return NearbyCallDto.from(nearby, order);
}
NearbyOrderFinder의 반환 상한이 10건(MAX_NEARBY_ORDER_COUNT)이므로 한 요청의 N은 최대 10이다. 실제로 로컬 MySQL에 주문 1만 건을 넣고 Hibernate SQL 로그로 세어본 결과가 아래다.
요청 count |
ORDERS 조회 statement |
|---|---|
| 1 | 1회 |
| 5 | 5회 |
| 10 | 10회 |
요청당 SQL이 화면에 뿌리는 카드 수에 그대로 비례했다. 게다가 매번 Orders 엔티티 전체를 읽어서, 목록에 쓰지도 않는 route_path(경로 좌표 JSON, TEXT), image_key, delivery_request 같은 컬럼까지 10번 실어 날랐다.
findNearbyCalls는 두 도메인을 합쳐서 응답을 만든다.
| 출처 | 주는 것 |
|---|---|
| matching (인메모리) | orderId, 좌표, 거리 |
| order (DB) | 품목, 주소, 예상수익, ETA |
MatchingService.waitingOrders()가 돌려주는 WaitingOrder는 record WaitingOrder(UUID orderId, GeoPoint location)가 전부다. 화면에 필요한 나머지를 order 도메인에서 다시 읽어야 하는데, 그 "다시 읽기"가 스트림 map 안에 들어가 있었던 것이 문제의 전부다.
연관관계 지연 로딩에서 생기는 전형적인 N+1이 아니다. 이 프로젝트는 JPA 연관관계 매핑이 하나도 없다.
$ grep -rE "@ManyToOne|@OneToOne|@OneToMany|@JoinColumn|FetchType" src/main/java
(결과 없음)
모든 FK가 raw UUID 컬럼(Orders.boormiId, Orders.dreamiId, Delivery.orderId)이고, 조인이 필요하면 JPQL 세타 조인으로 쓴다. 그래서:
@EntityGraph / join fetch를 쓸 수 없다. 페치할 연관이 애초에 없다.hibernate.default_batch_fetch_size=100도 무력하다. 이 설정은 연관 컬렉션/프록시를 모아 읽는 옵션이라, 연관이 없으면 아무것도 하지 않는다.즉 Hibernate가 만들어낸 N+1이 아니라 서비스 코드가 루프에서 직접 만든 수동 N+1이고, ORM 설정으로는 풀 수 없다. 호출부를 고쳐야 한다.