수 많은 우문은 현답을 만든다

JPA 와 N+1 문제점 본문

개발지식/Springboot

JPA 와 N+1 문제점

aiden.jo 2022. 1. 22. 23:32

JPA란

JPA(Java Persistence API)는 Java ORM 기술에 대한 API 표준 명세이다. 이에 대한 구현체로는 Hibernate, EclipseLink, DataNucleus가 있으며 Spring Framework는 기본적으로 Hibernate를 사용한다.

ORM 이란

 여기서 ORM은 Object Relational Mapping (객체-관계 매핑)로 OOP(Object Oriented Programming)에서 쓰이는 객체라는 개념을 구현한 클래스와 RDB(Relational DataBase)에서 쓰이는 테이블을 자동으로 매핑(연결)하는 것을 의미한다. 클래스와 테이블은 애초에 서로 호환가능성을 염두해두고 만들어진 것이 아니기 때문에 불일치가 발생하는데 이를 ORM이 해결한다. 즉 개발자는 SQL문을 직접 짤 필요 없이 ORM을 사용해 간접적으로 데이터베이스를 조작할 수 있게 된다.

 JDBC를 사용한 개발은 단순한 쿼리도 다음 과정을 거쳐야 한다.

  1. 데이터를 담을 컨테이너 객체 정의
  2. 결과를 객체에 맵핑할 맵퍼 정의
  3. SQL문 작성

작은 변경이라도 발생하면 위 3단계 모두에 수정이 가해져야 하는 비용이 발생한다. ORM은 이러한 패러다임 불일치를 해결하고, 객체지향 애플리케이션 개발에 집중할 수 있게 하기 위해 등장했다.

JPA의 장점

위에서 언급한 3가지 전통적 반복적인 작업을 하지 않아도 되어 생산성이 향상되고 유지보수에 용이하다.

특정 데이터베이스 기술에 종속되지 않아서 데이터베이스를 변경하면 JPA에게 다른 데이터베이스를 사용한다고 알려주기만 하면 된다.

JPA의 단점

JPA의 성능이 떨어진다는 이야기는 JAVA가 느리다고 말하는것과 같다고 생각한다.

다만 개발자의 실수로 N+1 같은 성능저하가 발생할 수는 있다.

N+1 문제

N+1(1+N) 문제는 1대 N관계에서 1번 쿼리를 날렸는데 추가로 N번 더 쿼리가 발생하는 상황을 말한다.

예를들어 고객이 한명에 여러개의 계좌(N)를 가지고 있다고 가정했을때, 고객의 정보를 조회했는데 연관 관계를 가지고있는 계좌정보들 까지 N번 조회되는 경우가 발생한다. 이를 해결할 수 있는 방법은 아래 3가지 정도가 있다.

N+1 해결방법

1. 일대다 관계가 아니면 지연 로딩을 쓰자.

JPA는 조회시 FetchType을 정할 수 있으며 FetchType에는 Eager, Lazy 두 가지 타입이 있다.

 

@Entity
class Member{
    @Id @GeneratedValue
    private Long id;

    @OneToMany(mappedBy = "member", fetch = FetchType.EAGER)
    private List<Order> orders = new ArrayList<>();
}

@Entity
class Order{
    @Id @GeneratedValue
    private Long id;

    @ManyToOne
    private Member member;
}

예를들어 위와같이 Member, Order Entitiy를 정의해보자. 이때 Member와 Order는 1:N 관계를 가진다.

 

List<Member> members = 
    em.createQuery("SELECT m FROM Member", Member.class)
    .getResultList();

FetchType.EAGER로 Member를 조회하면 어떤 일이 발생할까?

 

SELECT * FROM Member; -- if result is 5
SELECT * FROM Order_ WHERE MEMBER_ID = 1;
SELECT * FROM Order_ WHERE MEMBER_ID = 2;
SELECT * FROM Order_ WHERE MEMBER_ID = 3;
SELECT * FROM Order_ WHERE MEMBER_ID = 4;
SELECT * FROM Order_ WHERE MEMBER_ID = 5;

즉시로딩을 하므로 Member를 조회한 뒤에 바로 Member별 Order 조회를 해 N+1이 발생한다.

 

그럼 지연로딩을 사용하면 어떻게 될까?
FetchType.LAZY로 설정해 지연로딩을 사용하면 Member만 조회한다! 그럼 N+1 문제가 해결된걸까?

for(Member member : membres){
    System.out.println(member.getOrders().size());
}

아니다. Order를 사용하는 순간 지연로딩에 의해 N+1 조회가 발생한다.

