N+1 문제 정리

2024. 8. 18. 22:30BE & Infra

N+1

N+1 이슈

1번 조회해야할 것을 N개 종류의 데이터 각각을 추가로 조회하게 되서 총 N+1번 DB조회를 하게 되는 문제이다.

대상

@ManyToOne 연관관계를 가진 엔티티에서 주로 발생한다. 즉, 1:N 또는 N:1 관계를 가진 엔티티에서 발생한다.

발생(데이터 조회시)

  • 즉시 로딩으로 데이터를 가져오는 경우 ( N+1 문제가 바로 발생 )
  • 지연 로딩으로 데이터를 가져온 이후에 가져온 데이터에서 하위 엔티티를 다시 조회하는 경우 ( 하위 엔티티를 조회하는 시점에 발생 )

발생 원인

  1. “즉시 로딩”의 경우
    • 사용하지 않는 엔티티를 조회한다.
    • 조인을 이용해 매핑된 Entity를 함께 조회
    • N+1 문제 발생
    • 연관관계에 있는 데이터도 동시에 같이 불러야 할 상황일 경우에는 지연로딩은 select쿼리가 2번 실행되어(디스크에 2번 액세스) 비효율적이기 때문에 이때는 즉시 로딩을 이용하는 것이 효율적이다.
  2. “지연 로딩”의 경우
    • 연관된 데이터를 프록시로 조회
    • 조회 대상이 영속성 컨텍스트에 이미 있으면 프록시 객체가 아닌 실체 객체를 사용한다.

해결책

1. Fetch Join

  1. DB에서 데이터를 가져올때 처음부터 연관된 엔티티나 컬렉션을 한번에 같이 조회하는 방법이다. 즉, User 엔티티를 조회할때 Order도 같이 조회해서 N번 Oder를 조회하는 쿼리를 나가지 않도록 하는 것이다.
  2. @Query 어노테이션을 사용해서 join fetch 엔티티. 연관관계_엔티티”구문을 만들어준다.
  3. 조회의 주체가 되는 Entity 이외에 Fetch join이 걸린 연관 Entity도 같이 영속성 컨텍스트에서 관리해준다.
  4. 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());
    }
}

Untitled

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

Untitled 1

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

Untitled 2

⇒ 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());
}

Untitled 3

단순 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 ==");
}

Untitled 4

지연로딩

지연 로딩은 해당 연결 entity에 대해서 프록시로 걸어두고, 사용할 때 쿼리문을 결국 날리기 때문에 처음 find할 때는 N+1이 발생하지 않지만 추가로 User 검색 후 User의 Article을 사용해야한다면 이미 캐싱된 User의 Article 프록시에 대한 쿼리가 또 발생

Untitled 5

@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());
    }
}

Untitled 6

결론

  • 즉시로딩
    • 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 해결)

참고자료

https://velog.io/@jinyoungchoi95/JPA-모든-N1-발생-케이스과-해결책

https://velog.io/@sweet_sumin/JPA-N1-이슈는-무엇이고-해결책은-무엇인가요

'BE & Infra' 카테고리의 다른 글

RabbitMQ  (0) 2024.08.23
JPA 기본편 정리  (0) 2024.07.19
WebRTC  (0) 2024.07.15
Nginx  (0) 2024.07.01