개발 학습 실습실

이 사이트의 실습은 자바스크립트를 켜야 움직입니다. 아래는 같은 내용을 글로 적은 것입니다.

공부 › JPA › 프록시와 로딩

N+1

주문 목록 한 번 조회했는데 쿼리는 왜 여러 번 나가나

주문 목록을 조회한 뒤 주문마다 회원 이름을 꺼냅니다. 연관된 회원을 언제, 어떻게 가져오느냐에 따라 DB로 나가는 쿼리 수가 달라집니다. 목록을 가져오는 쿼리 1번에 더해, 연관된 데이터를 가져오는 쿼리가 N번 더 나가는 것을 N+1 문제라고 합니다.

실행으로 대조 · Hibernate 7.4.5와 H2에서 112가지 조합의 SQL 수를 대조

이 실습에 나오는 개념

N+1 문제
목록을 조회하는 쿼리 1번이 실행된 뒤, 조회된 N개의 결과마다 연관 데이터를 가져오는 쿼리가 1번씩 총 N번 더 실행되는 문제다. 데이터가 많아질수록 쿼리 수가 비례해 늘어 성능이 크게 떨어진다. 연관 데이터를 한 번의 쿼리로 함께 가져오거나(fetch join) 여러 건을 묶어 가져오는(배치 조회) 방식으로 해결한다.
지연 로딩과 즉시 로딩
연관된 엔티티를 언제 조회할지에 대한 설정이다. 즉시 로딩(EAGER)은 엔티티를 조회할 때 연관된 엔티티도 함께 가져온다. 지연 로딩(LAZY)은 연관된 엔티티 자리에 프록시 객체를 넣어 두고 실제로 그 값을 사용할 때 조회한다. 즉시 로딩은 필요 없는 데이터까지 항상 가져오고 예상하기 어려운 쿼리를 만들기 때문에 지연 로딩을 기본으로 쓰는 것이 권장된다.
fetch join
JPQL에서 엔티티를 조회하면서 연관된 엔티티를 SQL 조인으로 한 번에 함께 가져오게 하는 기능이다. 지연 로딩으로 설정된 연관 관계라도 이 쿼리에서는 즉시 함께 조회되어 N+1 문제를 해결한다. 컬렉션을 여러 개 함께 가져오면 결과 행이 곱으로 늘어나는 점에 주의한다.
@EntityGraph
조회할 때 함께 가져올 연관 관계를 애너테이션으로 지정하는 기능이다. Spring Data JPA의 리포지토리 메서드에 붙이면 JPQL을 직접 쓰지 않고도 fetch join과 같은 효과를 얻는다.
배치 페치(Batch Size)
지연 로딩 대상을 하나씩 조회하지 않고 여러 개를 IN 조건으로 묶어 한 번에 조회하게 하는 설정이다. 예를 들어 크기를 100으로 두면 100개의 연관 데이터를 한 번의 쿼리로 가져와, N번 실행되던 쿼리가 N/100번으로 줄어든다. 컬렉션을 fetch join하면 페이징을 메모리에서 처리하던 Hibernate 7.3 이하에서, 컬렉션 연관 관계를 페이징과 함께 쓸 수 있는 해결책이다(7.4부터는 fetch join과 페이징의 조합도 DB에서 처리된다).
1차 캐시
영속성 컨텍스트 안에 엔티티를 식별자 기준으로 보관하는 저장소다. 같은 트랜잭션 안에서 같은 식별자로 다시 조회하면 DB에 쿼리를 보내지 않고 보관된 엔티티를 돌려준다. 그 결과 같은 트랜잭션 안에서 같은 식별자의 엔티티는 항상 같은 객체다. 기본적으로 트랜잭션이 끝나면 사라지므로(OSIV가 켜진 웹 요청에서는 요청이 끝날 때까지 유지된다) 애플리케이션 전체의 캐시와는 다르다.

이렇게 바꿔 보세요

