Spring

N + 1 문제와 해결 방법

김예나 2025. 2. 27. 20:48

N + 1 문제

  • 연관관계가 설정된 엔티티를 조회할 경우에 조회된 데이터 갯수만큼 연관관계의 조회 쿼리가 추가로 발생하는 것
  • 연관된 데이터를 조회할 때 비효율적인 쿼리가 발생하는 문제

만약 주인은 3명이 있고, 고양이는 9마리가 있다고 하자

주인 1명당 고양이를 3마리씩 소유하고 있음

이때 주인 엔티티를 조회하게 되면 쿼리는 총 4번 날라가게 된다!

SELECT * FROM Owner; → 회원 3명을 조회하는 1번의 쿼리
SELECT * FROM Cat WHERE owner_id = ?; → 각 회원마다 고양이를 조회하는 3번의 쿼리

 

해결방법

이렇게 여러번의 select 쿼리가 날라가지 않게 하기 위해서는 join쿼리를 날리고 싶기 마련이다

select * from owner left join cat on cat.owner_id = owner.id

 

JPQL을 이용한 일반 JOIN 쿼리

지난 프로젝트때 실제로 이렇게 쿼리를 작성해서 데이터를 조회했었다 🫣

하지만 이렇게 하면 실제로는 위와 똑같이  N+1 문제가 동일하게 발생한다

@Query("SELECT o FROM Owner o JOIN o.cats c")
List<Owner> findAllWithJoin();

 

JOIN과 FETCH JOIN 차이

  • JOIN은 관계된 엔티티를 결합하여 데이터를 조회하지만, 연관된 엔티티는 지연 로딩(Lazy Loading) 방식으로 처리되며, 추가적인 쿼리가 실행
  • FETCH JOIN은 관계된 엔티티를 즉시 로딩(Eager Loading)하며, 연관된 엔티티도 한 번의 쿼리로 조회하여 N+1 문제 방지

 

FETCH JOIN

  • Cat과 Owner를 즉시 로딩하므로, Cat과 Owner를 함께 한 번의 쿼리로 조회됨
  • FetchType을 Lazy로 해놓는것이 무의미
  • 하나의 쿼리문으로 가져오다 보니 페이징 단위로 데이터를 가져오는것이 불가능하므로 페이징 쿼리를 사용할 수 없음
  • inner join으로 호출
@Query("SELECT c FROM Cat c JOIN FETCH c.owner o")
List<Cat> findAllCatsWithOwners();

 

수행되는 쿼리 : 1번만 수행

SELECT c.id, c.name, o.id, o.name
FROM Cat c
JOIN Owner o ON c.owner_id = o.id;

 

EntityGraph

  • Cat과 Owner를 즉시 로딩하므로, Cat과 Owner를 함께 한 번의 쿼리로 조회됨
  • outer join으로 호출
@EntityGraph(attributePaths = "cats")
@Query("select o from Owner o")
List<Owner> findAllEntityGraph();

 

수행되는 쿼리 : 1번만 수행

SELECT o.*, c.*
FROM owner o
LEFT JOIN cat c ON o.id = c.owner_id;

 

 

Fetch Join과 EntityGraph 주의할 점

Cartesian Product가 발생하여 Owner의 수만큼 Cat이 중복 데이터가 존재 -> 중복된 데이터가 컬렉션에 존재하지 않도록 주의

이런식으로 데이터가 들어오기 때문에 set을 사용하여 데이터를 담아줘야 한다!

public List<OwnerDTO> convertToDTO(List<Owner> owners) {
    Set<OwnerDTO> ownerDTOs = new LinkedHashSet<>();  // Set 사용하여 중복 제거

    for (Owner owner : owners) {
        // 새로 OwnerDTO 생성
        List<CatDTO> catDTOs = new ArrayList<>();
        for (Cat cat : owner.getCats()) {
            catDTOs.add(new CatDTO(cat.getId(), cat.getName()));
        }

        // OwnerDTO를 Set에 추가
        OwnerDTO ownerDTO = new OwnerDTO(owner.getId(), owner.getName(), catDTOs);
        ownerDTOs.add(ownerDTO);  // 중복 제거
    }

    // Set을 List로 변환해서 반환
    return new ArrayList<>(ownerDTOs);
}

 

 

FETCH JOIN을 사용했는데 Page객체로 받았다면?

  • JPA는 페이징을 DB에서 하지 않고, 모든 데이터를 가져와서 메모리에서 페이징을 시도
  • 결과적으로 OutOfMemoryError 등의 성능 문제가 발생가능성이 생김
@Query("SELECT t FROM Todo t JOIN FETCH t.user ORDER BY t.modifiedAt DESC")
Page<Todo> findAllTodos(Pageable pageable);