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

이재훈·2024년 10월 28일

N+1 문제란?

특정 엔티티를 조회할 때, 연관된 엔티티를 개별적으로 가져오기 위해 추가적인 쿼리가 발생하는 문제이다.
예를 들어, Memeber 엔티티와 연관된 Item 엔티티가 N개 있을때, Member를 조회하면 Item을 가져오기 위해 추가로 N개의 SELECT 쿼리가 실행된다. 결과적으로 총 N+1개의 쿼리가 발생한다.

왜 N+1 문제가 발생하지?

지연 로딩에 의해 발생한다. 기본적으로 @ManyToOne 이나 OneToMany와 같은 연관 관계에서, JPA는 연관된 엔티티를 나중에 필요한 시점에 로드하려고 한다. 그래서 초기 조회 쿼리 이후에 연관 엔티티를 조회하기 위한 추가적인 쿼리가 발생하게 된다.

이게 왜 문제지?

  1. 성능 저하: 쿼리의 수가 많아짐에 따라 데이터베이스와의 통신 횟수가 증가한다. 이는 데이터베이스의 응답 시간을 증가시킨다.
  2. 리소스 낭비: 여러 번의 쿼리 호출로 인해 네트워크 트래픽이 증가하고, 서버 부하가 커진다.
  3. 대용량 데이터: 연관된 데이터가 많은 경우 병목 현상을 일으킬 수 있다.

재현

아래는 Member와 Item 엔티티가 N:1 관계를 가지도록 작성되어있다.

@Getter  
@Setter  
@Entity  
public class Member {  
    @Id  
    @GeneratedValue(strategy = GenerationType.IDENTITY)  
    private Long id;  
  
    private String name;  
  
    @OneToMany(mappedBy = "owner")  
    private List<Item> itemList;  
}

@Getter  
@Setter  
@Entity  
public class Item {  
    @Id  
    @GeneratedValue(strategy = GenerationType.IDENTITY)  
    private Long id;  
  
    private String name;  
  
    @ManyToOne  
    private Member owner;  
}

각 Memeber마다 2개의 Item을 가지도록 데이터를 준비했다.

@BeforeEach  
public void beforeEach() {  
    List<Member> memberList = new ArrayList<>();  
    for (int i = 0; i < 10; i++) {  
       Member member = new Member();  
       member.setName("member: " + i);  
       memberList.add(member);  
    }  
    memberRepository.saveAllAndFlush(memberList);  
  
    List<Item> itemList = new ArrayList<>();  
    for (int i = 0; i < 10; i++) {  
       for (int j = 0; j < 2; j++) {  
          Item item = new Item();  
          item.setOwner(memberList.get(i));  
          item.setName("item: " + i + '-' + j);  
          itemList.add(item);  
       }  
    }  
    itemRepository.saveAllAndFlush(itemList);  
  
    em.clear();  
}

아래의 코드는 모든 Item을 불러오는 테스트이다.
모든 Item을 조회하는 하나의 쿼리문과 각 Item의 Member를 불러오는 10개의 추가 쿼리가 나올 것으로 기대된다.

@Test  
public void givenMemberAndItem_whenFindAll_thenNPlusOneOccurs() {  
    List<Item> itemList = itemRepository.findAll();  
    itemList.forEach(item -> assertNotNull(item.getOwner().getName()));  
}

실행 결과 Item에 대한 조회 한 건과 Member에 대한 조회 10건이 발생했다.
이를 통해 N+1 문제가 발생하는 것을 확인할 수 있다.

insert 
into
	item
	(name, owner_id, id) 
values
	(?, ?, default)

select
	m1_0.id,
	m1_0.name 
from
	member m1_0 
where
	m1_0.id=?

어떻게 해결할 수 있지?

Fetch join

JPQL에서 fetch join을 사용해서 연관된 엔티티를 한 번의 쿼리로 가져온다.

테스트 코드:

@Query("select i from Item i left join fetch i.owner")  
List<Item> findAllWithOwner();

@Test  
public void whenFetchJoinUsed_thenNoNPlus1Problem() {  
    List<Item> items = itemRepository.findAllWithOwner();  
    assertEquals(2, items.size());  
}

실행되는 쿼리:

select
	i1_0.id,
	i1_0.name,
	o1_0.id,
	o1_0.name 
from
	item i1_0 
