BackEnd/Database

[JPA] N+1 문제, 쿼리 단일화

연향동큰손 2025. 11. 7. 17:01

N+1 문제는 ORM 기술에서 특정 객체를 대상으로 수행한 쿼리가 해당 객체가 가지고 있는 연관관계 또한 조회하게 되면서 N번의 추가적인 쿼리가 발생하는 문제를 말한다.

 

N+1문제로 인해 불필요하게 많은 쿼리를 보내면 성능 저하로 이어질 수 있기 때문에 적절한 방법을 통해 N+1을 예방하는 것이 중요하다.

 


실습

실습에서 사용된 엔티티간의 연관 관계는 다음과 같다.

  • 국가 : 도시 (1:N)
  • 한 국가에 여러 도시가 리스트로 포함

 

 

<CountryEntity>

@Entity
@Getter
@Setter
public class CountryEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(unique = true)
    private String country;

    @OneToMany(mappedBy = "countryEntity")
    private List<CityEntity> cityEntities = new ArrayList<>();

    public void addCityEntity(CityEntity cityEntity) {
        cityEntities.add(cityEntity);
        cityEntity.setCountryEntity(this);
    }
}

 

<CityEntity>

@Entity
@Getter
@Setter
public class CityEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String city;

    @ManyToOne
    private CountryEntity countryEntity;
}

 

 

아래와 같이 3개의 Country와 각각의 City를 생성해주고 JPA를 이용해 조회를 했을때 쿼리가 몇번 수행되는지 확인해보면 N+1 문제가 발생하는지 확인해볼 수 있다.

korea : seoul, busan, daegu
france : paris
usa : newyork, chicago

 

1. 국가(부모)를 모두 조회

국가 리스트(CountryEntity)를 조회할 때는 단순히 부모 엔티티만 가져오는 작업이기 때문에 데이터베이스에 쿼리가 한 번만 전달된다. 뷰 템플릿에서도 국가 정보만 출력하고 연관된 도시 컬렉션을 접근하지 않기 때문에 추가적인 쿼리는 발생하지 않는다.

 

2. 국가(부모) findAll  → 도시(자식) 접근

국가와 함께 각 국가에 속한 도시 목록까지 조회하려고 하면 상황이 달라진다. 먼저 국가 목록을 가져오는 쿼리가 한 번 실행되고, 이후 템플릿에서 cityEntities 컬렉션에 접근하는 순간 각 국가마다 해당 도시에 대한 지연 로딩 쿼리가 추가로 발생한다. 그 결과 국가 수(N)만큼 도시 조회 쿼리가 반복 실행되어 총 1 + N개의 쿼리가 발생하는 것을 확인할 수 있다. 

 

3. 도시(자식)만 조회

도시 엔티티를 단독으로 조회하더라도, @ManyToOne의 기본 Fetch 전략이 EAGER이기 때문에 연관된 국가 엔티티도 즉시 함께 조회된다. 그 결과, 국가 정보를 조회할 의도가 없어도 JPA가 부모 엔티티를 자동으로 불러오기 위해 추가 쿼리를 한 번 더 실행하게 된다.

 

 


Lazy, Eager

JPA는 연관관계를 가진 엔티티를 언제 DB로 불러올지 결정하는 로딩 전략(Lazy,Eager)이 존재한다.

 

Lazy Loading (지연 로딩)

  • 필요할 때까지 연관 엔티티를 DB에서 가져오지 않고 실제로 접근할 때 쿼리 실행

Eager Loading(즉시 로딩)

  • 엔티티를 조회할 때 연관 엔티티도 즉시 함께 조회하는 방식
🎯JPA JOIN별 default 값
- OneToOne : Eager
- ManyToOne : Eager
- OneToMany : Lazy

 

앞서 도시(자식)을 조회할때 국가(부모)를 조회하는 쿼리가 추가된 이유도 도시에 대한 연관 엔티티(국가)가 ManyToOne조인의 default값 Eager로 되어있었기 때문에 자동으로 조회 쿼리가 실행 되었다.

 

