Database/JPA / / 2024. 9. 24. 14:16

N+1 문제를 해결하는 JPA 사용법

N+1 문제는 ORM(Object-Relational Mapping)에서 발생하는 대표적인 성능 문제 중 하나입니다. 이 문제는 하나의 쿼리를 통해 데이터를 조회한 후, 해당 데이터와 관련된 연관 데이터를 각각 추가로 조회할 때 발생합니다. 즉, 하나의 쿼리(N)로 데이터를 조회하고, 그 데이터와 연관된 데이터를 추가로 조회하기 위해 N번의 쿼리가 추가로 실행되는 상황입니다.

 

이 문제는 주로 JPAHibernate 같은 ORM을 사용할 때 발생하며, 데이터베이스에 불필요하게 많은 쿼리를 발생시켜 성능 저하를 초래합니다.

 

예를 들어, **팀(Team)**과 **멤버(Member)**의 관계가 있는 상황에서, 각 팀에 대한 멤버 목록을 조회할 때 다음과 같은 상황이 발생할 수 있습니다.

// N+1 문제 발생 예시
List<Team> teams = em.createQuery("SELECT t FROM Team t", Team.class).getResultList();
for (Team team : teams) {
    System.out.println("Team: " + team.getName());
    for (Member member : team.getMemberList()) {
        System.out.println("Member: " + member.getName());
    }
}

 

이 코드에서 Team을 조회한 후, 각 팀에 소속된 Member를 조회할 때 N+1 문제가 발생합니다.

 

1. 먼저 SELECT * FROM Team 쿼리가 실행됩니다. (이때 N개의 Team을 조회)

2. 이후 각 팀에 대한 Member를 조회하기 위해 팀 수만큼의 추가 쿼리가 발생합니다. (각 팀에 대한 SELECT * FROM Member WHERE team_id = ?가 N번 발생)

 

N+1 문제를 해결하는 방법

 

1. Fetch Join 사용하기

 

Fetch Join은 JPA에서 N+1 문제를 해결하는 가장 일반적인 방법입니다. 연관된 엔티티를 한 번에 조회할 수 있도록 도와줍니다. join fetch를 사용하면 연관된 엔티티를 한 번의 쿼리로 함께 조회할 수 있습니다.

// N+1 문제 해결: Fetch Join
List<Team> teams = em.createQuery(
        "SELECT t FROM Team t JOIN FETCH t.memberList", Team.class)
        .getResultList();
for (Team team : teams) {
    System.out.println("Team: " + team.getName());
    for (Member member : team.getMemberList()) {
        System.out.println("Member: " + member.getName());
    }
}

 

이렇게 하면, 팀과 각 팀에 속한 멤버들을 한 번에 조회할 수 있습니다. 결과적으로 한 번의 쿼리만 발생하고, 추가적인 쿼리가 발생하지 않습니다.

 

JPQL Fetch Join의 장점:

연관된 엔티티들을 한 번에 모두 가져옴으로써 N+1 문제를 해결할 수 있음.

성능이 크게 개선됨.

주의사항:

페치 조인은 여러 관계에 대해서 동시에 사용할 수 없으므로 너무 많은 관계를 한 번에 페치하려는 시도는 지양해야 합니다. 이로 인해 카테시안 곱(Cartesian Product) 문제가 발생할 수 있기 때문입니다.

 

2. @BatchSize 어노테이션 사용하기

 

@BatchSize는 JPA와 Hibernate에서 제공하는 기능으로, 배치 사이즈(한 번에 가져올 연관 엔티티의 개수)를 설정할 수 있습니다. 이 기능을 사용하면 연관된 엔티티를 한 번에 특정 개수만큼 미리 로드해, 쿼리 수를 줄일 수 있습니다.

@Entity
public class Team {
    @OneToMany(mappedBy = "team")
    @BatchSize(size = 10)  // 한번에 10개의 멤버를 로드
    private List<Member> memberList;
}

 

이렇게 하면 N개의 쿼리가 발생하는 대신, 10개씩 멤버를 배치로 가져오므로 쿼리 횟수를 줄일 수 있습니다.

 

장점:

데이터가 적을 때 성능이 좋고, 여러 개의 연관 데이터를 한 번에 미리 로딩할 수 있습니다.

단점:

너무 많은 데이터를 한 번에 배치로 가져오면, 메모리 사용량이 많아질 수 있습니다.

 

3. @EntityGraph 사용하기

 

