17. [JPA] N+1 문제 (@BatchSize 해결 방안)

프로그래밍|2017. 11. 23. 12:08

1번의 쿼리로 A라는 테이블의 데이터 100개를 가져왔다고 하자.

헌데 N번의 쿼리가 더 날아가는 상황이 발생했다.

내 의도는 한 번의 쿼리로 100개의 데이터를 가져오는게 전부인데 추가적으로 N번의 쿼리가 더 전송되는 문제이다. 이것을 JPA에서 N+1 문제라고 한다.


발생 케이스에 대해서 상세하게 알아보도록 하자.

다음과 같이 owner, cat 테이블이 존재한다고 하자.

두 테이블의 관계는 owner(1) --> cat(N) 관계이다.


[테이블 구조]

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
create table owner (
    id integer not null,
    name varchar(255),
    primary key (id)
)
 
create table cat (
    id integer not null,
    name varchar(255),
    owner_id integer,
    primary key (id)
)
 
alter table cat 
    add constraint FKlet4cncad4b281dourgr2687d 
    foreign key (owner_id) 
    references owner
cs

    

[엔티티 연관 관계]

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Owner {
    @Id
    private int id;
    private String name;
 
    @OneToMany(fetch = FetchType.EAGER, cascade = CascadeType.PERSIST, mappedBy = "owner")    
    private List<Cat> cats = new ArrayList<>();
}
 
class Cat {
    @Id
    private int id;
    private String name;
 
    @ManyToOne(fetch = FetchType.EAGER)
    private Owner owner;
}
cs


테스트 데이터는 다음과 같다.

owner 테이블 : 20 rows

cat 테이블 : 200 rows


[문제 발생 CASE_1]


자 이제 다음과 같이 cat 테이블의 데이터를 모두 조회해보자.

List<Cat> list = catRepository.findAll();

그럼 의도된 대로 다음의 쿼리가 전송될 것이다. 아래의 쿼리가 전송되면 결과값으로 200건의 rows가 리턴된다.

1
2
3
4
5
6
select
    cat0_.id as id1_3_,
    cat0_.name as name2_3_,
    cat0_.owner_id as owner_id3_3_ 
from
    cat cat0_
cs

        

헌데 다음의 쿼리가 20번 더 요청되는 것을 확인할 수 있다. (owner0_.id 필드에 1~20까지의 값이 매핑된 건 수)

1
2
3
4
5
6
7
select
    owner0_.id as id1_45_0_,
    owner0_.name as name2_45_0_ 
from
    owner owner0_ 
where
    owner0_.id=?
cs


왜 이런 현상이 발생되는가?

답은 엔티티의 객체 연관 관계를 보면 알 수 있다.

cat 엔티티와 owner 엔티티와의 객체 연관 관계를 보면 다음과 같다.

1
2
@ManyToOne(fetch = FetchType.EAGER)
private Owner owner;
cs

cat 엔티티가 초기화되면 owner 엔티티도 함께 초기화 되길 원하는 즉시 로딩이 설정되어 있다는 것이다.


1. cat 테이블 전체 조회 200건 리턴

2. cat 데이터 200건을 각각 영속화 진행

3. cat 엔티티의 owner 속성이 즉시 로딩으로 되어 있기에 owner 데이터를 가져오기 위한 쿼리


문제를 알았으니 즉시 로딩이 아니라 지연 로딩 기법으로 처리하면 되겠네? 라고 생각할 수 있을 것이다.

허나 고양이 목록이라는 페이지에 주인도 함께 노출해 줘야 하는 경우가 있다면 지연 로딩으로 적용해 봤자 똑같이 N번 쿼리가 호출 될 것이다. 물론 catRepository.findAll(); 시점에는 쿼리가 한 번 호출되겠지만 고양이 목록에 주인을 출력해야 하기에 cat.getOwner.getName() 와 같은 코드가 필요할 것이고 이는 곧 N번의 쿼리를 전송하게 되는 것과 마찬가지이다.


[CASE_1 문제 해결 방안]


해결 방안은 의외로 간단한다. N번 호출되는 owner 엔티티에 @BatchSize를 추가해 주면 된다.

1
2
@BatchSize(size = 10)
class Owner {
cs

위와 같이 적용하면 owner 데이터를 가져오기 위환 쿼리가 2번으로 줄게 된다.

1
2
3
4
5
6
7
8
9
select
    owner0_.id as id1_45_0_,
    owner0_.name as name2_45_0_ 
from
    owner owner0_ 
where
    owner0_.id in (
        ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
    )
cs




[문제 발생 CASE_2]


이젠 반대로 owner 테이블을 조회해보자.

ownerRepository.findAll()

1
2
3
4
5
select
    owner0_.id as id1_45_,
    owner0_.name as name2_45_ 
from
    owner owner0_
cs


20개의 rows 데이터가 리턴된 후 20번의 쿼리가 더 전송된다. (cats0_.owner_id 필드의 값 1~20까지)

1
2
3
4
5
6
7
8
9
10
select
    cats0_.owner_id as owner_id3_3_0_,
    cats0_.id as id1_3_0_,
    cats0_.id as id1_3_1_,
    cats0_.name as name2_3_1_,
    cats0_.owner_id as owner_id3_3_1_ 
from
    cat cats0_
where
    cats0_.owner_id=?
cs

        

이 문제가 발생되는 이유 또한 cat 엔티티와의 연관 관계가 즉시 로딩으로 설정되어 있기 때문이다.

1
2
@OneToMany(fetch = FetchType.EAGER, cascade = CascadeType.PERSIST, mappedBy = "owner")
private List<Cat> cats = new ArrayList<>();
cs


[CASE_2 문제 해결 방안]


다음처럼 cats 속성에 @BatchSize를 추가해 주자.

1
2
3
@OneToMany(fetch = FetchType.EAGER, cascade = CascadeType.PERSIST, mappedBy = "owner")
@BatchSize(size = 10)
private List<Cat> cats = new ArrayList<>();
cs


적용 후 테스트 해보면 다음과 같이 in 조건절을 이용해 쿼리하는 것을 알 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
select
    cats0_.owner_id as owner_id3_3_1_,
    cats0_.id as id1_3_1_,
    cats0_.id as id1_3_0_,
    cats0_.name as name2_3_0_,
    cats0_.owner_id as owner_id3_3_0_ 
from
    cat cats0_ 
where
    cats0_.owner_id in (
        ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
    )
cs




[결론]

JPA를 사용할 때 리스트 데이터를 가져와야 하는 경우가 있다면 항상 N+1 문제를 염두해야 한다.

고로 로그를 확인하며 쿼리가 어떻게 만들어지고 전송되는지 확인하는 습관을 가지자.

댓글()