1:N 관계가 아닌 엔티티간에는 지연로딩을 쓸 수 있으나 1:N 관계에서는 다른 방안이 필요할 것 같다.

 

 

2. BatchSize로 N+1 발생 횟수를 줄여보자.

설정한 BatchSize만큼 미리 조회를 하여 SQL 호출 횟수를 줄인다.

@BatchSize(size = 5)
@OneToMany(mappedBy = "id", fetch = FetchType.LAZY, cascade = CascadeType.ALL)
private List<SavingsAccount> savingsAccount2 = new ArrayList<>();

위 예를 보면, batchSize를 5로 설정하면 SQL IN 절 파라미터에 "id"가 5개가 들어가서 한번의 조회로 5개의 결과를 가져올 수 있다.

 

Hibernate: select savings_account.id from savings_account where savings_account.id in (?, ?, ?, ?, ?)

batchSize마다 쿼리를 날리는 것을 확인할 수 있다. 그래도 N+1이 완전이 해소되지는 않는 것으로 보인다.

 

 

3. oneToMany에는 SUBSELECT를 사용하자.

연관된 데이터를 조회할 때 서브쿼리를 사용해서 N+1 문제를 해결할 수 있다.
기본적으로 지연로딩으로 설정하고 필요한 곳에 서브쿼리를 사용한다.

@Fetch(FetchMode.SUBSELECT)
@OneToMany(mappedBy = "id", fetch = FetchType.LAZY, cascade = CascadeType.ALL)
private List<SavingsAccount> savingsAccount3 = new ArrayList<>();

 

아래와 같이 한 번만 실행된다.

Hibernate: select customer.id from customer
Hibernate: select savings_account.id from savings_account where savings_account.id in (select customer.id from customer)

 

 

4. manyToOne에는 Fetch Join을 사용하자.

N+1 이 발생하는 두 테이블을 미리 Join 하여 1개의 쿼리만 날린다.

아래와 같이 JPQL을 적용하면 Fetch Join을 사용할 수 있다.

@Query("select DISTINCT c from Customer c join fetch c.savingsAccount")
    public List<Customer> findAllFetchJoin();

일대 다 조인이므로 DISTINCT를 이용해 중복 제거를 해야한다.

Hibernate: select customer.id, savings_account.id from customer inner join savings_account on customer.id=savings_account.id

결과적으로 한 번의 쿼리만 발생하는 것을 볼 수 있다.

그러나 Fetch Join은 페이징처리가 까다롭고 oneToMany에서는 Many를 한번에 다 조회하기 때문에 메모리가 과도하게 사용될 수 있다. 또한 Many 객체가 여러개면 한개에만 적용할 수 있다는 한계가 있다.

 

 