left join
	member o1_0 
		on o1_0.id=i1_0.owner_id

join을 사용하여 Item과 Member를 조인하여 한 번의 쿼리로 데이터를 로드한다.
N+1 문제를 방지하면서도 Item과 Member를 한 번에 가져온다.
하지만 페이징이 필요한 상황에서 LIMIT 사용은 어렵다.

EntityGraph

JPA에서 제공하며, 연관된 엔티티를 동적으로 로딩할 수 있다.

테스트 코드:

@EntityGraph(attributePaths = {"owner"})  
@Query("select i from Item i")  
List<Item> findAllWithOwnerEntityGraph();

@Test  
public void whenEntityGraphUsed_thenNoNPlus1Problem() {  
    List<Item> items = itemRepository.findAllWithOwnerEntityGraph();  
    assertEquals(20, items.size());  
}

실행되는 쿼리:

select
	i1_0.id,
	i1_0.name,
	o1_0.id,
	o1_0.name 
from
	item i1_0 
left join
	member o1_0 
		on o1_0.id=i1_0.owner_id

EntityGraph를 사용해 owner를 함께 로드하여 N+1 문제를 해결한다.
JOIN을 통해 기존 fetch join 과 같은 한 번의 쿼리를 전송한다.
또한 페이징에 대해서 문제가 있고, 설정이 NamedEntityGraph, SubGraph 등의 설정이 복잡할 수 있다.

@BatchSize 사용

지연 로딩 시 여러 엔티티를 설정한 만큼 로드하여 완화하는 방식이다.

테스트 코드:

@Test  
public void whenBatchSizeUsed_thenReducedQueries() {  
    List<Member> memberList = memberRepository.findAll();  
    assertEquals(10, memberList.size());  
    memberList.forEach(member -> assertEquals(2, member.getItemList().size()));  
}

엔티티 설정(Member):

@OneToMany(mappedBy = "owner")  
@BatchSize(size = 5)  
private List<Item> itemList;

실행되는 쿼리

select
	m1_0.id,
	m1_0.name 
from
	member m1_0

select
	il1_0.owner_id,
	il1_0.id,
	il1_0.name 
from
	item il1_0 
where
	il1_0.owner_id in (?, ?, ?, ?, ?)

select
	il1_0.owner_id,
	il1_0.id,
	il1_0.name 
from
	item il1_0 
where
	il1_0.owner_id in (?, ?, ?, ?, ?)

첫 SELCT 문 이후 Member 기준으로 Item을 가져오는 쿼리가 두 번 실행된다.
배치 사이즈만큼의 Member 엔티티를 로드한다.
기존 N+1보다 쿼리 수를 줄일 수 있다.
이전의 방식과 다르게 IN 절을 사용하기 때문에 쿼리가 복잡해질 수 있다.

FetchMode.SUBSELECT

초기화되지 않은 컬렉션을 로드할 때 서브쿼리를 사용해 문제를 해결한다.

테스트 코드:

@Test  
public void whenSubSelectUsed_thenNoNPlus1Problem() {  
    List<Member> memberList = memberRepository.findAll();  
    assertEquals(10, memberList.size());  
    memberList.forEach(member -> assertEquals(2, member.getItemList().size()));  
}

엔티티 설정(Member):

@OneToMany(mappedBy = "owner")  
@Fetch(FetchMode.SUBSELECT)  
private List<Item> itemList;

실행되는 쿼리

select
	m1_0.id,
	m1_0.name 
from
	member m1_0

select
	il1_0.owner_id,
	il1_0.id,
	il1_0.name 
from
	item il1_0 
where
	il1_0.owner_id in (select
		m1_0.id 
	from
		member m1_0)

서브쿼리를 사용해서 연관된 컬렉션을 한 번에 로드한다.
기존 N+1은 하나의 엔티티에 대해 연관된 엔티티를 하나씩 조회하지만, 이 방식은 연관된 엔티티 전체에서 한 번에 가져온다.
BatchSize 방식처럼 IN 절에 대한 문제를 가지고 있다.

DTO 프로젝션 사용

필요한 필드만 선택적으로 가져와 메모리 사용량을 줄이고 성능을 최적화한다.

테스트 코드:

@Query("select "  
    + "m.id as id, "  
    + "m.name as memberName, "  
    + "i.name as itemName "    + "from Member m "  
    + "left join m.itemList i")  
