Java/Spring & Spring Boot

DB N+1 문제해결(JPQL의 join fetch 사용)

마손리 2023. 4. 29. 05:46

N+1문제란 DB에서 어떠한 데이터를 불러올때 해당 엔티티의 연관관계(N)만큼 쿼리가 추가 발생하는 문제이다. 

 

이러한 문제를 JPQL의 join fetch를 사용하여 발생하는 쿼리의 수를 줄일수가 있다.

 

 

테스트를 위한 세팅

@Profile("n-plus-one-problem")
@Configuration
public class NPlusOneProblemConfig {
    private EntityManager em;
    private EntityTransaction tx;

    @Bean
    public CommandLineRunner testJpaFetchStrategyRunner(EntityManagerFactory emFactory) {
        this.em = emFactory.createEntityManager();
        this.tx = em.getTransaction();

        return args -> {
            creatingTestRows();
        };
    }
    private void creatingTestRows(){
        tx.begin();
        Member member = new Member("회원1");
        Member member2 = new Member("회원2");
        Orders orders1 = new Orders();
        Orders orders2 = new Orders();
        Orders orders3 = new Orders();
        member.addOrder(orders1);
        member.addOrder(orders2);
        member2.addOrder(orders3);

        orders1.addMember(member);
        orders2.addMember(member);
        orders3.addMember(member2);

        em.persist(member);
        em.persist(member2);
        em.persist(orders1);
        em.persist(orders2);
        em.persist(orders3);
        tx.commit();

        em.clear(); //실험을 위해 persistence context에서 생성된 엔티티들을 지워줌
    }
}

테스트를 위해 2명의 회원을 생성해주고 회원1은 2개의 주문을, 회원2는 1개의 주문을 가지게 연관관계를 맺어주었다.

 

 

N+1문제 발생

@Profile("n-plus-one-problem")
@Configuration
public class NPlusOneProblemConfig {
    private EntityManager em;
    private EntityTransaction tx;

    @Bean
    public CommandLineRunner testJpaFetchStrategyRunner(EntityManagerFactory emFactory) {
        this.em = emFactory.createEntityManager();
        this.tx = em.getTransaction();
        
        return args -> {
            creatingTestRows();
            problemExample();
        };
    }
    
    private void problemExample() {
        TypedQuery<Member> query =
        em.createQuery("SELECT m FROM Member m", Member.class); // 1.

        List<Member> findMembers = query.getResultList(); // 2.
		System.out.println();
        
        // 3.
        findMembers.stream().forEach(member->{
            System.out.println(member.getName()+"의 오더의 갯수: " + member.getOrders().size());
            System.out.println();
        });
    }
    ...
    ...
}
  1. EntityManager.createQuery() 메서드를 이용하여 JPQL 쿼리문과 매핑할 클래스를 파라미터로 넣어준다.
    쿼리문에서 'm'은 Alias(별칭)으로 해당 쿼리문에서는 Member를 뜻한다.
  2. 작성한 쿼리문(회원정보 조회)을 실행한다.
  3. DB에서 받아온 회원들과 연관된 주문정보를 조회한다.

 

결과를 보면 우리가 작성한 쿼리문은 분명 하나인데 3개의 쿼리문이 실행되었다. 

 

첫번째 쿼리는 우리가 작성한 쿼리인 회원을 조회하고있다.

이후 두번째, 세번째의경우 받은 회원정보들로 반복문을 돌면서 해당 회원이 가진 주문을 조회하는 걸 알수있다.

 

이렇게 연관관계 정보를 조회하기위해 예상치 못한 추가 쿼리가 발생하는 것을 n+1문제라 한다.

 

여기서 신기했던것은 두번째와 세번째 쿼리발생의 시점이다. 두번째 쿼리가 발생하고 정보를 출력하고 세번째 쿼리가 발생했다. 

이것은 회원과 주문 엔티티간의 관계가 OneToMany이기 때문에 fetch전략이 Lazy로 설정되어서 그렇다. 이것을 Eager로 바꿔주면 결과는 아래와 같다.

fetch 전략을 Eager로 바꿔주니 쿼리의 발생시점이 달라젔지만 여전히 3번의 쿼리를 발생시켰다.

 

 

 

JPQL문의 JOIN FETCH 사용

    private void joinFetchUse(){
        TypedQuery<Member> query =
             em.createQuery("SELECT m FROM Member m "+
                     "JOIN FETCH m.orders o", Member.class); // JOIN FETCH 적용

        List<Member> findMembers = query.getResultList();

        findMembers.stream().forEach(member->{
            System.out.println(member.getName()+"의 오더의 갯수: " + member.getOrders().size());
        });
    }

위와 같이 쿼리문에 Join Fetch를 추가해 주었다.

 

결과를 보면 우리가 작성한 쿼리문에 inner join이 추가되어 한번의 쿼리 요청으로 연관된 정보를 모두 받아올 수 있었다. 

 

하지만 여기서 중복데이터가 출력되는 문제가 발생했다. 

그이유는 회원1의 경우 2개의 주문을 가지고 있었기에 가지고 있는 주문만큼 카운트되어 출력이 되었다. 

 

TypedQuery<Member> query =
        em.createQuery("SELECT DISTINCT m FROM Member m JOIN FETCH m.orders o", Member.class);

 

이 문제는 위와 같이 쿼리문에 DISTINCT를 추가해주면 중복된 데이터를 없애준다.

 

 

일반 JOIN과 JOIN FETCH의 차이

Join Fetch를 사용하지않고 그냥 Join을 사용하면 처음의 결과처럼 3개의 쿼리를 발생시킨다. 

Join Fetch의 경우 SQL쿼리문의 Inner Join처럼 메인의 대상과 연결된 대상의 정보까지 병합하여 결과물을 만들어 낸다. 

 

하지만 일반 Join의 경우 메인 대상의 정보만 결과물에 담을뿐 그저 특정한 대상을 찾기위한 조건에 지나지 않다. 

 

보여지는 것과 같이 두개의 쿼리문 모두 inner join을 사용하여 주문과 연관이 있는 회원이라는 공통된 조건으로 검색을 하지만 찾아 불러오는 정보가 다르다.

 

 

Spring Data JPA

public interface MemberRepository extends JpaRepository<Member,Long> {
    @Query("SELECT DISTINCT m FROM Member m JOIN FETCH m.orders o WHERE m.memberId = :memberId")
    public List<Member> findMemberAndOrder(Long memberId);
}

Spring Data JPA에서는 사용이 훨씬 간편하다.

위와 같이 JpaRepository를 상속받는 인터페이스를 만든다음 JPQL문을 작성하고 필요한곳에서 사용해주면 된다.  

 

 

(예제코드 : https://github.com/Mason3144/NPlusOneProblemSolution )