JPA N+1 문제를 찾고 해결한 과정
주문 목록 조회가 느려진 이유를 로그에서 확인하고, fetch join과 조회 분리로 N+1 문제를 정리한 기록입니다.
처음부터 N+1 문제를 의심했던 건 아닙니다.
관리자 주문 목록 화면이 유독 느리다는 이야기를 먼저 들었습니다. 주문 데이터가 아주 많은 것도 아니었는데, 어떤 날은 금방 열리고 어떤 날은 한참 뒤에 열렸습니다.
처음에는 "검색 조건이 많아서 그런가?" 정도로 생각했습니다. 그런데 로그를 열어보니 문제는 화면이 아니라 조회 방식에 있었습니다.
이상했던 지점
문제가 된 화면은 주문 목록을 20건씩 보여주는 페이지였습니다. 목록에는 아래 정보가 같이 나가고 있었습니다.
- 주문 번호
- 주문자 이름
- 배송지
- 주문 상태
- 대표 상품명
겉으로 보기에는 단순한 목록인데, 실제 서비스 코드는 주문 엔티티를 가져온 뒤 화면용 DTO로 바꾸는 과정에서 연관 객체를 계속 따라가고 있었습니다.
public List<OrderSummaryResponse> getOrders(OrderSearchCondition condition) {
List<Order> orders = orderRepository.findPage(condition);
return orders.stream()
.map(order -> new OrderSummaryResponse(
order.getId(),
order.getMember().getName(),
order.getDelivery().getAddress().getCity(),
order.getStatus(),
order.getOrderItems().get(0).getItem().getName()
))
.toList();
}코드만 보면 평범합니다. 문제는 order.getMember(), order.getDelivery(), order.getOrderItems()가 모두 연관 관계라는 점이었습니다.
처음 확인한 로그
Hibernate SQL 로그를 켜고 같은 요청을 다시 호출해봤습니다. 주문 20건을 조회했을 뿐인데 쿼리가 생각보다 많이 나갔습니다.
select o.id, o.member_id, o.delivery_id, o.status
from orders o
order by o.created_at desc
limit 20;
select m.id, m.name
from member m
where m.id = ?;
select d.id, d.city, d.street
from delivery d
where d.id = ?;
select oi.id, oi.order_id, oi.item_id
from order_item oi
where oi.order_id = ?;이 패턴이 주문 수만큼 반복됐습니다.
주문 목록 1번 쿼리 + 회원 조회 20번 + 배송 조회 20번 + 주문상품 조회 20번. 간단히 봐도 60개 가까운 쿼리가 나가고 있었습니다.
그제야 왜 목록이 들쭉날쭉 느렸는지 이해가 됐습니다. 데이터 양이 갑자기 폭증한 게 아니라, 한 번의 화면 조회가 필요 이상으로 많은 쿼리를 만들고 있었던 겁니다.
왜 이런 일이 생겼나
원인은 LAZY 로딩 자체가 아니라, 목록 조회 코드에서 연관 데이터를 반복해서 꺼내 쓴 방식이었습니다.
Order를 한 번 조회하고 끝난 게 아니라, DTO로 바꾸는 순간 아래 호출들이 추가 쿼리를 일으켰습니다.
order.getMember().getName();
order.getDelivery().getAddress();
order.getOrderItems().get(0).getItem().getName();특히 목록 화면은 "한 건을 자세히 보는 화면"이 아니라 "여러 건을 한 번에 모아 보는 화면"이라서, 이런 식의 접근이 금방 문제를 만들었습니다.
처음에는 EAGER로 바꾸면 되는 줄 알았다
솔직히 처음엔 단순하게 생각했습니다.
@ManyToOne(fetch = FetchType.EAGER)로 바꾸면 해결되지 않을까 싶었습니다.
그런데 그렇게 바꾸는 건 답이 아니었습니다.
- 조회 화면마다 필요한 연관 객체가 다릅니다.
- EAGER는 필요 없는 데이터까지 항상 끌고 오게 만듭니다.
- JPQL에서 원하는 방식으로 묶어 가져온다는 보장도 없습니다.
결국 연관 관계 기본값은 그대로 두고, 목록 조회에서 필요한 연관만 명시적으로 가져오는 방향으로 바꿨습니다.
먼저 고친 부분: to-one 관계
주문 목록에서 회원과 배송 정보는 항상 필요했습니다. 이 둘은 to-one 관계라서 fetch join으로 묶는 편이 가장 단순했습니다.
@Query("""
select o
from Order o
join fetch o.member m
join fetch o.delivery d
where o.deleted = false
order by o.createdAt desc
""")
List<Order> findOrderPage();이렇게 바꾸고 나니 회원과 배송 정보 때문에 추가로 나가던 쿼리는 사라졌습니다.
주문 20건을 읽어도 적어도 member, delivery 때문에 40번 더 날아가던 쿼리는 없어졌습니다.
남아 있던 문제: 컬렉션 관계
그 다음 걸린 건 orderItems였습니다.
처음에는 이것도 fetch join으로 한 번에 끝내고 싶었습니다.
join fetch o.orderItems oi
join fetch oi.item i그런데 목록 화면은 페이징이 들어가고 있었습니다. 컬렉션 fetch join을 붙이면 중복 row가 생기고, 페이징 결과가 기대와 다르게 나올 수 있습니다. 실제로 정렬과 건수도 금방 흔들렸습니다.
그래서 여기서는 욕심내지 않고 조회를 나눴습니다.
실제로 적용한 방식
최종적으로는 아래처럼 정리했습니다.
- 주문, 회원, 배송은 fetch join으로 한 번에 조회
- 주문 상품은 배치 조회로 묶어서 가져오기
- 목록 DTO에 꼭 필요한 값만 조립
설정은 이렇게 넣었습니다.
spring:
jpa:
properties:
hibernate.default_batch_fetch_size: 100그리고 컬렉션을 접근하더라도 한 건씩 따로 가져오지 않고 묶여서 읽히도록 만들었습니다.
이 방식이 마음에 들었던 이유는 단순했습니다.
목록은 목록답게 빠르게 읽히고, 상세 화면은 상세 화면답게 필요한 데이터를 따로 더 가져오면 됐기 때문입니다.
결과
적용 전후를 간단히 비교해보면 이 정도였습니다.
| 항목 | 변경 전 | 변경 후 |
|---|---|---|
| 주문 목록 20건 조회 쿼리 수 | 60개 이상 | 3~5개 수준 |
| 평균 응답 시간 | 약 700ms | 약 200ms |
| 로그 가독성 | 낮음 | 훨씬 단순 |
숫자보다 더 좋았던 건, 이제 왜 느린지 설명할 수 있게 됐다는 점이었습니다. 예전에는 "가끔 느리다" 수준의 감각적인 문제였는데, 바꾸고 나서는 어떤 화면이 어떤 연관을 끌고 오는지 훨씬 선명하게 보였습니다.
이번에 얻은 기준
이후로는 목록 화면을 만들 때 아래 기준을 먼저 봅니다.
- 이 화면이 정말 엔티티 그래프 전체를 필요로 하는가
- to-one과 to-many를 한 번에 같은 방식으로 가져와도 되는가
- DTO 변환 과정에서 연관 객체를 무심코 꺼내고 있지는 않은가
JPA N+1은 거창한 이론보다, 이런 사소한 코드에서 시작되는 경우가 많았습니다.
엔티티를 예쁘게 설계하는 것만큼, 어떤 화면에서 어떤 연관을 실제로 쓰는지 구분하는 습관이 더 중요하다는 걸 이때 많이 배웠습니다.
마무리
N+1은 JPA를 처음 쓸 때만 만나는 문제가 아니었습니다. 오히려 서비스가 조금씩 커지고, 화면이 늘고, DTO가 복잡해질수록 다시 나타났습니다.
그래서 지금은 "연관 관계를 어떻게 매핑할까"보다 "이 조회는 어떤 SQL을 만들까"를 먼저 보려고 합니다.
결국 성능 문제는 코드에서 시작하지만, 해결은 로그와 쿼리를 보는 태도에서 시작된다고 느꼈습니다.