JPA 2.1부터 추가된 **@EntityGraph**는 페치 전략을 명시적으로 지정하여, 특정 연관 엔티티를 함께 로드하는 기능을 제공합니다. JPQL 없이도 페치 전략을 설정할 수 있어 코드가 깔끔해집니다.

@Entity
@NamedEntityGraph(
    name = "Team.withMembers",
    attributeNodes = @NamedAttributeNode("memberList")
)
public class Team { }

public List<Team> findAllWithMembers() {
    return em.createQuery("SELECT t FROM Team t", Team.class)
             .setHint("javax.persistence.loadgraph", em.getEntityGraph("Team.withMembers"))
             .getResultList();
}

 

이렇게 하면 JPQL이나 페치 조인 없이도 팀과 멤버를 한 번에 조회할 수 있습니다. EntityGraph를 통해 Lazy Loading을 사용하는 엔티티도 필요에 따라 Eager Loading처럼 동작하게 만들 수 있습니다.

 

장점:

JPQL을 직접 사용하지 않아도 페치 전략을 설정할 수 있습니다.

코드의 가독성이 좋아집니다.

단점:

너무 많은 연관 관계를 EntityGraph로 가져오면, 한 번에 많은 데이터를 로딩하게 되어 성능 문제가 발생할 수 있습니다.

 

4. Hibernate의 @Fetch(FetchMode.SUBSELECT) 사용

 

Hibernate에서는 @Fetch(FetchMode.SUBSELECT)를 사용해 N+1 문제를 해결할 수 있습니다. 이 설정을 사용하면 연관된 엔티티를 서브쿼리로 한 번에 로드할 수 있습니다.

@Entity
public class Team {
    @OneToMany(mappedBy = "team")
    @Fetch(FetchMode.SUBSELECT)  // 서브쿼리를 사용하여 한번에 조회
    private List<Member> memberList;
}

@Fetch(FetchMode.SUBSELECT)를 적용하면, 멤버를 조회할 때 별도의 서브쿼리를 사용하여 필요한 데이터를 한 번에 가져옵니다.

SELECT * FROM Team;
SELECT * FROM Member WHERE team_id IN (SELECT team_id FROM Team);

 

이 방식은 한 번의 서브쿼리로 데이터를 가져오기 때문에 N+1 문제를 해결할 수 있습니다.

 

장점:

쿼리의 수를 크게 줄여 성능을 향상시킬 수 있습니다.

단점:

서브쿼리가 많은 데이터를 로드하면 성능 저하가 발생할 수 있습니다.

 

5. Lazy Loading과 필요한 시점에서 명시적 로딩

 

JPA의 기본 로딩 전략은 Lazy Loading(지연 로딩)입니다. Lazy Loading은 연관된 엔티티를 처음에 바로 로드하지 않고, 해당 엔티티가 필요할 때 데이터를 로드하는 방식입니다. 이를 통해 N+1 문제를 어느 정도 해결할 수 있지만, 잘못 사용하면 문제가 더 심해질 수 있습니다.

 

해결 방법 중 하나는 명시적으로 로딩을 제어하는 것입니다. 예를 들어, EntityManagerfind() 메서드를 통해 필요한 시점에만 데이터를 로드하거나, Lazy 전략을 적절히 활용하여 불필요한 로딩을 막는 것이 좋습니다.

Member member = em.find(Member.class, memberId);
Hibernate.initialize(member.getTeam());  // 명시적으로 로딩

 

장점:

필요한 시점에서만 데이터를 가져옴으로써 성능을 제어할 수 있습니다.

단점:

코드가 복잡해질 수 있고, 적절한 로딩 시점을 놓치면 다시 N+1 문제가 발생할 수 있습니다.

 

N+1 문제 해결 방법 요약

 

1. Fetch Join: 가장 간단하고 흔한 해결 방법으로, 한 번의 쿼리로 연관 데이터를 함께 가져오는 방식입니다.

2. @BatchSize: 배치로 연관 데이터를 가져오는 방식으로, 한 번에 가져오는 데이터 수를 조절합니다.

3. @EntityGraph: 특정 엔티티에 대해 명시적으로 페치 전략을 설정하여 연관 데이터를 함께 가져옵니다.

4. @Fetch(FetchMode.SUBSELECT): 서브쿼리를 통해 연관 데이터를 한 번에 가져오는 방식입니다.

5. Lazy Loading과 명시적 로딩: 지연 로딩을 사용하여 필요한 시점에만 데이터를 가져오는 전략입니다.

  • 네이버 블로그 공유
  • 네이버 밴드 공유
  • 페이스북 공유
  • 카카오스토리 공유