N+1 문제 다양한 해결법

Mugeon Kim·2023년 11월 26일
0
post-thumbnail

서론


  • JPA를 학습하면 무조건 듣는 키워드는 N+1 이다. 보통 블로그에서 소개하는 방식은 fetch join을 통하여 문제를 해결한다고 이야기한다.
  • 물론 틀린 방식은 아니다. 하지만 실제 프로젝트를 만들면서 N+1 문제를 많이 만나보면서 N+1을 처리하는 방식은 여러가지가 있다.
  • 상황에 따라서 N+1 문제를 처리하는 방식을 적절하게 해결하는게 성능 저하의 문제에 대응할 수 있다고 생각했다.

글을 시작하기 이전에 간단하게 정리하면
1:1 연관관계 : Fetch join
Collection 연관관계 : default_batch_fetch_size
N개의 컬렉션을 fetch join을 하면 MultipleBagFetchException이 발생한다.
특정 컬럼을 조회할 경우에 join을 하고 Projection을 Dto로 매핑을 한다.

본론


1. 왜 N+1 문제가 발생을 하나요?

  • JPA를 처음 학습하면 김영한님 강의를 보면 처음에 나오는건 관계형 데이터베이스와 객체지향 언어간의 패러다임 차이를 이야기한다. JPA에서 연관관계를 맺으면 레퍼런스를 통하여 관계가 있는 객체에 접근할 수 있다. 하지만 관계형 데이터베이스는 Select를 통해야지만 접근을 한다.

1-1.간단한 엔티티 코드

@Getter
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor(access = AccessLevel.PROTECTED)
@Table(name = "MEMBER", uniqueConstraints = {
        @UniqueConstraint(name = "MEMBER_EMAIL", columnNames = {"email"}),
})
public class Member extends BaseEntity{
	@OneToMany(mappedBy = "member",fetch = FetchType.EAGER)
    private List<Request> requests = new ArrayList<>();

}
------------------------------------------------------------------------------------
@Getter
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor(access = AccessLevel.PROTECTED)
public class Request {
    @ManyToOne
    @JoinColumn(name = "member_id")
    private Member member;
}
  • Fetch Type은 기본적으로 ToMany에서는 Lazy, ToOne은 Eager로 설정이 되어져 있습니다.
  • 일단 현재 엔티티를 살펴보면 회원(1) : 게시판(N)으로 되어져 있습니다. 그러면 이제 N+1을 발생을 시켜서 문제를 살펴보겠습니다.

2. N+1 문제

2-1. Eager에서 N+1 문제

    @BeforeEach
    void setUp(){
        List<Request> requestArrayList = new ArrayList<>();
        IntStream.range(0, 10)
                .mapToObj(i -> new Request("title" + i))
                .forEach(requestArrayList::add);
        
        requestRepository.saveAll(requestArrayList);

        List<Member> memberArrayList = new ArrayList<>();
        
        IntStream.range(0, 10)
                .mapToObj(i -> Member.builder()
                        .name("member" + i)
                        .requests(requestArrayList)
                        .build())
                .forEach(memberArrayList::add);
        memberRepository.saveAll(memberArrayList);

        entityManager.clear();
    }

    @Test
    @Transactional
    public void fix() throws Exception {
        System.out.println("===============================================================");
        List<Member> memberList = memberRepository.findAll();
        System.out.println("===============================================================");

        assertThat(memberList.size()).isNull();
    }
  • 간단하게 테스트 코드를 작성을 했다. 위에 @BeforeEach를 통하여 기본적으로 데이터를 세팅하고 10개의 회원을 전체를 조회하는 코드를 만들었다. 이것을 실행하면 다음과 같이 발생한다.
===============================================================
- 회원 조회
Hibernate: select m1_0.id,m1_0.name from member_table m1_0

- Request 조회
Hibernate: select r1_0.member_id,r1_0.id,r1_0.title from request r1_0 where r1_0.member_id=?
Hibernate: select r1_0.member_id,r1_0.id,r1_0.title from request r1_0 where r1_0.member_id=?
Hibernate: select r1_0.member_id,r1_0.id,r1_0.title from request r1_0 where r1_0.member_id=?
Hibernate: select r1_0.member_id,r1_0.id,r1_0.title from request r1_0 where r1_0.member_id=?
Hibernate: select r1_0.member_id,r1_0.id,r1_0.title from request r1_0 where r1_0.member_id=?
Hibernate: select r1_0.member_id,r1_0.id,r1_0.title from request r1_0 where r1_0.member_id=?
Hibernate: select r1_0.member_id,r1_0.id,r1_0.title from request r1_0 where r1_0.member_id=?
Hibernate: select r1_0.member_id,r1_0.id,r1_0.title from request r1_0 where r1_0.member_id=?
Hibernate: select r1_0.member_id,r1_0.id,r1_0.title from request r1_0 where r1_0.member_id=?
Hibernate: select r1_0.member_id,r1_0.id,r1_0.title from request r1_0 where r1_0.member_id=?
===============================================================
  • 바로 N+1 문제가 발생했다. 처음 회원을 조회하고 관련 Request를 10번 조회하는 쿼리가 나간다.

