JPA N+1 문제

작성일

JPA를 사용하다 보면 의도하지 않았지만 select 쿼리가 여러 개 발생하는 현상을 볼 수 있다. 이러한 현상을 N+1 문제라고 부르는데 이 문제가 왜 발생하는지와 이에 대한 해결 방법을 알아보자.

N+1 문제란?

간단히 말하자면, 조회 시 1개의 쿼리를 생각하고 설계를 했으나 나오지 않아도 되는 조회의 쿼리가 N개 더 발생하는 문제이다.

이 문제는 연관관계가 있는 엔티티간에 발생한다. 연관 관계가 설정된 엔티티를 조회할 경우에 조회된 데이터 갯수(N) 만큼 연관관계의 조회 쿼리가 추가로 발생하는 현상이다.

N+1 예제

다음은 N+1 문제를 설명하기 위한 예제이다.

  • Team 엔티티, User 엔티티
  • Team과 User는 양방향 일대다 연관관계
@Entity
@Getter @Setter
public class Team {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "team_id")
    private Long id;
    private String name;

    @OneToMany(mappedBy = "team")
    private List<User> users = new ArrayList<>();
}
@Entity
@Getter @Setter
public class User {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "user_id")
    private Long id;
    private String username;

    @ManyToOne
    @JoinColumn(name = "team_id")
    private Team team;
}

참고 FetchType은 Default로 *ToMany 에서는 Lazy Loading, *ToOne 에서는 Eager Loading로 지정된다. default 옵션을 사용해도 명시해주는 것이 협업하는 다른 개발자가 보기에 좋다.

테스트 데이터로 2개의 팀을 만들고 각 팀에 2명의 사용자를 넣겠다.

public class JpaProblem {

    public static void main(String[] args) {

        EntityManagerFactory emf = Persistence.createEntityManagerFactory("hello");
        EntityManager em = emf.createEntityManager();
        EntityTransaction tx = em.getTransaction();
        tx.begin();

        try {
            Team team = null;
            for (int i = 0; i < 2; i++) {
                team = new Team();
                team.setName("team" + i);
                em.persist(team);
            }

            for (int j = 0; j < 2; j++) {
                User user = new User();
                user.setUsername("member" + j);
                user.setTeam(team);
                em.persist(user);
            }
            em.flush();
            em.clear();

            List<User> users = em.createQuery("select u from User u", User.class)
                    .getResultList();
            tx.commit();
        } catch (Exception e) {
            tx.rollback();
            e.printStackTrace();
        } finally {
            em.close();
        }
        emf.close();
    }
}

Fetch 모드가 EAGER인 경우

public class User {
    ...

    @ManyToOne(fetch = FetchType.EAGER)
    @JoinColumn(name = "team_id")
    private Team team;
}
Hibernate: /* select u from User u */ select 
user0_.user_id as user_id1_1_, user0_.team_id as team_id3_1_, user0_.username as username2_1_ from User user0_
Hibernate: select team0_.team_id as team_id1_0_0_, team0_.name as name2_0_0_ from Team team0_ where team0_.team_id=?
Hibernate: select team0_.team_id as team_id1_0_0_, team0_.name as name2_0_0_ from Team team0_ where team0_.team_id=?
  • user를 조회하는 select문이 발생한다.
  • user를 조회하는 쿼리가 team을 조회한 row 만큼 select문을 호출한다.

FetchType.EAGER에서 N+1 문제가 발생했다.

N+1 문제는 즉시로딩에서 발생?

NO! N+1 문제는 FetchType으로 인해 발생하는 문제가 아니다. 지연로딩으로 변경해도 N+1 문제는 발생한다.

Fetch 모드가 Lazy인 경우

public class User {
    ...

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "team_id")
    private Team team;
}
Hibernate: /* select u from User u */ select user0_.user_id as user_id1_1_, user0_.team_id as team_id3_1_, user0_.username as username2_1_ from User user0_
  • user를 조회하는 select문이 발생한다.

쿼리문이 하나만 호출되었다. 그렇다면 N+1 문제가 해결된 것일까? 아쉽게도 해결된 것이 아니다.🥲 지연로딩으로 설정했다는 것은 연관관계 데이터를 프록시 객체로 바인딩한 것이다. 실제 연관관계의 엔티티를 호출 시 team에 대한 쿼리문이 발생한다.

결국, 즉시로딩과 지연로딩은 동일한 결과가 발생한다. FetchType을 변경하는 것은 단지 N+1 발생 시점을 연관관계 데이터를 사용하는 시점으로 미룰지, 아니면 초기 데이터 로드 시점에 가져올지의 차이이다.

그렇다면, N+1은 왜 발생하는 것일까?

발생 원인

N+1 문제가 발생하는 이유JPQL에서 찾을 수 있다. JPQL은 SQL을 추상화한 객체지향 쿼리 언어로서 특정 SQL에 종속되지 않고 엔티티 객체와 필드 이름을 가지고 쿼리를 실행한다. 따라서, 위의 User 엔티티와 Team 엔티티가 연관관계가 있음에도 JPQL 입장에선 연관관계 데이터를 무시하고 해당 엔티티를 기준으로 쿼리를 조회한다. (select * from User) 그렇기 때문에 연관된 엔티티 데이터가 필요한 경우, FetchType으로 지정한 시점에 별도로 조회하게 된다.

