✏️주문 상세 조회 API를 개발하면서 N+1 문제를 해결하기 위한 두 가지 접근법, EntityGraph와 QueryDSL 프로젝션을 비교 검토했다. 초기에는 JPA의 EntityGraph를 적용했으나 실제 쿼리 로그를 분석하며 몇 가지 개선이 필요한 부분을 발견했다.
EntityGraph는 코드가 간결하고 직관적인 장점이 있었지만, 모든 컬럼을 조회하는 비효율성과 복잡한 JOIN 구조로 인한 성능 문제가 우려되었다. 이를 개선하기 위해 QueryDSL 프로젝션 방식으로 전환하고 그 과정에서 얻은 인사이트를 정리하여 공유하고자 한다.
주문 상세 조회 기능을 구현할 때 처음에는 EntityGraph로 N+1 문제를 해결하려 했으나, 필요 이상의 데이터를 조회하는 문제가 있었다. 이를 QueryDSL 프로젝션으로 전환하여 필요한 데이터만 효율적으로 조회하는 방식으로 개선했다.
처음에는 다음과 같이 JPA의 EntityGraph를 활용했다.
@EntityGraph(attributePaths = {"user", "store", "orderItems", "orderItems.product"})
Optional<Order> findById(UUID orderId);
이 방식은 간결하지만 실제 로그를 분석해보니 모든 테이블의 모든 컬럼을 조회하는 것을 확인했다.
SELECT o1_0.id, o1_0.cancel_reason, o1_0.created_at, /* ... 모든 컬럼 ... */
FROM p_order o1_0
LEFT JOIN p_order_item oi1_0 ON o1_0.id=oi1_0.order_id
LEFT JOIN p_product p1_0 ON p1_0.id=oi1_0.product_id
JOIN p_store s2_0 ON s2_0.id=o1_0.store_id
JOIN p_user u2_0 ON u2_0.id=o1_0.customer_id
WHERE o1_0.id=?
필요한 데이터만 선택적으로 조회하기 위해 QueryDSL 프로젝션으로 전환했다. 이 방식은 두 개의 쿼리로 나누어 실행한다.
// 주문 기본 정보 조회
private Tuple fetchOrderMainData(UUID orderId) {
return queryFactory
.select(
new QOrderDetailResponse_OrderResponse(
order.id, order.orderType, order.status,
order.totalPrice, order.cancelReason, order.request),
new QOrderDetailResponse_UserResponse(user.id, user.username),
new QOrderDetailResponse_StoreResponse(store.id, store.name))
.from(order)
.join(order.user, user)
.join(order.store, store)
.where(order.id.eq(orderId))
.fetchOne();
}
// 주문 상품 정보 조회
private List<OrderItemResponse> fetchOrderItems(UUID orderId) {
return queryFactory
.select(new QOrderDetailResponse_OrderItemResponse(
orderItem.id, product.name, orderItem.amount))
.from(orderItem)
.join(orderItem.product, product)
.where(orderItem.order.id.eq(orderId))
.fetch();
}
이 쿼리들은 실제로 필요한 컬럼만 선택적으로 조회한다.
/* 기본 정보 조회 */
SELECT o1_0.id, o1_0.order_type, o1_0.status, o1_0.total_price,
o1_0.cancel_reason, o1_0.request, u1_0.id, u1_0.username,
s1_0.id, s1_0.name
FROM p_order o1_0
JOIN p_user u1_0 ON u1_0.id=o1_0.customer_id
JOIN p_store s1_0 ON s1_0.id=o1_0.store_id
WHERE o1_0.id=?
/* 주문 상품 조회 */
SELECT oi1_0.id, p1_0.name, oi1_0.amount
FROM p_order_item oi1_0
JOIN p_product p1_0 ON p1_0.id=oi1_0.product_id
WHERE oi1_0.order_id=?
EntityGraph와 QueryDSL 프로젝션 방식의 주요 차이점은 다음과 같다.
| 구분 | EntityGraph | QueryDSL 프로젝션 |
|---|---|---|
| 쿼리 수 | 1개 | 2개 |
| 조회 컬럼 | 모든 컬럼 | 필요한 컬럼만 |
| 코드 복잡성 | 낮음 | 중간 |
| 데이터 전송량 | 많음 | 적음 |
| 메모리 사용 | 많음 | 적음 |
| 유연성 | 낮음 | 높음 |
모든 최적화에는 트레이드오프가 있지만, 이번 케이스에서는 다음 이유로 QueryDSL 프로젝션 방식을 선택했다.
1. 필요한 데이터만 정확히 조회하여 성능 향상
2. 쿼리가 더 간결하고 이해하기 쉬움
3. 메모리와 네트워크 리소스 효율적 사용
4. DTO 직접 매핑으로 변환 오버헤드 감소
✏️이번 개발을 통해 단순히 코드의 간결함보다 실제 성능과 효율성을 고려한 의사결정의 중요성을 체험했다. 특히 쿼리 로그를 분석하여 실제 동작을 이해하는 과정이 매우 중요했다. 앞으로도 다양한 ORM 기능들을 사용할 때는 편의성과 함께 실제 성능 특성을 항상 검토해야 한다는 점을 배웠다. 또한 QueryDSL의 유연성을 활용하면 JPA의 편리함을 유지하면서도 세밀한 성능 최적화가 가능하다는 점도 중요한 인사이트였다.