2-2. Lazy에서 N+1문제

  • 회원의 Fetch Type을 Lazy로 변경하고 똑같이 테스트를 진행하면 한번만 발생한다.
===============================================================
Hibernate: select m1_0.id,m1_0.name from member_table m1_0
===============================================================
  • 그러면 N+1 문제는 해결이 되었는가? 아직은 아니다. 다른 테스트 코드를 실행을 해보겠다.
    @Test
    @Transactional
    public void fix() throws Exception {
        System.out.println("===============================================================");
        memberRepository.findAll().stream().flatMap(member -> member.getRequests().stream()
                        .map(Request::getTitle))
                .collect(Collectors.toList());
        System.out.println("===============================================================");

        assertThat(memberRepository.findAll().size()).isNull();
    }
===============================================================
Hibernate: select m1_0.id,m1_0.name from member_table m1_0
Hibernate: select r1_0.member_id,r1_0.id,r1_0.title from request r1_0 where r1_0.member_id=?
Hibernate: select r1_0.member_id,r1_0.id,r1_0.title from request r1_0 where r1_0.member_id=?
Hibernate: select r1_0.member_id,r1_0.id,r1_0.title from request r1_0 where r1_0.member_id=?
Hibernate: select r1_0.member_id,r1_0.id,r1_0.title from request r1_0 where r1_0.member_id=?
Hibernate: select r1_0.member_id,r1_0.id,r1_0.title from request r1_0 where r1_0.member_id=?
Hibernate: select r1_0.member_id,r1_0.id,r1_0.title from request r1_0 where r1_0.member_id=?
Hibernate: select r1_0.member_id,r1_0.id,r1_0.title from request r1_0 where r1_0.member_id=?
Hibernate: select r1_0.member_id,r1_0.id,r1_0.title from request r1_0 where r1_0.member_id=?
Hibernate: select r1_0.member_id,r1_0.id,r1_0.title from request r1_0 where r1_0.member_id=?
Hibernate: select r1_0.member_id,r1_0.id,r1_0.title from request r1_0 where r1_0.member_id=?
===============================================================

정리
Eager를 사용하든 Lazy를 사용하든 결국 동일하게 발생한다. Lazy를 사용하면 단지 프록시 객체로 가져오기 때문에 N+1이 데이터 사용하는 시점으로 미루는 것이지 해결하는 것은 아니다.

N+1 발생하는 이유
jpaRepository에 정의한 인터페이스 메서드를 실행하면 JPA는 메서드 이름을 분석해서 JPQL을 생성하여 실행하게 된다. JPQL은 SQL을 추상화한 객체지향 쿼리 언어로서 특정 SQL에 종속되지 않고 엔티티 객체와 필드 이름을 가지고 쿼리를 한다.


3. N+1 문제 해결

3-1. Fetch Join + Lazy Loading

  • 보통 JPA를 처음 사용하면 N+1 문제를 해결하는 방식을 Fetch Join을 사용해서 해결한다. 하지만 만능은 아니다. 일단 간단한 코드를 보고 장단점에 대해서 설명을 하겠다.

  • 일단 기존의 코드를 기반으로 문제를 해결하겠다. 위에 Lazy를 처리하여도 N+1 문제가 발생을 하였다. 이걸 Lazy Loading, Fetch Join을 통하여 해결하면 다음과 같다.


  • Repository
    @Query("select distinct m from Member m join fetch m.requests")
    List<Member>findAllRelatedRequest();
  • 테스트 코드
    @Test
    @Transactional
    public void fix() throws Exception {
        System.out.println("===============================================================");
        memberRepository.findAllRelatedRequest().stream().flatMap(member -> member.getRequests().stream()
                        .map(Request::getTitle))
                .collect(Collectors.toList());
        System.out.println("===============================================================");

        assertThat(memberRepository.findAll().size()).isNull();
    }
  • 기존에 findAll에서 새롭게 만든 findAllRelatedRequest로 변경을 하였다. 이렇게 변경을 하니 기존에 N+1 문제가 발생한 쿼리에서 한방 쿼리로 변경이 되었다.
