KKanging

[JPA] JPA N+1 문제 발생 원인과 해결 방법 본문

백엔드/JPA

[JPA] JPA N+1 문제 발생 원인과 해결 방법

천방지축 개발자 2024. 5. 14. 17:59

JPA에서 N+1 문제가 생기는 이유

JPA를 사용하는 개발자라면 항상 만나는 연관관계는 1대다 혹은 다대다 일 것이다.(굳이 JPA를 사용하지 않더라도 RDB를 사용한다면)

우리가 JPA를 유용하게 사용하다보면 블로그나 다른 기업 테크톡 같은 곳을 보면 JPA를 사용할때 N+1 문제를 조심해라 이런 말을 본적 있을 것이다.

이번 글은 N+1은 도대체 무엇이고 어떨때 발생하며, 해결 방법은 무엇인지 정리해보겠다.

가정

Team 과 Member 가 1:N 관계에 있다고 가정한다.

Team 에서 Fetch 전략은 LAZY이다.

@Entity
@Table(name = "team")
@NoArgsConstructor
@Getter
@Setter
public class Team {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    @Column
    private String name;

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

    @Builder
    public Team(String name) {
        this.name = name;
    }

}

@Entity
@Table(name = "member")
@NoArgsConstructor
@Getter
@Setter
public class Member {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column
    private String name;

    @ManyToOne
    @JoinColumn(name = "team_id",nullable = false)
    private Team team;

    @Builder
    public Member(String name) {
        this.name = name;
    }
}

LAZY에서의 N+1 문제

void setUp() {
        Team team = Team.builder().name("팀1").build();
        Member member = Member.builder().name("강민기").build();
        Member member1 = Member.builder().name("강민기1").build();
        member.setTeam(team);
        member1.setTeam(team);
        team.getMembers().add(member);
        team.getMembers().add(member1);
        em.persist(team);
        em.persist(member);
        em.persist(member1);
    }

    @Test
    @DisplayName("조회 쿼리 테스트")
    void test2() {
        for (int i = 0; i < 5; i++) {
            setUp();
        }
        em.flush();
        em.clear();

        List<Team> selectTFromTeamT = em.createQuery("SELECT t FROM Team t", Team.class)
                .getResultList();
        selectTFromTeamT.forEach(team -> {
            System.out.println("team.getMembers().getClass() = " + team.getMembers().getClass());
        } );

    }

위와 같은 Test 를 실행해보자

fetch 전략이 LAZY이므로 찍히는 쿼리는 다음과 같다.

각 팀에 대한 member를 조회하는 select 쿼리는 날라가지 않고 team 만 탐색하는 쿼리를 날린다.

그리고 members 에 대한 클래스를 콘솔로 찍어보면 JPA에서 변환한 프록시 객체인 것을 볼 수 있다.

이 경우처럼 단순하게 Team 만 사용한다면 N+1 문제가 생길 일이 없다.
문제는 다음에서 생긴다.
    @Test
    @DisplayName("조회 쿼리 테스트")
    void test2() {
        for (int i = 0; i < 5; i++) {
            setUp();
        }
        em.flush();
        em.clear();

        List<Team> selectTFromTeamT = em.createQuery("SELECT t FROM Team t", Team.class)
                .getResultList();
        selectTFromTeamT.forEach(team -> {
            team.getMembers().size(); // 추가
        } );

    }

지연 로딩이기 때문에 만약 member에 대한 초기화를 한다면 저렇게 5+1 즉 N+1 의 쿼리가 날라가는 것을 볼 수 있다.

이게 N+1 문제에 해당한다.

조인을 이용해서 만약 Team 을 초기화 할 때 한번의 Member 엔티티까지 초기화한다면 1개의 쿼리로도 초기화가 가능할 것인데, 잘못된 코드로 인해 N개의 쿼리가 더 날라 갔다.

이는 데이터가 많다면 큰 성능저하를 맞이할 것이다.

그럼 만약 즉시 로딩이었다면??????

위에 문제를 보고 “에이 그냥 즉시 로딩하면 따로 초기화 할때 Member를 찾는 쿼리를 안날리고 Team 로딩할때 바로 로딩하니까 해결된거 아님?” 이라고 생각할 수 있다.

그럼 즉시로딩은 join으로 쿼리가 날라갈까?

다음을 실행해보자

  • Team 단일 조회
public class Team {
		...
    @OneToMany(mappedBy = "team",fetch = FetchType.EAGER) // 바뀜
    private List<Member> members = new ArrayList<>();
		...
}
  • 단일 조회 테스트
    @Test
    @DisplayName("단일 조회")
    void test3() {
        Team team = Team.builder().name("팀1").build();
        Member member = Member.builder().name("강민기").build();
        member.setTeam(team);
        team.getMembers().add(member);
        em.persist(team);
        em.persist(member);
        em.flush();
        em.clear();

        em.find(Team.class, team.getId());
    }

join 되네????

해결???

위처럼 단일 조회를 하면 즉시 로딩은 join을 해서 team과 member를 같이 join 한다.

그럼 해결된걸까?

→ 이제 Team을 한꺼번에 조회를 해보자.

  • Team 전체 조회 테스트
    @Test
    @DisplayName("조회 쿼리 테스트")
    void test2() {
        for (int i = 0; i < 5; i++) {
            setUp();
        }
        em.flush();
        em.clear();

        List<Team> selectTFromTeamT = em.createQuery("SELECT t FROM Team t", Team.class)
                .getResultList();
//        selectTFromTeamT.forEach(team -> {
//            team.getMembers().size();
//        });

    }

