연관관계(Mapping) - N+1문제

대영·2024년 1월 15일
3

Spring

목록 보기
8/15

🙏내용에 대한 피드백은 언제나 환영입니다!!🙏

앞 글에서 즉시로딩(EAGER), 지연로딩(LAZY)에 대해서 알아보았다.
이제, 이와 관련된 N+1문제에 대해서 알아보겠다.
아래 실행 코드는 이 글에 적힌 코드이다.

📌N+1문제

지연로딩(LAZY)을 사용하더라도 해결이 되지 않는 것이 있다.
코드와 결과부터 먼저 보겠다.

		for(int i = 0; i < 10; i++) {
        // 10개를 DB에 저장. 1개의 팀당 1명의 멤버 저장.
            Team team = new Team("팀" + i);
            teamRepository.save(team);

            Member member = new Member("멤버" + i);
            member.setTeam(team);
            memberRepository.save(member);
        }

먼저 위와 같이 1개의 Team에 1명 Member를 저장. 총 10번을 하였다.

그리고,

		List<Member> members= memberRepository.findAll();
        for( Member member : members) {
            System.out.println("<< " + member.getTeam().getName() + "에 접근 >>");
        }

위와 같은 코드를 작성하여 쿼리를 날려보았다.

Hibernate: 		// memberRepository.findAll()에 대한 쿼리.
    select
        m1_0.id,
        m1_0.name,
        m1_0.team_id 
    from
        member m1_0
Hibernate: 		// 여기부터는 Team 객체에 접근하는 쿼리.
    select
        t1_0.id,
        t1_0.name 
    from
        team t1_0 
    where
        t1_0.id=?
<<0에 접근 >>        
        .
        .		(10)
        .
        
Hibernate: 
    select
        t1_0.id,
        t1_0.name 
    from
        team t1_0 
    where
        t1_0.id=?
<<9에 접근 >>  

위와 같은 결과가 나왔다. 결과가 길어 중간은 삭제하였지만, 10번의 Team 객체를 불러오는 쿼리가 날린 것이다.
최초에 Member 목록을 가져오는 것 1번. member.getTeam().getName(); 코드로 인해 Team에 접근하는 쿼리로 인하여 10번. 총 11번의 쿼리가 발생하였다.

이것이 데이터의 수 N개인. N+1문제이다.
만약 데이터의 수가 100개라면 101 쿼리가 발생할 것이다.

👉해결법 1: fetch join + 지연로딩(LAZY)

fetch join은 Entity를 조회 할 때, 지연로딩(LAZY) 매핑되어 있는 것에 join쿼리를 발생시켜 한번에 조회할 수 있는 기능이다.

특징❓

먼저, 내용을 찾아보다가 다음과 같은 내용을 가져와 보았다.
<Fetch Join은 객체관계 매핑인 ORM에서의 사용을 전제로 DB Schema를 Entity로 자동 변환 해주고 영속성 컨텍스트에 영속화 해준다.
이 때문에 Fetch Join을 통해 조회 하면 연관 관계(ORM)는 영속성 컨텍스트 1차캐시에 저장되어 다시 엔티티를 탐색하더라도 조회 쿼리가 수행 되지 않는다.
위 내용 출처>

즉, 중복된 내용까지 전부 조회하게 된다.

예를 들어 Team1 + Member1 과 Team1 + Member1 은 같은 것이고, Team2 + Member1 과 Team2 + Member2는 다른 것이다.
그래서 데이터를 출력하면 Team2는 2번 호출되게 된다. (가지고 있는 객체 수만큼 호출)
(이 글에서의 예제는 중복되는 내용이 없다.)
이러한 문제를 해결하려면 Distinct를 사용하여야 한다.
Distinct는 DB에서 실행되며, 다른 내용인 것만 남기고 걸러준다.

두번째로, 두 개이상의 컬렉션을 fetch join을 할 수 없다.

경우로는

  • 클래스 내부에 일대다 관계에 대한 필드가 2개인 경우.

  • 클래스 내부에 일대다 관계에 대한 필드가 1개고, 그 필드의 객체 내부에 일대다 관계에 있는 필드가 존재하는 경우.

이러한 상황에서는 fetch join 사용 불가능하다.

그리고 마지막으로, Paging이 불가능하다.

앞서 Paging에 대해서 다룬 내용이 없기에 간단히 언급하자면, 대량의 데이터를 나누어서 가져오는 것이다. 사용자가 원하는 만큼의 데이터를 가져와 보여주는 것이다.

위에서 말했듯이, Fetch Join이 영속성 컨텍스트에 엔티티를 저장한다. 하지만, Paging을 하게되면, 직접 SQL을 작성하여 DB 테이블에 조인하는데, 실제 DB 레코드의 관계와 일치하지 않을 수 있다.(누락되거나 할 수 있음.)
그래서, 페이징을 통해서 결과를 가져올 때 오류가 발생하게 된다.

❕다시 돌아와서

public interface MemberRepository extends JpaRepository<Member, Long> {
    @Query("select m from Member m join fetch m.team")
    List<Member> findAll();
}

fetch join을 사용하려면 위와 같이 Repository에 설정해줘야 한다.

이것을 실행한 후 결과는 아래와 같다.

