Contents
see ListN+1 조회 문제는 왜 운영 환경에서 커질까
Spring Boot에서 목록 API를 만들 때 가장 흔한 성능 저하 원인 중 하나가 JPA의 N+1 조회입니다. 주문 목록을 조회한 뒤 각 주문의 회원 이름이나 배송 정보를 응답 DTO에 넣는 과정에서, 처음에는 주문을 한 번 조회하고 이후 주문 건수만큼 연관 엔티티 조회 SQL이 추가로 실행됩니다. 개발 데이터가 10건이면 알아차리기 어렵지만, 페이지당 100건과 여러 연관 관계가 겹치면 데이터베이스 왕복 횟수와 커넥션 점유 시간이 빠르게 증가합니다.
중요한 점은 EAGER로 바꾸는 것이 해법이 아니라는 것입니다. 즉시 로딩은 조회 시점을 앞당길 뿐, 목록 상황에서 필요한 데이터를 한 번에 가져온다는 보장이 없습니다. 엔티티의 기본 로딩 전략은 도메인 관계에 맞추고, API별 조회 요구는 리포지터리 쿼리에서 명시적으로 설계하는 편이 예측 가능하고 안전합니다.
먼저 SQL 개수로 증상을 확인하기
로컬 또는 테스트 환경에서 Hibernate SQL 로그와 바인딩 값 로그를 잠시 켭니다. 같은 형태의 SELECT가 주문 수만큼 반복되는지 확인합니다. 운영 환경에서 모든 SQL 로그를 계속 켜는 것은 로그량과 민감 정보 노출 위험이 있으므로 피하고, 슬로우 쿼리 로그·APM·관측 도구의 지표를 함께 사용합니다.
spring.jpa.properties.hibernate.format_sql=true; spring.jpa.properties.hibernate.generate_statistics=true; logging.level.org.hibernate.SQL=debug; logging.level.org.hibernate.orm.jdbc.bind=trace로그에서 주문 목록 SELECT 한 번 뒤에 member 또는 delivery 조회가 반복되면 의심할 수 있습니다. 더 정확하게는 통합 테스트에서 Hibernate 통계의 prepared statement 수를 검사합니다. 단, 2차 캐시나 테스트 실행 순서가 결과에 영향을 줄 수 있으므로 테스트마다 영속성 컨텍스트를 비우고 기대 SQL 수는 유연하게 관리하는 것이 좋습니다.
to-one 관계는 fetch join으로 필요한 화면에만 묶기
주문 목록 화면이 주문자와 배송 정보를 항상 표시한다면, 해당 API 전용 쿼리에 fetch join을 적용할 수 있습니다. 아래 예시는 주문의 다대일 회원과 일대일 배송 정보를 한 번의 SQL로 가져옵니다. 응답을 만들 때 연관 객체에 접근해도 추가 조회가 발생하지 않습니다.
@Query("select o from Order o join fetch o.member left join fetch o.delivery where o.status = :status order by o.id desc") List<Order> findForAdminList(@Param("status") OrderStatus status);fetch join은 조회 요구가 분명한 단건 또는 to-one 중심 목록에 적합합니다. 하지만 컬렉션 관계까지 여러 개를 동시에 fetch join하면 행이 불필요하게 늘어나거나 중복된 루트 엔티티가 생길 수 있습니다. 특히 페이징 쿼리에 일대다 컬렉션 fetch join을 섞으면 데이터베이스 페이징 대신 메모리에서 잘리는 문제가 발생할 수 있으므로 피해야 합니다.
컬렉션은 배치 조회 또는 두 단계 조회로 분리하기
주문마다 주문상품 목록까지 필요한 화면이라면 전역 또는 관계 단위 배치 크기를 설정하는 방법이 실용적입니다. 주문 100건에서 컬렉션을 지연 로딩하더라도, 개별 100회 대신 ID를 묶은 IN 쿼리 여러 번으로 줄일 수 있습니다. 데이터베이스의 IN 절 제한과 트래픽을 고려해 100~500 정도에서 부하 테스트로 조정합니다.
spring.jpa.properties.hibernate.default_batch_fetch_size=200; @BatchSize(size = 200) private List<OrderItem> orderItems = new ArrayList<>();관리자 목록처럼 필요한 열이 명확한 화면은 엔티티 그래프보다 DTO 직접 조회가 더 나을 수 있습니다. 목록 ID를 페이지로 먼저 조회하고, 다음 쿼리에서 필요한 회원·배송·집계 값을 가져온 뒤 애플리케이션에서 조립하면 페이징과 SQL 비용을 통제하기 쉽습니다. 대량 데이터에서는 개별 주문상품을 모두 내려보내기보다 상품 수, 총액 같은 집계값을 별도 쿼리로 계산하는 것도 효과적입니다.
성능 개선 뒤 반드시 확인할 항목
- 목록 건수를 10건, 100건, 최대 페이지 크기로 바꾸어 SQL 횟수와 응답 시간을 비교한다.
- 페이징 쿼리에 컬렉션 fetch join이 들어가지 않았는지 확인한다.
- DTO 변환 코드에서 지연 로딩 프록시에 다시 접근하지 않는지 점검한다.
- 배치 크기를 키운 뒤 데이터베이스의 IN 쿼리 길이, 실행 계획, 커넥션 대기 시간을 확인한다.
- 조회 API와 수정 트랜잭션을 분리하고, 조회 전용 트랜잭션에는
readOnly = true적용을 검토한다.
N+1 문제는 로딩 전략 하나를 바꾸는 작업이 아니라 화면별 데이터 요구를 SQL 비용으로 번역하는 작업입니다. to-one은 필요한 경우 fetch join, 컬렉션은 배치 조회나 두 단계 조회, 복잡한 목록은 DTO 조회라는 기준을 두고 실제 SQL 수와 실행 계획으로 검증하면 재발을 줄일 수 있습니다.
spring
| No | 작성일 | Title |
|---|---|---|
| 40 | 2014. 09. 29. | [ mybatis ] 마이바티스 map 이나 bean 을 이용하지 않고 2개이상 파라메터 전달하기 |
| 39 | 2014. 09. 26. | [ spring ] view(jsp) 에서 htmlEscape 사용하기 |
| 32 | 2014. 09. 26. | [ spring ] properties 파일 쉽게 사용하기(jsp,javs 소스) |
| 31 | 2014. 09. 25. | [ spring ] 스프링에서 aop(aspectj) 와 트랜젝션 순서 정하기 |