===============================================================
Hibernate: select distinct m1_0.id,m1_0.name,r1_0.member_id,r1_0.id,r1_0.title 
from member_table m1_0 
join request r1_0 on m1_0.id=r1_0.member_id
===============================================================

Fetch Join에 대한 한계

1. Fetch join과 일반 Join의 차이 ( 패러다임 불일치 줄여줌 )

N+1 문제를 fetch join으로 해결할 수 없다. 일단 fetch join에 대해서 설명하자면 우리가 알고 있는 join과 차이가 있다.

  • fetch join은 orm에서 사용하며 디비 스키마를 엔티티로 자동 변환 > 영속성 컨텍스트에 영속화를 해준다.

  • 이러한 특징 덕분에 fetch join을 해서 가져온 연관 관계가 있는 1차 캐시에 저장이 되고 다시 조회를 하여도 쿼리를 수행하지 않는다.

  • 하지만 일반 join쿼리는 단순히 sql에서 데이터를 조회하는 개념이기 때문에 영속성 컨텍스트와 관련이 없다. 이것이 패러다임의 차이이며 fetch join은 이를 줄여주는 역활을 한다.


2. Collection 연관관계 Fetch Join시 데이터 뻥튀기 (Distinct 추가)

  • 위에 그림을 보면 1:n 관계가 되어져 있다. 위에 사진처럼 데이터를 중복 되어 존재함입니다. 이 때문에 fetch join을 하면 n개 만큼 관계가 생성된다.
  • 이 때문에 distinct절을 활용을 해야됩니다.
  • 그러면 여거서 고민할 부분이 있다.

SQL Distinct / JPQL Distinct 차이

  • SQL에서 Distinct절은 DB에서 수행되며 JOIN 발생한 데이터 형태에서 각 ROW를 비교하여 다른 경우만 남긴다. 하지만 JPA의 Distinct는 엔티티객체에 대해서 Distinct를 수행을 합니다.

3. N개 컬렉션 Fetch Join시 MultipleBagFetchException

  • 처음에 컬렉션을 2개를 FETCH JOIN을 하면 이 오류가 발생한다.

  • 즉 하나만 FETCH JOIN을 해야합니다.

  • 테스트를 위해서 엔티티를 하나 추가를 시키고 오류를 살펴보겠다.

    @OneToMany(mappedBy = "member",fetch = FetchType.LAZY)
    private List<Request> requests = new ArrayList<>();

    @OneToMany(mappedBy = "member",fetch = FetchType.LAZY)
    private List<Notice> notices = new ArrayList<>();
    
    @Query("select distinct m from Member m join fetch m.requests join fetch m.notices")
    List<Member>findAllRelatedRequest();
  • 이렇게 관계를 맺고 컬렉션 2개를 fetch join을 하게되면 다음과 같은 오류가 발생한다.
org.springframework.dao.InvalidDataAccessApiUsageException: org.hibernate.loader.MultipleBagFetchException: cannot simultaneously fetch multiple bags: [com.example.localcache.domain.Member.notices, com.example.localcache.domain.Member.requests]

	at org.springframework.orm.jpa.EntityManagerFactoryUtils.convertJpaAccessExceptionIfPossible(EntityManagerFactoryUtils.java:371)
	at org.springframework.orm.jpa.vendor.HibernateJpaDialect.translateExceptionIfPossible(HibernateJpaDialect.java:246)
  • 그러면 이렇게 컬렉션을 어떻게 문제를 해결을 해야되는가?? 이것은 batch size로 해결하면 된다. 밑에서 살펴보면 된다.

4. 페이징 제한(Out Of Memory)

  • fetch join을 하여 가져온 데이터를 페이징을 처리하면 다음과 같은 오류가 발생한다.
  • 왜냐하면 쿼리 수행한 결과를 모두 어플리케이션 메모리에 올려서 페이징 처리를 수행을 했기 때문이다. 만약에 만건을 가져오면 어플리케이션에 올리게 되면 메모리 문제가 발생을 합니다.
2022-01-16 12:37:18.309  WARN 39536 --- [           main] o.h.h.internal.ast.QueryTranslatorImpl   : HHH000104: firstResult/maxResults specified with collection fetch; applying in memory!