List<MemberProjection> findAllMemberProjections();

@Test  
public void whenDTOProjectionUsed_thenNoNPlus1Problem() {  
    List<MemberProjection> projections = memberRepository.findAllMemberProjections();  
    assertEquals(20, projections.size());  
}

실행되는 쿼리:

select
	m1_0.id,
	m1_0.name,
	il1_0.name 
from
	member m1_0 
left join
	item il1_0 
		on m1_0.id=il1_0.owner_id

하나의 Member당 2개의 Item이 있으므로 20개의 행이 만들어진다.
필요한 필드만을 요청하므로 네트워크 트래픽을 절약할 수 있다.
DTO 에 대한 정의와 유지 관리가 번거로울 수 있다.

QueryDSL 사용

Inetger와 Long 을 헷갈리는 문제를 해결할 수 있도록, 타입 안전한 방식으로 쿼리를 작성해준다.
fetchJoin()을 사용해 N+1 문제를 해결한다.

빌드 추가:

implementation 'com.querydsl:querydsl-jpa:5.0.0:jakarta'  
annotationProcessor "com.querydsl:querydsl-apt:5.0.0:jakarta"  
annotationProcessor "jakarta.annotation:jakarta.annotation-api"  
annotationProcessor "jakarta.persistence:jakarta.persistence-api"

테스트 코드(N+1):

@Test  
public void whenQueryDSLUsed_thenNoNPlus1Problem() {  
    List<Item> items = new JPAQueryFactory(em)  
       .selectFrom(QItem.item)  
       .leftJoin(QItem.item.owner, QMember.member)  
       .fetchJoin()  
       .fetch();  
    assertEquals(20, items.size());  
}

실행되는 쿼리:

select
	i1_0.id,
	i1_0.name,
	o1_0.id,
	o1_0.name 
from
	item i1_0 
left join
	member o1_0 
		on o1_0.id=i1_0.owner_id

일반 fetch 조인과 같이 동작하므로 N+1 문제를 방지한다.
또한 검색과 같은 동적 쿼리 작성과 페이징 처리에 용이하다.

아래는 페이징 처리를 하는 두 가지 방법이다.

테스트 코드(페이징)

@Test  
public void whenQueryDSLWithPagination_thenCorrectPageResults() {  
    int pageNumber = 0;  
    int pageSize = 5;  
  
    List<Member> memberList = new JPAQueryFactory(em)  
       .selectFrom(QMember.member)  
       .leftJoin(QMember.member.itemList, QItem.item)  
       .fetchJoin()  
       .offset(pageNumber * pageSize)  
       .limit(pageSize)  
       .fetch();  
  
    assertEquals(pageSize, memberList.size());  
    for (int i = 0; i < pageSize; i++) {  
       Member member = memberList.get(i);  
       assertEquals(member.getId(), i + 1);  
  
       List<Item> itemList = member.getItemList();  
       assertEquals(itemList.size(), 2);  
       for (int j = 0; j < 2; j++) {  
          assertEquals(itemList.get(j).getId(), i * 2 + j + 1);  
       }  
    }  
}

실행되는 쿼리:

select
	m1_0.id,
	il1_0.owner_id,
	il1_0.id,
	il1_0.name,
	m1_0.name 
from
	member m1_0 
left join
	item il1_0 
		on m1_0.id=il1_0.owner_id

기존 fetch join 과 같은 동작을 하지만 LIMIT 절이 동작하지 않는다.
데이터베이스 레벨에서는 일반 fetch join 과 같이 동작한다.

대신 메모리 레벨에서 페이징 동작이 발생한다.
그래서 아래와 같은 경고문이 발생한다.

HHH90003004: firstResult/maxResults specified with collection fetch; applying in memory

이는 메모리 사용량이 N * M 을 가지게 되므로 매우 부담일 수 있다.

이를 해결하기 위해서는 FetchMode.SUBSELECT 처럼 쿼리를 나눠서 전송해야 한다.
테스트 코드(분할 쿼리)