4-1. Fetch Join의 문제점 : JPA만으로는 Pagination을 할 수 없다.

    1. JPQL을 사용하는 경우.

      페이징 기능(Pagination)구현을 위해서는 MySQL 기준 limit, offset을 사용해야 한다.

      JPA에서 쿼리를 직접 사용하려면 JPQL을 사용하는데 JPQL에서는 limit, offset을 사용할 수 없다.
      @Query("SELECT a FROM Article a INNER JOIN FETCH a.comments LIMIT 3")
      List<Article> findAllLimit3Fetch();​
      위와 같이 쿼리를 작성하면 QuerySyntaxExceptionIllegalArgumentException 이 발생한다.

      TypedQuery<Article> query = entityManager.createQuery("SELECT a FROM Article a INNER JOIN FETCH a.comments c", Article.class);
      query.setFirstResult(0);
      query.setMaxResults(3);​

      대신 setFirstResult(조회 시작 위치)와 setMaxResults(조회할 데이터 수)를 이용해서 pagination을 구현할 수 있다.


    2. QueryDSL을 사용하는 경우.

      QueryDSL에서는 limit, offset을 사용할 수 있다.

      public List<Article> findArticle() {
              return queryFactory.selectFrom(article)
                      .innerJoin(article.comments, comment).fetchJoin()
                      .offset(0)
                      .limit(5)
                      .fetch();
      }
      사실 QueryDSL에서 사용하는 offset(), limit()는 JPQL에서 사용하는 setFirstResult(), setMaxResults() 과 같다.


      AbstractJPAQuery 클래스의 createQuery 메소드 중간을 보면 offset, limit을 설정해주는 내용을 볼 수 있다.


    3. QueryDSL을 사용시 주의해야할 점
      위와 같은 1:N 엔티티 관계에서 Article 1개와 Comment 5개를 조회해야 한다면 어떻게 해야할까?

      public List<Article> findArticleByIdLimit5Fetch(Long id) {
              return queryFactory.selectFrom(article)
                      .innerJoin(article.comments, comment).fetchJoin()
                      .where(article.id.eq(id))
                      .limit(5)
                      .fetch();
      }
      얼핏 보면 완벽해보이는 코드이다. 하지만 실행을 하면 1개의 Aricle에 Comment 6개가 조회된다.


      HHH000104: firstResult/maxResults specified with collection fetch; applying in memory!
      쿼리 결과를 전부 메모리에 적재한 뒤 Pagination 작업을 어플리케이션 레벨에서 하기 때문에 위험하다는 로그가 발생한다. 우리는 limit을 명시하여서 이미 Pagination된 결과를 가져올 것 인데 어째서 이런 위험 경고가 발생하는 것일까?

      Hibernate: 
          select
              article.id.
              comment.article_id
          from
              article 
          inner join
              comment 
                  on article.id=comment.article_id​
       실제 실행 결과를 보면 limit이 적용되지 않은 것을 확인할 수 있다. 그래서 comment가 6개가 조회 된 것이다.
      사실 limit은 from 절에 걸린 entity(위 예제의 atricle)를 limit 하기 때문에 comment는 풀스캔 한 결과 6개가 조회된 것이다.

      이런 경우에는 반대로 조회해야한다.
      public List<Comment> findCommentByArticleIdLimit5(Long id) {
              return queryFactory.selectFrom(comment)
                      .innerJoin(comment.article, article).fetchJoin()
                      .where(article.id.eq(id))
                      .limit(5)
                      .fetch();
      }​
      selectFrom에 article 대신 comment를 넣었다.
      이렇게 하면 위에서 나타났던 경고문구는 나타나지 않는다. fetch 결과가 Article 1개와 Comment 5개로 제한이 잘 걸려있기 때문에 예상 범위의 결과들을 메모리에 적재하는 일이 발생하지 않기 때문이다.


    4. QueryDSL을 사용시 개선 포인트

      위 예제와 같은 경우 조금 이상한 점이 느껴질 것이다.
      조회의 결과로 Article 값을 반환할 때 어떤 Comment의 값이든 동일한 Article의 값을 가지고 있게 된다. 예를들면 comment.get(0).getArticle() 과 comment.get(1).getArticle() 이 같다는 말이다. 만약 comment가 100만개라고 가정하면 필요없는 데이터는 제외시킬 수록 좋을 것이다. 이런 경우에는 DTO에 article 정보를 담아주면 비용을 절약할 수 있고 Fetch Join을 사용하지 않아도 된다.

      public ArticleComments findArticleWithTop5Comments(Long articleId) {
              return queryFactory.from(comment)
                      .innerJoin(comment.article, article)
                      .where(article.id.eq(articleId))
                      .limit(5)
                      .transform(
                              groupBy(comment.article.id)
                                      .list(new QArticleComments(article.contents, list(comment.contents)))
                      ).get(0);
      }​
      selectFrom 대신 from을 쓰고 fetchJoin도 쓰지 않는다. 그리고 엔티티를 반환하지 않고 Dto를 바로 만들어서 반환한다.

4-2. Fetch Join을 마무리하며 돌발 질문.

fetch join 과 pagination을 같이 사용하면 어떻게 되는지 답변해보세요
: Fetch Join을 쓰면 JPA만으로는 pagination을 할 수 없다. pagination을 구현하기 위해서 우리는 JPQL이나 QueryDSL이라는 두가지 선택을 할 수 있는데, JPQL을 사용하려면 setFirstResult와 setMaxResults를 쓸 수 있다. QueryDSL을 쓰면 limit, offset을 사용할 수 있다.


결론

JPA는 객체지향을 이룰 수 있는 좋은 ORM이다.

N+1 문제는 해결 가능한 문제이다.

- 기본적으로 FetchType은 Lazy로 설정한다.
- manyToOne 일때는 Fetch Join을 사용한다.

- oneToMany는 SubSelect를 사용한다.

- manyToMany는 성능테스트를 해보고 비교해야한다. 
- 성능 향상을 위해서 필요한 정보만 DTO 형식으로 가져오도록 한다.

 

 

참고

JPA : https://henrybook.tistory.com/m/1

ORM : https://geonlee.tistory.com/207

N+1 : https://skagh.tistory.com/m/39

Fetch Join : https://tecoble.techcourse.co.kr/post/2020-10-21-jpa-fetch-join-paging/

Subselect : https://yearnlune.github.io/java/java-jpa-n1/#subselect 

 

 

감사합니다.