여기서 아래 코드와 같이 ManyToOne의 FetchType을 Lazy(지연 로딩)방식으로 변경해주면 연관 엔티티에 대한 접근이 없다면 쿼리를 추가적으로 실행하지 않게 된다.

@ManyToOne(fetch = FetchType.LAZY)
private CountryEntity countryEntity;

도시에 대한 조회 쿼리만 실행된것을 확인할 수 있다.


연관관계 쿼리 단일화

JPA에서 엔티티 조회 후 연관된 엔티티 접근 시 발생하는 추가 쿼리를 단건의 쿼리로 단일화 시키는 방법에는 다음과 같은 방법이 존재한다.

 

  • JPQL @Query로 JOIN FETCH 작성
  • QueryDSL로 fetchJoin()
  • @EntityGraph
  • Lazy 쪽 조회시 Batch 설정을 통한 IN절

 

JPQL JOIN FETCH 작성

JOIN FETCH란 연관된 엔티티를 즉시 조인하여 한 번의 쿼리로 함께 로딩하는 JPQL 문법이다.

 

<기본 문법>

SELECT e
FROM Parent e
JOIN FETCH e.children

 

위 실습에서 국가(부모)를 조회하고 도시(자식)을 조회하는 상황에서는 국가를 조회하고 각 국가마다 해당 도시에 대한 지연 로딩 쿼리가 추가로 발생하였다.

왜냐하면 국가(부모)테이블의 연관 테이블(도시)가 OneToMany로 연결되어있었고 OneToMany의 디폴트 로딩값이 Lazy로딩이기 때문이다.

 

이 상황은 JPQL의 JOIN FETCH를 통해 해결 가능하다.

@Query("SELECT co FROM CountryEntity co " + "JOIN FETCH co.cityEntities ci")
List<CountryEntity> findAllFetch();

JOIN FETCH를 통해 국가와 도시를 모두 한번에 로딩함으로써 추가 쿼리가 발생하지 않도록 할 수 있다.

 

테스트 결과 1번의 쿼리만 실행되는 것을 확인할 수 있다.

 

하지만 이렇게 JOIN FETCH만 사용한다면 누락되는 데이터가 존재할 수 있다.

 

국가에 Germany를 넣고 도시를 추가하지 않게 되면 조회시 Germany가 조회되지 않는다.

(OneToMany에서 One은 존재하는데, Many가 존재하지 않는 경우)

왜냐하면  그냥 JOIN FETCH만 사용할 경우 INNER JOIN으로 연관된게 있는 교집합이어야 조회가 되기 때문이다.

 

만약 교집합이 안되는 데이터도 조회하고 싶다면 LEFT JOIN FETCH을 사용하면 된다.

 

@Query("SELECT co FROM CountryEntity co "+"LEFT JOIN FETCH co.cityEntities ci")
List<CountryEntity> findAllFetch();

LEFT JOIN FETCH


다중 OneToMany Fetch 문제

하나의 엔티티에 2개 이상의 OneToMany가 포함되어 있는데 Fetch 조회를 할 경우 예외가 발생한다.

 

왜냐하면 JPA의 구현체인 Hibernate에서 2개 이상의 OneToMany Fetch를 강제로 막고 있기 때문이다.

 

여러개의 OneToMany를 Fetch Join하게 되면 조인 결과가 카티션 곱(Cartesian Product)으로 나와서 결과가 폭발적으로 커질 수 있다.

 

해결 방법

  1. 기준 엔티티의 OneToMany 필드에서 Set을 사용하여 중복을 제거
  2. 한쪽만 Fetch, 나머지는 in절 : 이렇게 되면 1+N 쿼리는 아니지만 1+소수 개의 쿼리로 문제를 해결 가능하다.