3-2. default_batch_fetch_size, @BatchSize

  • Lazy Loading시 프록시 객체를 조회할 때 where in절로 묶어서 한번에 조회 할 수 있게 해주는 옵션입니다. yml에 전역 옵션으로 적용할 수 있고 @BatchSize를 통해 연관관계 BatchSize를 다르게 적용할 수 있습니다.

batchsize는 몇으로 적용을 해야되나요??
일반적으로 100~1000으로 설정을 합니다. 하지만 dbms에 따라서 where in절은 1000까지 제한하는 경우가 있기 때문에 1000이상은 설정하지 않는다. 그렇다고 너무 크게하면 was에서 메모리에 로딩하디에 오버헤드가 발생하기 때문에 서비스에 맞게 적절하게 설정을 해야합니다.

  jpa:
    hibernate:
      ddl-auto: create
    properties:
      hibernate:
        show_sql: true
        default_batch_fetch_size: 100
  
    @BatchSize(size = 10)
    @OneToMany(mappedBy = "member",fetch = FetchType.LAZY)
    private List<Request> requests = new ArrayList<>();
===============================================================
Hibernate: select m1_0.id,m1_0.name from member_table m1_0
Hibernate: select r1_0.member_id,r1_0.id,r1_0.title from request r1_0 where r1_0.member_id in (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
===============================================================
Hibernate: select m1_0.id,m1_0.name from member_table m1_0

Fetch join의 한계를 Batch Size로 해결

  • 컬렉션 fetch join시 paging 문제나 여러개 컬렉션을 fetch join을 할 수 없는 문제를 해결합니다.
  • 쿼리 수로는 fetch join이 한방으로 나가기 때문에 유리하다. 하지만 batch size는 in절을 하고 사이즈에 따라서 몇번의 쿼리가 발생할 수 있다.
  • 데이터 전송량 관점에서는 Batch Size가 유리합니다. Fetch Join은 Join을 하고 나서 가져오기 때문에 중복 데이터를 많이 가져와야하기 때문입니다.

결론
1. 컬렉션 n개 조회 : batch size 해결
2. 쿼리 개수 : fetch join이 1개의 쿼리로 해결 > batch size는 in절 + 추가적인 쿼리 ( 최적화)
3. 데이터 전송 : fetch join은 join을 하고 중복 데이터를 많이 가져옴 batch size가 유리


3-3. @EntityGraph

  • EntityGraph는 어노테이션 방식으로 편하게 N+1 문제를 해결할 수 있다. 하지만 여기서 trade off가 발생한다. 사실은 Lazy Loading을 Eager Loading으로 부분적으로 전환하는 기능입니다.

  • 여러 1:N 연관관계를 한번에 Join해 올 수 있습니다. FetchJoin의 경우 1개의 Collection까지만 같이 Join하여 조회할 수 있습니다.

    @EntityGraph(attributePaths = {"requests"})
    @Query("select o from Member o")
    List<Member>findAllEntityGraph();
  • 여기서 fetch join과 차이점은 EntityGraph는 left outer join으로 가져오고 컬렉션 fetch join을 해결할 수 있지만 중복적인 데이터 처리를 주의를 해야된다. 이를 처리하기 위해 컬렉션의 자료구조를 set 또는 jpql에서 distinct 처리가 필요하다.

3-4. join연산 > Projection하여 특정 컬럼만 Dto로 조회

select new 패키지 경로.Dto(원하는 필드) 
from Member m
join m.request r
where m.id=r.id
  • 장점으로는 많은 컬럼에서 projection하여 특정 컬럼만 조회를 할 수 있다. 커버링 인덱스로 처리될 수 있기 때문에 성능적인 이점을 가져올 수 있다.
  • 하지만 단점으로는 영속성 컨테스트와 무관하게 동작하고 repository가 dto에 의존하기 때문에 dao 변경이 필요하다.

결론


글을 시작하기 이전에 간단하게 정리하면
1:1 연관관계 : Fetch join
Collection 연관관계 : default_batch_fetch_size
N개의 컬렉션을 fetch join을 하면 MultipleBagFetchException이 발생한다.
특정 컬럼을 조회할 경우에 join을 하고 Projection을 Dto로 매핑을 한다.

참고


https://junhyunny.github.io/spring-boot/jpa/jpa-fetch-join-paging-problem/

https://incheol-jung.gitbook.io/docs/q-and-a/spring/n+1

profile
빠르게 실패하고 자세하게 학습하기

0개의 댓글