JPA | JPA N+1 문제 및 해결방안

N+1 문제란?

연관관계에서 발생하는 이슈로 연관관계가 설정된 엔티티를 조회할 경우에 조회된
데이터 갯수(N)만큼 연관관계의 조회 쿼리가 추가로 발생하며 데이터를 읽어오게 된다. 이를 N+1 문제라고 한다.

N+1 문제가 발생하는 이유?

jpa repository에 정의한 인터페이스 메서드를 실행하면 JPA는 메서드 이름을 분석해서 JPQL을 생성하여 실행한다.
JPQL은 SQL을 추상화한 객체지향 쿼리 언어로서 특정 SQL에 종속되지 않고 엔티티 객체와 필드 이름을 가지고 동작한다.
그렇기 때문에 JPQL은 findAll()이란 메서드를 수행하였을 때 해당 엔티티를 조회하는 쿼리만 실행하게 되는것이다.


select * from Team

JPQL 입장에서는 연관관계 데이터를 무시하고 해당 엔티티 기준으로 쿼리를 조회하기 때문이다.
그렇기 때문에 연관된 데이터가 필요한 경우, FetchType으로 지정한 시점에 조회를 별도로 호출하게 된다.

N+1 문제를 확인하기 위한 관계 코드

멤버와 팀의 관계로 팀은 여러명의 멤버로 구성될 수 있다.
멤버는 하나의 팀에 속해 있다.

Member.class

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38

package practice.practiceproject.domain;
 
import javax.persistence.Entity;
import javax.persistence.FetchType;
import javax.persistence.GeneratedValue;
import javax.persistence.Id;
import javax.persistence.JoinColumn;
import javax.persistence.ManyToOne;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
 
@Entity
@NoArgsConstructor
@Getter
public class Member {
    @Id
    @GeneratedValue
    private Long id;
 
    private String firstName;
    private String lastName;
 
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = “team_id”)
    private Team team;
 
    @Builder
    public Member(String firstName, String lastName, Team team) {
        this.firstName = firstName;
        this.lastName = lastName;
        this.team = team;
    }
 
    public void updateTeam(Team team) {
        this.team = team;
    }
}

cs

Team.class

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26

@Entity
@Getter
@NoArgsConstructor
public class Team {
    @Id
    @GeneratedValue
    private Long id;
 
    private String name;
 
    @OneToMany(mappedBy = “team”, cascade = CascadeType.ALL, fetch = FetchType.LAZY)
    private List<Member> members = new ArrayList<>();
 
    @Builder
    public Team(String name, List<Member> members) {
        this.name = name;
        if (members != null) {
            this.members = members;
        }
    }
 
    public void addMember(Member member) {
        this.members.add(member);
        member.updateTeam(this);
    }
}

cs

TestCode

팀을 10개를 생성했다.
한개의 팀당 2명의 멤버를 보유하고 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37

@ExtendWith(SpringExtension.class)
@SpringBootTest
class TeamServiceTest {
    @Autowired
    private TeamRepository teamRepository;
 
    @Autowired
    private TeamService teamService;
 
    @BeforeEach
    public void setUp() {
        teamRepository.deleteAll();
 
        List<Team> teams = new ArrayList<>();
        for (int i = 0; i < 10; i++) {
            Team team = Team.builder()
                    .name(“team” + i)
                    .build();
 
            team.addMember(Member.builder()
                    .firstName(“first” + i)
                    .build());
            team.addMember(Member.builder()
                    .lastName(“last” + i)
                    .build());
            teams.add(team);
        }
 
        teamRepository.saveAll(teams);
    }
 
    @Test
    void 팀_조회_N1_발생() {
        List<Team> teams = teamRepository.findAll();
        assertThat(teams.size()).isEqualTo(10);
    }
}

cs


결과는 아래에서 차차 알아보도록 하자.

FetchType이 EAGER 일때, N+1문제

  • 팀을 조회하는 쿼리를 호출했다.
  • 멤버를 조회하는 쿼리가 팀을 조회한 row만큼 쿼리가 호출된 것을 확인 할 수 있다.

jpa OneToMany관계에서 발생하는 N+1문제

FetchType.EAGER라서 발생한 것일까?

즉시로딩이라서 발생하는 문제는 아니다.
FetchType을 LAZY로 변경해서 호출해보자.
jpa FetchType이 LAZY이어도 N+1문제
쿼리가 한번밖에 호출되지 않았지만 LAZY로 설정되었기 때문에 연관관계 데이터를 프록시 객체로 바인딩한다는 것이다.
하지만 실제로 연관관계 엔티티를 프록시만으로는 사용하지 않는다.
연관관계 엔티티의 멤버변수를 사용하거나 가공하는 일은 코드를 구현하는 경우가 훨씬 흔하기 때문에 테스트 코드에서 연관관계 데이터를 사용하는 로직을 추가해보자.
jpa 테스트코드로 N+1문제 확인하기
팀이 보유하고 있는 멤버의 firstName을 추출해보면 FetchType을 변경하는 것은 N+1 발생 시점을 연관관계 데이터를 사용하는 시점으로 미룰지 아니면 초기 데이터 로드 시점에 가져오느냐의 차이만 있는 것이다.

해결방법에 대해서

우리가 원하는 코드는 select * from team left join member on team.id = member.team_id일 것이다.

JoinFetch

JPQL로 join fetch를 작성한다.


@Query("select a from Team a join fetch a.members")
List findAllJoinFetch();

jpa JPQL join fetch
쿼리는 한번 수행되었고 INNER JOIN으로 호출된 것을 확인할 수 있다.
(단점)
연관관계 설정해 놓은 FetchType을 사용하게 되면 데이터 호출 시점에 모든 연관관계의 데이터를 가져오기 때문에 FetchType을 LAZY로 설정해 놓은 것이 무의미해진다.
또한 페이징 쿼리를 사용할 수 없다.

EntityGraph

@EntityGraph의 attributePaths에 쿼리 수행시 바로 가져올 필드명을 지정하면 LAZY가 아닌 EAGER 조회로 가져오게 된다.
JoinFetch와 동일하게 JPQL을 사용하여 query문을 작성하고 필요한 연관관계를 EntityGraph에 설정하면 된다.


@EntityGraph(attributePaths = "members")
@Query("select a from Team a")
List findAllEntityGraph();

jpa EntityGraph attributePaths 쿼리 수행
쿼리는 한번 수행되었고 JoinFetch와 달리 Join문의 OUTER JOIN으로 실행되는 것을 볼 수 있다.


team1에 소속되어 있는 멤버가 2명일 때, TEAM과 MEMBER를 조인해서 가져오면 row가 2줄로 늘어나게 된다.
jpa 카다시안곱 테스트코드
따라서 Team을 조회했을 때, 등록된 10개의 Team만큼 조회 결과를 갖는것이 아니라 각 Team마다 등록된 Member 2명씩 10 x 2 = 20개의 조회 결과를 갖게 되는 것이다.
이 문제를 해결하기 위해선 2가지 방법이 있습니다.

  • 일대다 필드의 타입을 Set으로 선언해 중복을 허용하지 않는 것이다.
  • JPQL이 작성된 @Query에 DISTINCT를 작성해준다.
Reference.
https://jojoldu.tistory.com/165
https://incheol-jung.gitbook.io/docs/q-and-a/spring/n+1#github-repository-example
https://ttl-blog.tistory.com/161

Leave a Comment