특정 엔티티를 조회할 때, 연관된 엔티티를 개별적으로 가져오기 위해 추가적인 쿼리가 발생하는 문제이다.
예를 들어, Memeber 엔티티와 연관된 Item 엔티티가 N개 있을때, Member를 조회하면 Item을 가져오기 위해 추가로 N개의 SELECT 쿼리가 실행된다. 결과적으로 총 N+1개의 쿼리가 발생한다.
지연 로딩에 의해 발생한다. 기본적으로 @ManyToOne 이나 OneToMany와 같은 연관 관계에서, JPA는 연관된 엔티티를 나중에 필요한 시점에 로드하려고 한다. 그래서 초기 조회 쿼리 이후에 연관 엔티티를 조회하기 위한 추가적인 쿼리가 발생하게 된다.
아래는 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=?
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 사용은 어렵다.
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 등의 설정이 복잡할 수 있다.
지연 로딩 시 여러 엔티티를 설정한 만큼 로드하여 완화하는 방식이다.
테스트 코드:
@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 절을 사용하기 때문에 쿼리가 복잡해질 수 있다.
초기화되지 않은 컬렉션을 로드할 때 서브쿼리를 사용해 문제를 해결한다.
테스트 코드:
@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 절에 대한 문제를 가지고 있다.
필요한 필드만 선택적으로 가져와 메모리 사용량을 줄이고 성능을 최적화한다.
테스트 코드:
@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 에 대한 정의와 유지 관리가 번거로울 수 있다.
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의 경우 최적화를 적용할 수 있으며, 다른 기술에 적용가능한 유연성을 지니고 있다.