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()가 돌려주는 WaitingOrderrecord WaitingOrder(UUID orderId, GeoPoint location)가 전부다. 화면에 필요한 나머지를 order 도메인에서 다시 읽어야 하는데, 그 "다시 읽기"가 스트림 map 안에 들어가 있었던 것이 문제의 전부다.

흔한 N+1과는 다르다

연관관계 지연 로딩에서 생기는 전형적인 N+1이 아니다. 이 프로젝트는 JPA 연관관계 매핑이 하나도 없다.

$ grep -rE "@ManyToOne|@OneToOne|@OneToMany|@JoinColumn|FetchType" src/main/java
(결과 없음)

모든 FK가 raw UUID 컬럼(Orders.boormiId, Orders.dreamiId, Delivery.orderId)이고, 조인이 필요하면 JPQL 세타 조인으로 쓴다. 그래서:

즉 Hibernate가 만들어낸 N+1이 아니라 서비스 코드가 루프에서 직접 만든 수동 N+1이고, ORM 설정으로는 풀 수 없다. 호출부를 고쳐야 한다.

해결 과정과 그 근거