우리가 기대하는 것은 1개의 조회 쿼리다.

하지만 여전히 N+1 쿼리를 실행한다.

이유가 무엇일까?

JPQL 의 특성

List<Team> selectTFromTeamT = em.createQuery("SELECT t FROM Team t", Team.class)
      .getResultList();

이유는 위에 JPQL 때문이다.

JPQL은 테이블 관점의 쿼리문인 SQL을 우리가 엔티티 관점의 쿼리문 작성으로 쉽게 변환하게 해주는 문법이다.

하지만 JPQL의 이러한 특성 때문에 엔티티의 연관관계에 대한 생각을 하지 않고 쿼리를 날린다.

즉, Team 쿼리를 실행하고 조회한 다음에 연관관계를 탐색하여 즉시로딩인 것을 확인하고 Member를 찾는 쿼리를 날린다.

 

 

결론은 즉시 로딩이든 지연 로딩이든 N+1 문제를 해결하지 못한다는 의미이다.

N+1 해결하는 방법

다행히도 JPA의 노예인 개발자들을 위해 다음과 같이 N+1 문제를 해결할 방법들이 있다.

  • fetch join
  • 엔티티 그래프
  • @batchsize

fetch join 사용

JPQL에서 제공하는 join 종류 중 하나의 방법이다.

일반 join을 사용하면 다음과 같이 작성해야한다.

select 
t1_0.id,m1_0.team_id,m1_0.id,m1_0.name,t1_0.name from team t1_0 
join member m1_0 
on t1_0.id=m1_0.team_id 
where t1_0.name='팀1'

하지만 JPQL에서 제공하는 fetch join을 사용하면 다음과 같이 사용할 수 있다.

SELECT t FROM Team t join fetch t.members where t.name ='팀1'

위와 같은 join fetch 를 이용해서 JPQL 쿼리를 만들면 위에 SQL join으로 변형 해준다.

그럼 위 예제를 실행해보자.

  • 실행 코드
    @Test
    @DisplayName("페치 조인 조회")
    void test4() {
        for (int i = 0; i < 5; i++) {
            setUp();
        }
        em.flush();
        em.clear();

        String query = "SELECT t FROM Team t join fetch t.members";
        List<Team> teams = em.createQuery(query,Team.class).getResultList();
    }

자 이제 모든 문제가 해결된 것일까…. 물론 주제인 N+1 문제는 해결 하였다.

하지만 fetch join은 만능은 아니다. fetch join 의 문제점은 다음과 같다.

fetch join의 문제점

  • fetch join 은 별칭을 줄 수 없다.
    • 물론 JPA 구현체마다 다르다.
  • 둘 이상의 컬렉션을 페치할 수 없다.
    • 아래와 같은 경우 fetch join 불가
public class Team {
		...
    @OneToMany(mappedBy = "team",fetch = FetchType.LAZY)
    private List<Member> members = new ArrayList<>();

    @OneToMany(mappedBy = "team",fetch = FetchType.LAZY)
    private List<Fan> fans = new ArrayList<>();
		...
}
  • 컬렉션을 fetch join 하면 페이징 API를 사용할 수 없다.
    • 이는 관계를 보면 알 수 있다 일대다 관계에서 fetch join은 다음과 같이 수행된다

무슨 문제일까??

문제는 일대다 관계는 컬렉션의 수에 따라 일쪽(Post)의 수가 달라지기 때문이다.

즉 _ToMany 관계에서 fetch join의 결과물은 가변적이고, _ToOne 관계에서 fetch join은 불변적이게 된다.

그래서 OneToMany 관계에 fetch join은 오류가 발생하거나 혹은 경고 메세지가 발생하면서 메모리에 모든 데이터를 올리고 페이징 처리를 한다. → 이는 엄청난 위험성이 있어서 사용하지 말아야한다.

  • OneToMany에서 페이징 처리
    @Test
    @DisplayName("페치 조인 조회")
    void test4() {
        for (int i = 0; i < 5; i++) {
            setUp();
        }
        em.flush();
        em.clear();

        String query = "SELECT t FROM Team t join fetch t.members";
        List<Team> teams = em.createQuery(query,Team.class)
                .setFirstResult(0)
                .setMaxResults(10).getResultList();
    }

  • ManyToOne에서 페이징 처리
    @Test
    @DisplayName("페치 조인 조회")
    void test5() {
        for (int i = 0; i < 5; i++) {
            setUp();
        }
        em.flush();
        em.clear();

        String query = "SELECT m FROM Member m join fetch m.team";
        List<Member> members = em.createQuery(query,Member.class)
                .setFirstResult(0)
                .setMaxResults(10).getResultList();
    }

엔티티 그래프

→ 엔티티 그래프는 fetch join을 쉽게 생성해주는 기능이다.

→ 하지만 엔티티 그래프도 fetch join의 문제점인 페이징 기능 제한을 해결할 수 없다.

[JPA] 엔티티 그래프 (tistory.com)

@Batchsize

→ 다른 게시물에서 다루도록 하겠다.

'백엔드 > JPA' 카테고리의 다른 글

[JPA] 엔티티 그래프  (0) 2024.05.10
[JPA] Context 주기와 트랜잭션 주기 그리고 OSIV  (0) 2024.05.08