해결 방안

해결방안으론 다음과 같은 방법이 있다. 하나씩 살펴보자.

  • JPQL Fetch Join 사용
  • EntityGraph 애노테이션
  • Batch Size

JPQL Fetch Join 사용

우리가 원하는 코드는 ‘select * from Team left join User on User.team_id = Team.id’ 이다. JPQL의 fetch join을 사용하면 우리가 원하는 쿼리를 생성할 수 있다.

List<Team> teams = em.createQuery("select t from Team t left join fetch t.users", Team.class)
                    .getResultList();
Hibernate: /* select t from Team t left join fetch t.users */ select team0_.team_id as team_id1_0_0_, users1_.user_id as user_id1_1_1_, team0_.name as name2_0_0_, users1_.team_id as team_id3_1_1_, users1_.username as username2_1_1_, users1_.team_id as team_id3_1_0__, users1_.user_id as user_id1_1_0__ from Team team0_ left outer join User users1_ on team0_.team_id=users1_.team_id

로그를 살펴보면 원하는 대로 조회하는 쿼리문 한 개가 발생하는 것을 확인할 수 있다.

하지만, fetch join도 단점이 있다. 우선 우리가 연관관계로 설정해놓은 FetchType을 사용할 수 없다는 것이다. fetch join을 사용하게 되면 데이터 호출 시점에 모든 연관관계의 데이터를 가져오기 때문에 FetchType을 Lazy로 설정하는 것이 무의미하다.

또한, 페이징 쿼리를 사용할 수 없다는 점과 2개 이상의 Collection join이 안된다는 단점이 있다.

@EntityGraph 애노테이션

JPQL에서 fetch join을 하게 되면 하드코딩 하게 된다는 단점이 있다. 이를 최소화 하고 싶을 때 @EntityGraph를 사용한다. 그냥 이런게 있구나만 알아두고 사용하지 말자. (너무 복잡하다.)

@EntityGraph(attributePaths = {"articles"}, type = EntityGraphType.FETCH)
@Query("select distinct u from User u left join u.articles")
List<User> findAllEntityGraph();

참고 Fetch Join과 EntityGraph 주의할 점

Fetch Join과 EntityGraph는 JPQL을 사용하여 JOIN문을 호출한다는 공통점이 있다. 또한, 공통적으로 카테시안 곱(Cartesian Product)이 발생하여 중복 데이터가 존재할 수 있다. 그러므로 중복된 데이터가 컬렉션에 존재하지 않도록 주의해야 한다.

중복된 데이터를 제거할 수 있는 방법은 다음과 같다.

  • 컬렉션을 Set을 사용하게 되면 중복을 허용하지 않는 자료구조이기 때문에 중복된 데이터를 제거할 수 있다.
  • JPQL을 사용하기 때문에 distinct를 사용하여 중복된 데이터를 조회하지 않을 수 있다.

Batch Size

이 옵션은 정확히는 N+1 문제를 해결하기보단 N+1 문제가 발생해도 IN 방식으로 데이터를 가져올 수 있는 방법이다. 이렇게 하면 100번 일어날 N+1 문제를 1번만 더 조회하는 방식으로 성능을 최적화할 수 있다.

select * from user where team_id = ?
select * from user where team_id = ?

# Batch Size 사용 
select * from user where team_id in (?,?);

Batch Size에 관련된 옵션은 application.yml에서 설정하면 된다. 설정 하나만 세팅하면 아래와 같이 in 절로 쿼리가 나간다.

spring:
  jpa:
    properties:
      hibernate:
        default_batch_fetch_size: 1000

Batch Size는 앞서 fetch join에서 단점이었던 페이징 처리와 Collection Join이 가능하다. 하지만, Batch Size는 최적화 데이터 사이즈를 알기 쉽지 않다는 단점이 있다.

QueryBuilder

Query를 실행하도록 지원해주는 다양한 플러그인이 있는데 대표적으로 Mybatis, QueryDSL, JDBC Template 등이 있다. 이를 사용하면 로직에 최적화된 쿼리를 구현할 수 있다.

// QueryDSL로 구현한 예제
return from(team).leftJoin(team.users, user).fetchJoin();

실무에서 N+1 문제를 방지하는 방법

우선 연관관계 설정은 FetchType을 모두 지연로딩으로 설정한다. 그리고 성능 최적화가 필요한 부분에서는 Fetch Join을 사용한다. 기본적으로 Batch Size의 값은 1000이하로 설정한다. (최적화 데이터 사이즈를 알긴 어렵다.) JPA 만으로 실제 비즈니스 로직을 모두 구현하긴 어려우므로 QueryDSL을 함께 사용하는 것이 좋은 방법이 될 수 있다.

카테고리: