2024. 8. 18. 22:30ㆍBE & Infra
N+1
N+1 이슈
1번 조회해야할 것을 N개 종류의 데이터 각각을 추가로 조회하게 되서 총 N+1번 DB조회를 하게 되는 문제이다.
대상
@ManyToOne 연관관계를 가진 엔티티에서 주로 발생한다. 즉, 1:N 또는 N:1 관계를 가진 엔티티에서 발생한다.
발생(데이터 조회시)
- 즉시 로딩으로 데이터를 가져오는 경우 ( N+1 문제가 바로 발생 )
- 지연 로딩으로 데이터를 가져온 이후에 가져온 데이터에서 하위 엔티티를 다시 조회하는 경우 ( 하위 엔티티를 조회하는 시점에 발생 )
발생 원인
- “즉시 로딩”의 경우
- 사용하지 않는 엔티티를 조회한다.
- 조인을 이용해 매핑된 Entity를 함께 조회
- N+1 문제 발생
- 연관관계에 있는 데이터도 동시에 같이 불러야 할 상황일 경우에는 지연로딩은 select쿼리가 2번 실행되어(디스크에 2번 액세스) 비효율적이기 때문에 이때는 즉시 로딩을 이용하는 것이 효율적이다.
- “지연 로딩”의 경우
- 연관된 데이터를 프록시로 조회
- 조회 대상이 영속성 컨텍스트에 이미 있으면 프록시 객체가 아닌 실체 객체를 사용한다.
해결책
1. Fetch Join
- DB에서 데이터를 가져올때 처음부터 연관된 엔티티나 컬렉션을 한번에 같이 조회하는 방법이다. 즉, User 엔티티를 조회할때 Order도 같이 조회해서 N번 Oder를 조회하는 쿼리를 나가지 않도록 하는 것이다.
- @Query 어노테이션을 사용해서 join fetch 엔티티. 연관관계_엔티티”구문을 만들어준다.
- 조회의 주체가 되는 Entity 이외에 Fetch join이 걸린 연관 Entity도 같이 영속성 컨텍스트에서 관리해준다.
- inner join 발생
Fetch Join의 장단점
- 장점
- 단 한번의 쿼리만 발생하도록 설계할 수 있다.
- fetch join을 이용해 특정 엔티티의 하위 엔티티의 하위 엔티티까지 가져오도록 할 수 있다.
- 단점
- 번거롭게 쿼리문을 작성해야 함
- JPA가 제공하는 Pageable 기능 사용 불가(Pageable 사용 불가) → 페이징 단위로 데이터 가져오기 불가능
- batch size로 해결 : 즉시로딩이나 지연로딩 시에 연관된 엔티티를 조회할 때 지정한 size 만큼 sql의 IN절을 사용해서 조회하는 방식
- 1 : N 관계가 2개인 엔티티를 패치 조인 사용 불가→ MultipleBagFetchException 발생
해결법 1. Pagination
Paging처리를 JPA에서 할 때 가장 많이 겪는 이슈입니다. fetch join을 통해서 N+1을 개선한다고는 하지만 막상 Page를 반환하는 쿼리를 작성해보면 다음과 같은 에러가 발생
// Fetch join을 Paging 처리해서 반환
@EntityGraph(attributePaths = {"articles"}, type = EntityGraphType.FETCH)
@Query("select distinct u from User u left join u.articles")
Page<User> findAllPage(Pageable pageable);
// 0페이지의 총 2명의 유저를 반환
@Test
@DisplayName("fetch join을 paging처리에서 사용해도 N+1문제가 발생한다.")
void pagingFetchJoinTest() {
System.out.println("== start ==");
PageRequest pageRequest = PageRequest.of(0, 2);
Page<User> users = userRepository.findAllPage(pageRequest);
System.out.println("== find all ==");
for (User user : users) {
System.out.println(user.articles().size());
}
}
⇒ Limit, Offset이 없음, Count 쿼리는 Page 반환 시 무조건 발생하는 쿼리
⇒ 반환 값은 2명의 유저 article size가 정상적으로 출력
2021-11-18 22:25:56.284 WARN 79170 --- [ Test worker] o.h.h.internal.ast.QueryTranslatorImpl : HHH000104: firstResult/maxResults specified with collection fetch; applying in memory!
⇒ 인메모리를 적용해서 조인을 했다(인메모리에 저장하고, application 단에서 필요한 페이지만큼 반환을 알아서 해주었다는 이야기가 됩니다.)
⇒ 100만건의 데이터가 있을 때 그 중 10건의 데이터만 paging하고 싶었으나 100만건을 다 가져온다? 그것도 메모리에? OOM(Out of Memory)이 발생할 확률이 매우 높음.
Pagination에서는 fetch join을 하고 싶더라도 해결할 수 없음
- fetch join에서 distinct를 쓰는 것과 연관이 있음.
- distinct를 쓰는 이유는 하나의 연관관계에 대해서 fetch join으로 가져온다고 했을 때 중복된 데이터가 많기 때문에 실제로 원하는 데이터의 양보다 중복되어 많이 들어오게 됨.
- 개발자가 직접 distinct를 통해서 jpa에게 중복 처리를 지시하게 되는 것이고, Paging처리는 쿼리를 날릴 때 진행되기 때문에 jpa에게 pagination 요청을 하여도 jpa는 distinct때와 마찬가지로 중복된 데이터가 있을 수 있으니 limit offset을 걸지 않고 일단 인메모리에 다 가져와서 application에서 처리하는 것
Pagination 해결책1 : ToOne 관계에서 페이징 처리 ⇒ 페이징 처리해도 괜찮다.
@EntityGraph(attributePaths = {"user"}, type = EntityGraphType.FETCH)
@Query("select a from Article a left join a.user")
Page<Article> findAllPage(Pageable pageable);
⇒ fetch join을 걸어도 Pagination이 원하는대로 제공된다.
Pagination 해결책 2 : Batch Size
→ ~ToMany 관계 ⇒ 컬랙션 조인을 하는 경우에는 fetch join을 아예 사용하지 않고 조회할 컬랙션 필드에 대해서 @BatchSize
를 걸어 해결
@BatchSize(size = 100)
@OneToMany(mappedBy="user", fetch=FetchType.LAZY)
private Set<Article> articles = emptySet();
⇒ articles만 따로 한번에 select
⇒ 해당하는 Article에 대해서 쿼리를 batch size개를 날리는 것
⇒ in (?, ?)가 결국 user id를 100개를 가져오는 쿼리문으로써 그때그때 조회하는 것이 아닌 조회할 때 그냥 batch size만큼 한번에 가져와서 뒤에 생길 지연로딩에 대해서 미연에 방지하는 것
해결법 2. EntityGraph 어노테이션
- EntityGraph 상에 있는 Entity들의 연관관계 속에서 필요한 엔티티와 컬렉션을 함께 조회하려고 할때 사용한다
- outerJoin 사용
- attributePaths에 쿼리 수행 시 바로 가져올 필드명을 지정하면 LAZY(지연 로딩)가 아닌 Eager(즉시 로딩) 조회로 가져오게 된다.
@EntityGraph(attributePaths = {"order"})
List<User> findAllEntityGraph();
@EntityGraph의 장단점
- 장점
- fetch join의 매번 쿼리를 작성하고 확인하는 문제 해결
- 단점
- outerJoin을 사용하기 때문에 중복 데이터 발생함 (카테시안 곱 현상)→ Set으로 중복 방지/distinct 적용
2. 둘 이상의 Collection fetch join(~ToMany) 불가능
⇒ MultipleBagFetchException
⇒ 자료형을 Set으로 ⇒ 불가능
⇒ BatchSize로 해결 ⇒ but, batch size에 fetch join을 걸면 안됨.
⇒ fetch join이 우선시되어 적용되기 때문에 batch size가 무시되고 fetch join을 인메모리에서 먼저 진행하여 List가 MultipleBagFetchException가 발생하거나, Set을 사용한 경우에는 Pagination의 인메모리 로딩을 진행
- List 자료구조를 꼭 사용해야하는 경우
- 2개 이상의 Collection join을 사용하는데 Pagination을 사용해야해서 인메모리 OOM을 방지하고자 하는 경우
N+1이 발생하는 예시
@Entity
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(length = 10, nullable = false)
private String name;
@OneToMany(mappedBy = "user")
private Set<Article> articles = emptySet();
}
@Entity
public class Article {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(length = 50, nullable = false)
private String title;
@Lob
private String content;
@ManyToOne
private User user;
}
즉시 로딩
// User.java
@OneToMany(mappedBy = "user", fetch = FetchType.EAGER)
private Set<Article> articles = emptySet();
// Article.java
@ManyToOne(fetch = FetchType.EAGER)
private User user;
1.1 findById()
@Test
@DisplayName("Eager type은 User를 단일 조회할 때 join문이 날아간다.")
void userSingleFindTest() {
System.out.println("== start ==");
User user = userRepository.findById(1L)
.orElseThrow(RuntimeException::new);
System.out.println("== end ==");
System.out.println(user.name());
}
단순 findById()로 하나의 유저를 찾는거면 괜찮지만, jpql문을 직접 짜서 전달하거나, data jpa에서 findBy~의 쿼리메소드 같은 경우에도 data jpa 내부에서 jpql이 만들어져서 나갈 때, 문제가 생김. ⇒ user를 조회하면 즉시로딩을 통해 user와 매핑된 articles들이 모두 조회되기 때문.
1.2 findAll()
@Test
@DisplayName("Eager type은 User를 전체 검색할 때 N+1문제가 발생한다.")
void userFindTest() {
System.out.println("== start ==");
List<User> users = userRepository.findAll();
System.out.println("== find all ==");
}
지연로딩
지연 로딩은 해당 연결 entity에 대해서 프록시로 걸어두고, 사용할 때 쿼리문을 결국 날리기 때문에 처음 find할 때는 N+1이 발생하지 않지만 추가로 User 검색 후 User의 Article을 사용해야한다면 이미 캐싱된 User의 Article 프록시에 대한 쿼리가 또 발생
@Test
@DisplayName("Lazy type은 User 검색 후 필드 검색을 할 때 N+1문제가 발생한다.")
void userFindTest() {
System.out.println("== start ==");
List<User> users = userRepository.findAll();
System.out.println("== find all ==");
for (User user : users) {
System.out.println(user.articles().size());
}
}
결론
- 즉시로딩
- jpql을 우선적으로 select하기 때문에 즉시로딩을 이후에 보고 또다른 쿼리가 날아가 N+1
- 지연로딩
- 지연로딩된 값을 select할 때 따로 쿼리가 날아가 N+1
- fetch join
- 지연로딩의 해결책
- 사용될 때 확정된 값을 한번에 join에서 select해서 가져옴
- Pagination이나 2개 이상의 collection join에서 문제가 발생
- Pagination
- fetch join 시 limit, offset을 통한 쿼리가 아닌 인메모리에 모두 가져와 application단에서 처리하여 OOM 발생
- BatchSize를 통해 필요 시 배치쿼리로 원하는 만큼 쿼리를 날림 > 쿼리는 날아가지만 N번 만큼의 무수한 쿼리는 발생되지 않음
- 2개 이상의 Collection join
- List 자료구조의 2개 이상의 Collection join(~ToMany관계)에서 fetch join 할 경우 MultipleBagFetchException 예외 발생
- Set자료구조를 사용한다면 해결가능 (Pagination은 여전히 발생)
- BatchSize를 사용한다면 해결가능 (Pagination 해결)
참고자료
'BE & Infra' 카테고리의 다른 글
RabbitMQ (0) | 2024.08.23 |
---|---|
JPA 기본편 정리 (0) | 2024.07.19 |
WebRTC (0) | 2024.07.15 |
Nginx (0) | 2024.07.01 |