@Query("SELECT co FROM CountryEntity co "+
        "LEFT JOIN FETCH co.cityEntities ci "+
        "LEFT JOIN  co.religionEntities re")
List<CountryEntity> findAllFetch();

 

@BatchSize(size = 250) //Lazy로딩할 때 한번에 250개씩 묶어서 로딩
@OneToMany(mappedBy = "countryEntity")
private List<ReligionEntity> religionEntities = new ArrayList<>();

 

이렇게 BatchSize를 250개로 설정하면 250개의 국가에 대해서 religionEntities 조회 쿼리는 단 1번만 실행된다.


OneToMany fetch시 페이지네이션

@Query("SELECT co FROM CountryEntity co "+
        "LEFT JOIN FETCH co.cityEntities ci ")
List<CountryEntity> findAllFetch(Pageable pageable);

One쪽 엔티티를 기준으로 OneToMany fetch시 페이지네이션을 함께 수행하게 되면 조회는 정상적으로 수행 되지만, 경고가 발생하게 된다.

 WARN 4334 --- [1plusNtest] [nio-8080-exec-1] org.hibernate.orm.query : HHH90003004: firstResult/maxResults specified with collection fetch; applying in memory

 

경고문이 발생하는 이유는 Fethch Join이 적용된 상태에서 페이징을 수행하게 되면 Hibernate가 DB에 Limit/OFFSET을 적용할 수 없기 때문에 모든 데이터를 조회한 뒤, 메모리에서 강제로 페이징을 수행하므로 메모리 낭비 위험이 있기 때문이다.

 

이 문제를 해결하는 방법 중 하나는 batch in 절을 사용하여 1+N 개의 쿼리 중 N개의 쿼리에 대해 batch단위로 묶어 1+소수개의 쿼리로 성능을 최적화 할 수 있다.

@BatchSize(size = 250)
@OneToMany(mappedBy = "countryEntity")
private List<CityEntity> cityEntities = new ArrayList<>();

 

<기존의 Fetch Join방법 대신 findAll로 조회>

public List<CountryEntity> readCountryFetch(){
        Pageable pageable = PageRequest.of(0,5);
//      return countryRepository.findAllFetch(pageable);
        return countryRepository.findAll(pageable).stream().toList();
    }

 

조회 실행 시 국가(부모)에 대한 목록을 조회하는 쿼리 실행 후 batch in을 통해 현재 페이지에 담긴 여러 국가들의 도시(자식)들을 BatchSize만큼 묶어서 일괄 조회하는 것을 확인할 수 있었다.

Hibernate: select ce1_0.id,ce1_0.country from CountryEntity ce1_0 limit ?,?
Hibernate: select ce1_0.countryEntity_id,ce1_0.id,ce1_0.city from CityEntity ce1_0 where ce1_0.countryEntity_id in (?......?)

정리

단순하게 One(부모) 목록만 보여주고 연관된 Many(자식)한테는 접근을 안함

  • Lazy로딩 사용해도 무방하다.

List<One> 목록 및 각 연관 Many로 접근 함

- 페이지네이션이 들어가는 경우 :  @BatchSize를 통한 IN절 쿼리 수행

 

- 다중 OneToMany 상황에서 각각의 OneToMany 데이터가 다 필요한 경우 : 하나만 JOIN FETCH, 나머지는 @BatchSize를 통한 IN절 쿼리 수행 OR 전체 @BatchSize를 통한 IN절 쿼리 수행

 

- SQL 조건으로 가져온 데이터가 다 필요한 경우 OR List<One>의 사이즈가 작고 Many로 접근 함 : JOIN FETCH 사용

 

'BackEnd > Database' 카테고리의 다른 글

스프링 트랜잭션 전파  (0) 2025.02.14
QueryDSL  (1) 2025.02.10
Spring Data JPA  (0) 2025.02.10
MyBatis  (0) 2025.02.08
DB Test[@Transactional, 임베디드 모드 DB]  (0) 2025.02.07