처음 설정으로 실행하면

  1. 쿼리 1번: 주문 10건을 가져왔습니다. 회원은 가져오지 않았습니다. JPQL은 적힌 그대로 주문 테이블만 조회하는 SQL이 됩니다.
  2. 쿼리 2번: 주문 1에서 회원 1의 이름을 꺼내는 순간, 비어 있던 회원 객체(프록시)를 채우려고 조회 쿼리가 나갑니다.
  3. 쿼리 3번: 주문 2에서 회원 2의 이름을 꺼내는 순간, 비어 있던 회원 객체(프록시)를 채우려고 조회 쿼리가 나갑니다.
  4. 쿼리 4번: 주문 3에서 회원 3의 이름을 꺼내는 순간, 비어 있던 회원 객체(프록시)를 채우려고 조회 쿼리가 나갑니다.
  5. 쿼리 5번: 주문 4에서 회원 4의 이름을 꺼내는 순간, 비어 있던 회원 객체(프록시)를 채우려고 조회 쿼리가 나갑니다.
  6. 쿼리 6번: 주문 5에서 회원 5의 이름을 꺼내는 순간, 비어 있던 회원 객체(프록시)를 채우려고 조회 쿼리가 나갑니다.
  7. 쿼리 7번: 주문 6에서 회원 6의 이름을 꺼내는 순간, 비어 있던 회원 객체(프록시)를 채우려고 조회 쿼리가 나갑니다.
  8. 쿼리 8번: 주문 7에서 회원 7의 이름을 꺼내는 순간, 비어 있던 회원 객체(프록시)를 채우려고 조회 쿼리가 나갑니다.
  9. 쿼리 9번: 주문 8에서 회원 8의 이름을 꺼내는 순간, 비어 있던 회원 객체(프록시)를 채우려고 조회 쿼리가 나갑니다.
  10. 쿼리 10번: 주문 9에서 회원 9의 이름을 꺼내는 순간, 비어 있던 회원 객체(프록시)를 채우려고 조회 쿼리가 나갑니다.
  11. 쿼리 11번: 주문 10에서 회원 10의 이름을 꺼내는 순간, 비어 있던 회원 객체(프록시)를 채우려고 조회 쿼리가 나갑니다.

실행 결과

코드

@Entity class Order {
    @ManyToOne(fetch = FetchType.LAZY)
    Member member;
}

interface OrderRepository {
    @Query("select o from Order o")
    List<Order> findOrders();
}

// 서비스 (트랜잭션 안)
List<Order> orders = repo.findOrders();   // 주문 10건
for (Order o : orders) {
    names.add(o.getMember().getName());  // 회원 이름 사용
}

이 모형의 전제

주문(Order)이 회원(Member)을 @ManyToOne으로 참조하고, 반복문은 트랜잭션(영속성 컨텍스트)이 열려 있는 동안 실행된다고 가정했습니다. 영속성 컨텍스트가 닫힌 뒤에 지연 로딩 대상을 건드리면 기본 설정에서는 쿼리 대신 LazyInitializationException이 납니다. 쿼리 수는 실제 Hibernate 7.4.5와 H2 데이터베이스로 112가지 조합(다섯 가지 방법, 주문 1·10·30건, 회원 1·3·10·30명, 배치 크기 2·5·20, 이름 사용 여부)을 실행해, 실제로 나간 SQL의 수가 화면과 일치하는 것을 확인했습니다. @EntityGraph는 스프링 데이터가 내부에서 쓰는 것과 같은 JPA 엔티티 그래프 힌트로 실행했습니다. 즉시 로딩의 1 + 회원 수는 배치 설정이 없을 때의 값입니다. 배치 로딩은 묶음 크기대로 나누는 Hibernate 6 이후의 방식 기준이며, Hibernate 5의 기본 방식에서는 쿼리가 더 나갈 수 있습니다. SQL 모양은 DB에 따라 달라서 MySQL은 IN 목록을, PostgreSQL은 배열 파라미터(= any(?))를 씁니다. 왕복 시간은 쿼리마다 같다고 단순화했습니다. 실제로는 조인 쿼리 한 번이 단순 조회 한 번보다 무거울 수 있습니다.

앞 실습: 프록시와 지연 로딩 · 다음 실습: JPQL과 페치 조인

이 실습에 나오는 개념

이 실습에서 보는 것

설정

코드


      
    

그림

실행 비교

이렇게 바꿔 보세요