JPA N+1 문제

강민욱·2024년 2월 26일

JPA

목록 보기
1/1

1. 목적

처음 구독하던 곳에서 받을 메일중에 link 읽던중 관련된 것들을 정리할 필요성이 있어서 정리하게 되었다.

2.FetchType이란

말그대로 fetch 방법을 결정하는 속성인데 EAGER, LAZY 두가지가 있다.
EAGER는 '열렬한' 단어 뜻 그대로 조회시 연관된 객체도 함께 Fetch하는 것이고
LAZY는 '게으른' 단어 뜻 그대로 조회시 연관된 객체는 함께 Fetch 하지 않는다.

※Fetch DB의 정보가 영속성 컨텍스트(Persistence Context)에 저장되는 것(Managed 상태)을 Fetch라 이해함 (잘못 알았을 경우 수정)

기본적으로 @XXXToOne(@ManyToOne, @OneToOne)에는 FetchType.EAGER가 @XXXToMany(@ManyToMany, @OneToMany)에는 FetchType.LAZY 디폴트로 설정된다.

3. N + 1 확인 default

ERD

확인을 하기위한 ERD이다. member마다 team_id를 이용하여 team을 참조하도록 했다.

Entity

Member 엔티티

public class Member {

    @Id
    Long memberId;

    String memberName;

    @ManyToOne(fetch속성을 명시하겠다
    
    @ManyToOne(fetch = FetchType.EAGER)
    @JoinColumn(name = "team_id")
    Team team;
}

위에서 적은대로 Team은 @ManyToOne이므로 디폴트값이 FetchType.EAGER이긴 하지만 이해를 위해 fetch속성을 명시하겠다.

Team 엔티티

public class Team {

    @Id
    Long teamId;

    String teamName;

    @OneToMany(mappedBy = "team", fetch = FetchType.LAZY)
    List<Member> memberList;
}

memberList도 마찬가지 @OneToMany이므로 FetchType.LAZY이다.

테스트 진행

@DataJpaTest를 이용하여 진행하였다

select m1_0.member_id,m1_0.member_name,m1_0.team_id from tb_member m1_0
member 엔티티를 findall 했으므로 당연히 실행돼야 한다.

select t1_0.team_id,t1_0.team_name from tb_team t1_0 where t1_0.team_id=?
member의 team_id를 조회하기 위한 쿼리를 한번 더 수행한다.

4. N + 1 확인 default @OneToMany FetchType.EAGER

public class Team {
    @OneToMany(mappedBy = "team", fetch = FetchType.EAGER)
    List<Member> memberList;
}

해당 부분이다. EAGER로 변경해본다.

테스트진행


select m1_0.member_id,m1_0.member_name,m1_0.team_id from tb_member m1_0는 당연히 실행된다.

하지만 밑에 줄을 보면
select t1_0.team_id,t1_0.team_name,ml1_0.team_id,ml1_0.member_id,ml1_0.member_name from tb_team t1_0 left join tb_member ml1_0 on t1_0.team_id=ml1_0.team_id where t1_0.team_id=?
팀마다 속하는 member를 구하기 위해 join을 더 수행한다. 팀원, 팀이 많다면 정말 낭비가 아닐까 한다.

5. 해결방법

(1) FetchType.LAZY 사용

@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "team_id")
Team team;
}

해당부분이다. FetchType.LAZY로 변경한다.

FetchType.LAZY 테스트진행


select m1_0.member_id,m1_0.member_name,m1_0.team_id from tb_member m1_0 한번만 실행된다.

(2) Fetch join 사용

@Query("select m from Member m join fetch m.team")
List<Member> findAllFetchJoin();

위와 같이 메소드를 분리해놓고 해당 메소트 호출시에만 이용하는 것이다.

Fetch join 테스트진행


한번의 쿼리 수행으로 Team 정보를 얻을 수 있다.

(3) BatchSize

@BatchSize(size = 3)
@OneToMany(mappedBy = "team", fetch = FetchType.LAZY)
List<Member> memberList;

위와같이 BatchSize를 3으로 설정해보았다.

for (int i = 0; i < 2; i++) {
	Team teamRow = teamList.get(i);
    teamRow.getMemberList().forEach(member -> {
    	System.out.println("선수이름 : "+member.getMemberName() + "  소속팀 : " +member.getTeam().getTeamName());
    });
}

test에서 팀을 2개만 Memberlist를 찍도록 했다.

log를 살펴보면 array [1, 2, 3]이 파라미터로 binding됐다. 이렇게 해서 N+1을 방지하자는 뜻인데

//@BatchSize(size = 3)
@OneToMany(mappedBy = "team", fetch = FetchType.LAZY)
List<Member> memberList;

@BatchSize없이 실행해보면

그냥 1 ,2개의 memberlist만 select해서 fetch시키므로 n+1을 걱정하진 않아도 될 거 같다.
그래서 @BatchSize 미리 설정을 함으로써 방지하는 역할이 아닐까 하지만 FetchType.EAGER를 사용하면 @BatchSize상관없이 전체를 fetch 해버린다. 심지어 난 2개의 memberlist만 출력했지만 @BatchSize가 미리 3으로 설정돼있어서 필요없는 3도 같이 fetch된 것도 있다.

추후 더 알아내는 것이 있으면 수정하겠다.

6. 결론

데이터 양이 적다면 N+1은 큰문제가 아닐수도 있다, 하지만 필요없이 불러와지는 데이터가 크다면 이는 분명히 낭비이다.

profile
백엔드 개발자

0개의 댓글