Skip to content

Latest commit

 

History

History
245 lines (167 loc) · 8.25 KB

File metadata and controls

245 lines (167 loc) · 8.25 KB

N+1문제에 대한 생각

N+1문제

N+1문제는JPA를 통해 연관관계를 가지는 데이터를 조회하는 상황에서 마주치게 되는 문제이다.

엔티티를 조회할 때 N개의 데이터를 조회하면 조회문이 N+1번 실행되는 문제로 알려져 있다.

사실 말로는 N이 엔티티의 개수인지 각 엔티티가 연관관계를 가지고는 엔티티가 N개인지 헷갈린다. 그래서 예시를 통해 알아보도록 하자.

다음과 같이 여행에 대한 Entity 클래스가 있다고 해보자.

@Entity
public class Trip {

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

    @Column(name = "name")
    private String name;

    @OneToMany(cascade = CascadeType.ALL, mappedBy = "trip", fetch = FetchType.EAGER)
    private List<Location> locations;
}

JPA Repository 사용

JpaRepository를 사용해서 id를 통해 특정 Trip을 조회해보자.

tripRepository.findById(1)

로그

select t1_0.id,t1_0.name from trip t1_0 where t1_0.id=?
Hselect l1_0.trip_id,l1_0.id from location l1_0 where l1_0.trip_id=?

trip을 조회하고 trip에 연관된 location을 조회하는 쿼리 총 2개가 실행된다.

JpaRepository를 사용해서 전체 Trip을 조회해보자.

tripRepository.findAll();

로그

select t1_0.id,t1_0.name from trip t1_0;
select l1_0.trip_id,l1_0.id from location l1_0 where l1_0.trip_id=16;
select l1_0.trip_id,l1_0.id from location l1_0 where l1_0.trip_id=15;
select l1_0.trip_id,l1_0.id from location l1_0 where l1_0.trip_id=14;
`
`
`
select l1_0.trip_id,l1_0.id from location l1_0 where l1_0.trip_id=1;

Location이 null인 객체도 있지만 모든 Trip에 대해 Location을 조회하는 쿼리를 실행한다.

Entity Manager 사용

EntityManger를 통해 조회해보자.

val trip = em.createQuery("select t from Trip t").resultList

로그

select t1_0.id,t1_0.name from trip t1_0;
select l1_0.trip_id,l1_0.id from location l1_0 where l1_0.trip_id=16;
select l1_0.trip_id,l1_0.id from location l1_0 where l1_0.trip_id=15;
select l1_0.trip_id,l1_0.id from location l1_0 where l1_0.trip_id=14;
`
`
`
select l1_0.trip_id,l1_0.id from location l1_0 where l1_0.trip_id=1;

entity Manager 를 통해 직접 쿼리를 실행해도 결과는 똑같았다.

연관된 객체 추가

그렇다면 연관된 테이블을 하나 더 만들면 쿼리의 개수가 어떻게 될까?

@Entity
data class Trip(
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    val id: Long?,

    @Column(name = "name")
    val name: String,

    @OneToMany(cascade = [CascadeType.ALL], mappedBy = "trip", fetch = FetchType.EAGER)
    @ToString.Exclude
    var locations: MutableList<Location> = mutableListOf(),

    @OneToMany(cascade = [CascadeType.ALL], mappedBy = "trip", fetch = FetchType.EAGER)
    @ToString.Exclude
    var tests: MutableList<Test> = mutableListOf(),
)

전체 조회

tripRepository.findAll()

로그

