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);