Hibernate: 
    select
        m1_0.id,
        m1_0.name,
        m1_0.team_id,
        t1_0.id,
        t1_0.name 
    from
        member m1_0 
    join
        team t1_0 
            on t1_0.id=m1_0.team_id
팀0에 접근
팀1에 접근
팀2에 접근
팀3에 접근
팀4에 접근
팀5에 접근
팀6에 접근
팀7에 접근
팀8에 접근
팀9에 접근

결과에서 볼 수 있듯이, 매핑되어 있던 Team Entity또한 한번에 실행되는 것을 볼 수 있다.

👉해결법 2: default_batch_fetch_size || @BatchSize

지연로딩(LAZY)시 프록시 객체를 조회할 때, 한 번에 조회할 수 있도록 where ~ in절로 묶어서 조회하는 옵션이다.

전역 구간에 적용시키고 싶다면,
application.yml은 아래와 같이,

spring:
  jpa:
    properties:
        default_batch_fetch_size: 10

application.properties에는 아래와 같이 적용시키면 된다.

spring.jpa.properties.hibernate.default_batch_fetch_size=10

만약, 전역구간에 적용시키는 것이 아닌, 특정 구간에만 적용시키거나, Batch Size를 다르게 하고 싶다면 @BatchSize 어노테이션을 사용하면 된다.

위에 예제를 기준으로 보면

List<Member> members= memberRepository.findAll();
        for( Member member : members) {
            System.out.println("<< " + member.getTeam().getName() + "에 접근 >>");
        }

Member을 통해 Team에 접근하므로,

@BatchSize(size = 10)
@Entity
public class Team{
	...
}

만약, Team을 통해 Member을 조회한다면,

	@BatchSize(size = 10)
    @OneToMany(mappedBy = "team", fetch = FetchType.LAZY)
    private List<Member> members = new ArrayList<>();

이렇게 설정하면 된다.

위 코드에서 보면 10이라고 적힌 것을 볼 수 있다. 이것은 Batch Size로, 얼마나 묶어서 조회를 할 것인지를 뜻한다. 숫자가 너무 크다면 DB에서 부담을 느끼기에 적절한 숫자를 택하는 것이 좋다. (보통 100 ~ 1000 으로 적용한다고 한다.)

결과를 쉽게 보기 위해 Batch Size를 10으로 하겠다.

Hibernate: 
    select
        m1_0.id,
        m1_0.name,
        m1_0.team_id 
    from
        member m1_0
Hibernate: 
    select
        t1_0.id,
        t1_0.name 
    from
        team t1_0 
    where
        t1_0.id in (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)0에 접근
팀1에 접근
팀2에 접근
팀3에 접근
팀4에 접근
팀5에 접근
팀6에 접근
팀7에 접근
팀8에 접근
팀9에 접근

위 결과에서

	where
        t1_0.id in (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)

이것을 보면 알 수 있듯이 Batch Size를 10으로 했기 때문에 10개를 묶어서 한 번에 조회를 하였다. 만약에, Batch Size를 5로한다면 아래와 같은 결과가 나올 것이다.

Hibernate: 
    select
        m1_0.id,
        m1_0.name,
        m1_0.team_id 
    from
        member m1_0
Hibernate: 
    select
        t1_0.id,
        t1_0.name 
    from
        team t1_0 
    where
        t1_0.id in (?, ?, ?, ?, ?)0에 접근
팀1에 접근
팀2에 접근
팀3에 접근
팀4에 접근
Hibernate: 
    select
        t1_0.id,
        t1_0.name 
    from
        team t1_0 
    where
        t1_0.id in (?, ?, ?, ?, ?)5에 접근
팀6에 접근
팀7에 접근
팀8에 접근
팀9에 접근

fetch join vs Batch size❓

fecth join의 장점
1. 쿼리를 날리는 갯수가 적다.

fecth join의 단점
1. 두 개이상의 컬렉션을 fetch join을 할 수 없다.
2. Paging문제 발생.

BatchSize의 장점
1. fetch의 단점들을 해결할 수 있다.
2. 데이터 전송량 관점에서 유리하다. (아래 참고)

BatchSize의 단점
1. BatchSize의 크기보다 큰 데이터가 들어있을 경우, fetch join보다 더 많은 쿼리를 날린다.

데이터 전송량 관점에서는 BatchSize가 유리하다. Fetch Join은 Join을 하고 나서 가져오기 때문에 중복 데이터를 많이 가져오기 때문이다.

추가적으로, N+1문제를 해결하는 다른 방법인 @EntityGraph가 있는데
이것은 fetch join과 유사하며 차이점이 있다. 그것에 대해서 글을 써보겠다.

💡마치며

프로그램을 만들 땐 성능이 중요하다.
처음에 공부를 할 땐, 성능을 생각하지 못하고 쿼리를 날리는 과정을 보지 않았다.
이 글을 작성하는 과정을 거치며 과정과 결과를 보며 성능에 대한 중요성을 다시 한 번 깨달은 것이다.
이 후에 개인 프로젝트를 통해서 N+1 문제가 발생하지 않도록 코드를 짜서 글을 작성해보겠다.

profile
Better than yesterday.

0개의 댓글