@Test  
public void whenQueryDSLWithSeperatedPagination_thenCorrectPageResults() {  
    int pageNumber = 0;  
    int pageSize = 5;  
  
    List<Member> memberList = new JPAQueryFactory(em)  
       .selectFrom(QMember.member)  
       .offset(pageNumber * pageSize)  
       .limit(pageSize)  
       .fetch();  
  
    List<Long> memberIds = memberList.stream()  
       .map(Member::getId)  
       .toList();  
  
    Map<Long, List<Item>> itemsByMemberId = new JPAQueryFactory(em)  
       .selectFrom(QItem.item)  
       .where(QItem.item.owner.id.in(memberIds))  
       .fetch().stream()  
       .collect(Collectors.groupingBy(item -> item.getOwner().getId()));  
  
    memberList.forEach(member -> {  
       List<Item> itemList = itemsByMemberId.getOrDefault(member.getId(), Collections.emptyList());  
       member.setItemList(itemList);  
    });  
  
    assertEquals(pageSize, memberList.size());  
    for (int i = 0; i < pageSize; i++) {  
       Member member = memberList.get(i);  
       assertEquals(member.getId(), i + 1);  
  
       List<Item> itemList = member.getItemList();  
       assertEquals(itemList.size(), 2);  
       for (int j = 0; j < 2; j++) {  
          assertEquals(itemList.get(j).getId(), i * 2 + j + 1);  
       }  
    }  
}

실행되는 쿼리

select
	m1_0.id,
	m1_0.name 
from
	member m1_0 
offset
	? rows 
fetch
	first ? rows only

select
	i1_0.id,
	i1_0.name,
	i1_0.owner_id 
from
	item i1_0 
where
	i1_0.owner_id in (?, ?, ?, ?, ?)

기준 엔티티는 페이징을 적용하고, 연관된 엔티티는 별도로 조회하여 매핑한다.
현재 코드는 setter를 이용하지만 유지보수를 위해서는 별도의 처리가 필요하다.
N+1의 문제를 해결하면서 EntityGraph의 복잡성을 해소할 수 있다.

해결 방법 정리

해결 방법장점한계적용 대상페이징 지원 여부실행되는 쿼리 형태
Fetch Join간단한 설정, 한 번의 쿼리로 데이터 로드페이징 시 LIMIT 사용 어려움단순한 연관 관계 조회제한적 (페이징 시 문제 발생)LEFT JOIN
EntityGraph코드에서 간결하게 연관 관계 정의 가능설정이 복잡할 수 있음 (NamedEntityGraph, SubGraph 등)복잡한 연관 관계, 동적 페칭 필요 시제한적 (페이징 시 문제 발생)LEFT JOIN
@BatchSize여러 엔티티를 한 번에 로드 가능데이터가 많을 때 IN 절로 인해 쿼리가 복잡해질 수 있음대량의 연관 엔티티 페칭가능WHERE IN ()
FetchMode.SUBSELECT서브쿼리를 사용해 연관된 데이터를 한 번에 로드서브쿼리 성능이 데이터베이스에 따라 달라질 수 있음초기화되지 않은 컬렉션 페칭 시가능WHERE IN (SELECT)
DTO Projection필요한 데이터만 로드하여 네트워크 트래픽 감소, 성능 최적화DTO 클래스 정의와 유지 관리가 번거로울 수 있음특정 필드만 필요한 경우가능LEFT JOIN
QueryDSL직관적인 쿼리 작성, 동적 쿼리와 페이징 처리에 용이초기 설정과 빌드 과정이 필요, 메모리 페이징 시 성능 부담복잡한 조건 및 동적 쿼리 작성, 페이징 처리 필요 시가능 (페이징 시 메모리 문제 주의 필요)LEFT JOIN OFFSET LIMIT
QueryDSL (분할 쿼리)기준 엔티티는 페이징하고 연관 엔티티는 별도로 조회해 매핑코드가 복잡해질 수 있으며, 직접 매핑이 필요연관된 엔티티 페칭 시 메모리 사용 최적화 필요 시가능OFFSET LIMIT + WHERE IN ()

결론

N+1 문제는 JPA 에서 성능 저하의 주요 원인 중 하나다.
이를 해결하기 위한 방법들을 찾아보았고, 각자 장단점이 있다는 것을 확인했다.
단순한 쿼리는 fetch join을, 복잡한 조건에서는 QueryDSL을 고려할 수 있다.
또한 대용량 데이터로 실험하지 않았기에 정확한 성능을 비교해볼 필요가 있다.
DTO의 경우 최적화를 적용할 수 있으며, 다른 기술에 적용가능한 유연성을 지니고 있다.

0개의 댓글