select t1_0.id,t1_0.name from trip t1_0;
select t1_0.trip_id,t1_0.id from test t1_0 where t1_0.trip_id=16;
select l1_0.trip_id,l1_0.id from location l1_0 where l1_0.trip_id=16;
select t1_0.trip_id,t1_0.id from test t1_0 where t1_0.trip_id=15;
select l1_0.trip_id,l1_0.id from location l1_0 where l1_0.trip_id=15;
`
`
`
select t1_0.trip_id,t1_0.id from test t1_0 where t1_0.trip_id=2;
select l1_0.trip_id,l1_0.id from location l1_0 where l1_0.trip_id=2;

Trip을 조회하는 요청 1번 + Location 불러오는 요청 N번 + Test를 불러오는 불러오는 요청 N번 총 2N +1번의 요청이 발생한다. 즉, 테이블에서 연관된 테이블이 많으면 많을 수록 더 치명적이라는 것이다.

여기까지 봤을 때 N+1문제는?

Trip 객체가 N개가 있을 때 전체 Trip을 조회하는 Select 문 하나와 N번의 연관된 객체에 대한 Select문이 각각 한번씩 총 (N * 연관된 테이블 수)번 실행된다. 즉, 조회하려는 엔티티 N개에 대해 (N * 연관된 테이블 수)+1번의 쿼리가 실행되는 문제가 N+1문제이다.

알려진 해결법

FetchType.Lazy

FetchType을 Lazy로 바꾸는 것만으로는 N+1문제를 늦출 뿐이다. 하지만 적어도사용하지 않는 객체에 대해서 N+1번의 조회가 일어나지 않게는 할 수있기 때문에 연관 매핑을 사용할 때 FetchType.Lazy를 사용하는 것이 좋다.

Fetch Join을 통해 직접 Join하도록 구현

JPQL을 통해서도 가능하지면 타입의 안전성을 가질 수 있는 QueryDSL을 사용해서 구현해보았다.

@Repository
class TripCustomRepositoryImpl(
    val queryFactory: JPAQueryFactory
) : TripCustomRepository{
    override fun fetchJoin(): MutableList<Trip>? {
        val qTrip = QTrip.trip
        val qTest = QTest.test
        val qLocation = QLocation.location
        return queryFactory.selectFrom(qTrip)
            .join(qTrip.locations,qLocation)
            .fetchJoin()    
            .join(qTrip.tests,qTest)
            .fetchJoin()
            .fetch()
    }
}

로그

select t1_0.id,l1_0.trip_id,l1_0.id,t1_0.name,t2_0.trip_id,t2_0.id from trip t1_0 join location l1_0 on t1_0.id=l1_0.trip_id join test t2_0 on t1_0.id=t2_0.trip_id;

join을 통해서 한번에 연관관계에 있는 테이블의 정보를 한번에 조회하기 때문에 한번의 요청으로 불러온다.

@BatchSize를 통한 In절 조회

@BatchSize(size=5)
var locations: MutableList<Location> = mutableListOf(),

다음과 같이 @BatchSize를 적용하고 다음과 같은 코드를 실행하면

val tripList = tripRepository.findAll()

println(tripList.get(2))
println(tripList.get(10))

로그

select t1_0.id,t1_0.name from trip t1_0;
select l1_0.trip_id,l1_0.id from location l1_0 where l1_0.trip_id in (3,6,7,4,5);
select t1_0.trip_id,t1_0.id from test t1_0 where t1_0.trip_id in (3,6,7,4,5);
select l1_0.trip_id,l1_0.id from location l1_0 where l1_0.trip_id in (11,15,12,13,14);
select t1_0.trip_id,t1_0.id from test t1_0 where t1_0.trip_id in (11,15,12,13,14);

배치사이즈를 5로 설정하였기때문에 id = 3에 대해 조회할 때 근접한 5개의 데이터를 한번에 조회를 하고 id=11에 대해 조회 할 때 근접한 5개의 데이터를 한번에 조회하였다.

당연하게도 근접한 데이터를 접근하게되면 조회를 한번 더 하지 않고 불러왔던 데이터를 사용한다.

SUBSELECT를 통한 조회

@Fetch(FetchMode.SUBSELECT)
var locations: MutableList<Location> = mutableListOf(),

SubSelect를 통해서 조건에 맞는 데이터를 조회하는 방식이다. 아래는 전체 조회시 나오는 로그이다

로그

select t1_0.id,t1_0.name from trip t1_0;
select l1_0.trip_id,l1_0.id from location l1_0 where l1_0.trip_id in (select t1_0.id from trip t1_0);
select t2_0.trip_id,t2_0.id from test t2_0 where t2_0.trip_id in (select t1_0.id from trip t1_0);

결론

N+1문제는 연관된 테이블이 많아질수록 N+1보다 더 많은 쿼리로 문제를 일으킬 수 있다. 따라서 N+1문제는 해결해야하는데, 해결방법이 여러가지이다. 어떤 상황에서 어떤 해결방법을 사용할지는 상황에 따라 다르다고 생각한다.

우선적으로, FetchType은 거의 모든 경우에 Lazy로 설정하는 것이 좋다고 생각된다. 어떤 해결방법을 사용하든 필요한 경우에 쿼리가 이뤄지도록 미루는 것이 대부분 좋을 것이라고 생각한다.

거의 모든 데이터를 수정하거나 사용하는 비즈니스 로직에 대해서는 Fetch Join을 통해 한번에 조회하는 방식을 채택하는 것이 같은 기능에 대해서 쿼리 수가 최소한의 방법이다.

데이터를 사용하는 부분에서 id가 인접한 데이터를 주로 사용하는 경우 @BatchSize를 통해 인접한 데이터를 우선적으로 조회하며 크기를 적절하게 선택하는 것이 필요하다.

마지막으로 @BatchSize와 비슷하지만 사이즈를 지정할 필요 없이 전체 사이즈에 대해 Where In절과 서브쿼리를 사용하면서 조건이 복잡할 경우 SubSelect 방법을 사용하는 것이 좋아 보인다.