아이돔을 리팩토링 하면서 쿼리가 다중으로 발생하는 것을 확인했습니다. 이러한 상황에서는 N + 1 문제가 발생할 수 있어 성능 저하를 발생시킬 수 있습니다. 따라서 이 문제를 해결하기 위해 N + 1문제에 대해 알아보고 개선 방법을 찾아 적용해보려고 합니다.
ORM 기술에서 연관 관계가 설정된 데이터 엔티티 사이에서 한 엔티티를 조회할 때, 조회된 엔티티의 개수(N 개) 만큼 연관된 엔티티를 조회하기 위해 추가적인 쿼리가 발생하는 문제입니다.
N + 1
이 발생하는 근본적인 원인은 관계형 데이터베이스와 객체지향 언어 간의 패러다임 차이로 인해 발생합니다. 객체는 연관 관계를 통해 레퍼런스를 가지고 있으면 언제든지 메모리 내에서 Random Access를 통해 연관 관계 객체에 접근할 수 있지만, RDB인 경우에는 Select 쿼리를 통해서만 조회할 수 있기 때문입니다.
언제 발생하는가
- JPA Fetch 전략이 EAGER로 설정된 경우 데이터를 조회
- JPA Fetch 전략이 LAZY로 설정된 경우, 이후 연관된 하위 엔티티를 다시 조회
엔티티 조회시 연관관계에 있는 데이터까지 한번에 조회해오는 기능입니다. (fetch = FetchType.EAGER
)
장점
단점
엔티티 조회 시점이 아닌 엔티티 내 연관관계를 참조할 때(즉, 요청할때만) 연관된 데이터를 조회하는 기능입니다. (fetch = FetchType.LAZY
)
Fetch Join으로 N + 1 문제 해결
Fetch Join은 대상 엔티티를 조회할 때 Lazy Loading으로 설정 되어 있는 연관 관계를 Join 쿼리를 발생시켜 한번에 조회할 수 있는 기능입니다.
Join과 Fetch Join의 차이점
대상 엔티티에 대한 컬럼만 SELECT 합니다.
@Query("SELECT distinct t FROM Team t JOIN t.members")
public List<Team> findAllWithMembersUsingJoin();
Fetch Join이 걸려있는 엔티티를 포함한 컬럼만 함께 SELECT 합니다.
@Query("SELECT distinct t FROM t JOIN FETCH t.members")
public List<Team> findALlWithMembersUsingFetchJoin();
Lazy Loading시 프록시 객체를 조회할 때 한 번에 조회할 수 있게 해주는 옵션은 Hibernate의 default_batch_fetch_size
옵션입니다. 이 옵션은 전역적으로 설정할 수 있으며, 엔티티나 관계에 대한 모든 지연 로딩 프록시를 단일 쿼리로 가져올 수 있습니다.
spring:
jpa:
properties:
default_batch_fetch_size: 100
위 설정은 모든 지연 로딩 프록시에 대해 100개의 엔티티를 단일 쿼리로 가져오도록 설정합니다.또한, @BatchSize
를 사용하여 특정 연관 관계에 대한 Batch Size
를 지정할 수도 있습니다.
Batch Size
는 일반적으로 100에서 1000 정도로 설정하는 것이 적절합니다. 그러나 DBMS에 따라서는 where in
절에 대한 제한이 있어서 1000까지만 처리할 수 있는 경우도 있습니다. 큰 Batch Size
를 사용하면 WAS는 한 번에 더 많은 데이터를 메모리에 로딩해야 하지만, DB에 부담을 줄 수 있습니다. 따라서, 적절한 Batch Size
를 선택하는 것이 중요합니다.
A엔티티가 지연 로딩된 ~ToMany
관계의 B엔티티 컬렉션을 최초로 조회할 때, 이미 조회한 A엔티티들의 ID를 수집하여, Where B.A_ID IN(?, ?, ?, ?, …)
와 같은 SQL IN 구문에 담아 B엔티티 데이터를 조회하는 쿼리가 발생합니다. 이는 A엔티티들이 필요로 하는 모든 B엔티티 데이터를 한 번에 조회합니다.
Batch Size 옵션에 설정하는 숫자는 IN 구문에 넣을 부모 엔티티의 키(ID)의 최대 개수를 의미합니다.
Batch Size
는 Fetch Join
의 한계를 일부 해결할 수 있습니다.
Collection
과 Fetch Join
시에는 Paging
문제나 단일 Fetch Join
의 제한을 해결할 수 있습니다.Collection
연관관계에 적용할 경우 1쪽에 중복 데이터가 발생합니다. 이를 방지하기 위해서 Distinct를 사용하여 중복을 방지해야 합니다.Out Of Memory
가 발생할 수 있습니다.Fetch Join
이 한 번의 쿼리로 모든 관련 데이터를 가져올 수 있기 때문에 Batch Size
보다 쿼리 수가 적습니다. 그러나 데이터 전송량 측면에서는Batch Size
를 사용하면 중복 데이터를 줄일 수 있으며, Fetch Join
은 Join
후에 데이터를 가져오기 때문에 중복 데이터를 많이 가져와야 합니다.Fetch Join 경우
레코드 | Member | Post |
---|---|---|
1 | Member1 | Post1 |
2 | Member1 | Post2 |
3 | Member2 | Post3 |
4 | Member2 | Post4 |
BatchSize 경우
레코드 | Member |
---|---|
1 | Member1 |
2 | Member2 |
레코드 | Post |
---|---|
1 | Post1 |
2 | Post2 |
3 | Post3 |
4 | Post4 |
EntityGraph vs FetchType
EntityGraph
는 FetchType
을 즉시로딩으로 변환하는 방식으로 Outer Left Join
을 수행하여 데이터를 가져오지만, Fetch Join
의 경우에는 명시적으로 Outer Join
을 사용하지 않는한 Inner Join
을 수행합니다. 이는 Fetch Join
의 단점을 피할 수 있습니다. 특히 1:N 컬렉션 Join
시 하나만 Join
할 수 있는 제약과 중복 데이터 문제를 피하기 위해 distinct
를 사용합니다.
이는 EntityGraph
가 FetchType
을 변환하여 조회하는 개념이기 때문입니다. 그래서 Fetch Join
보다 EntityGraph
를 사용하는 것이 더 효과적인 경우가 많습니다. EntityGraph
를 사용하면 Fetch Join
의 단점을 피하면서도 데이터를 효율적으로 가져올 수 있습니다.
@EntityGraph 동작 방식
FetchType.Lazy
와 FetchType.Eager
는 static
정보로서 runtime
에 변경할 수 없습니다. 그러나 EntityGraph
를 사용하면 이를 변경할 수 있게 됩니다. 예를 들어, FetchType.Lazy
로 설정해둔 경우에도 EntityGraph
를 활용하여 FetchType.Eager
로 사용할 수 있습니다.
EntityGraph vs FetchJoin
Fetch Join
에서 1:N 연관관계로 조회할 때의 제약사항인 1개의 컬렉션까지만 같이 조회할 수 있는 부분이나 distinct
가 필요한 단점을 극복할 수 있게 해줍니다.
따라서, EntityGraph
를 사용하면 FetchType
의 설정을 유연하게 변경하여 필요에 따라 데이터를 효율적으로 조회할 수 있습니다. FetchType.Lazy
로 설정된 엔티티에 대해서도 필요한 경우에는 EntityGraph
를 활용하여 FetchType.Eager
로 변경하여 사용할 수 있습니다.
일반 Join을 사용하고 DTO를 만들어서 객체를 받아오는 방식입니다.
Bean() : getter, setter, 디폴트 생성자 필요합니다.
List<PostDto> result = queryFactory
.select(Projections.bean(PostDto.class,
post.title,
post.content,
post.isAnonymous,
post.likeCount))
.from(post)
.fetch();
필드 직접 접근 : getter, setter 필요 없으며 바로 주입이 가능합니다.
List<PostDto> result = queryFactory
.select(Projections.fileds(PostDto.class,
post.title,
post.content,
post.isAnonymous,
post.likeCount))
.from(post)
.fetch();
DTO의 이름이 다르다면?
ExpressionUtils.as(source, alias) : 필드나 서브 쿼리에 별칭 적용
List<PostDto> result = queryFactory .select(Projections.fileds(PostDto.class, post.title.as("title"), post.content, post.isAnonymous, post.likeCount)) .from(post) .fetch();
생성자 필요
List<PostDto> result = queryFactory
.select(Projections.constructor(PostDto.class,
post.title,
post.content,
post.isAnonymous,
post.likeCount))
.from(post)
.fetch();
@QueryProjection
DTO 생성자에 @QueryProjection을 붙여주면 DTO도 Q파일로 생성됩니다.
import com.querydsl.core.annotations.QueryProjection;
public class PostDto {
private String title;
private String content;
private boolean isAnonymous;
public PostDto (){}
@QueryProjection
public PostDto (String titie, String content, boolean isAnonymous) {
this.title = title;
this.content = content;
this.isAnonymous = isAnonymous;
}
}
위와 같이 작성하면 Q파일이 생성되며, 이 파일을 사용하면 됩니다.
queryFactory
.select(new QPostDto(post.title, post.content, post.isAnonymous))
.from(post)
.fetch();
제가 작성한 게시글 조회 시, getComments()
를 호출할 때마다 추가적으로 댓글 엔티티가 게시글 개수만큼 쿼리가 발생하는 것을 확인했습니다. 이는 N+1
문제로, 이대로 방치할 경우 운영 단계에서 데이터베이스 부하가 늘어나고 응답 속도가 느려지며, 동일한 데이터를 반복해서 가져오는 문제가 발생할 수 있습니다. 또한 데이터 불일치 문제도 발생할 수 있습니다.
먼저 Fetch Join과 Lazying을 적용해서 조회해 올 때, 한번에 commets까지 가져오도록 했습니다.
Post
엔티티와 그에 연관된 comments
를 INNER JOIN FETCH
를 사용하여 comments
를 함께 가져오고 있으며, DISTINCT
를 사용하여 중복된 Post
엔티티가 반환되지 않도록 하고 있습니다.
이 방법을 사용하면 Fetch Join을 통해 Post와 관련된 comment를 한 번의 쿼리로 함께 조회할 수 있습니다. Lazy Loading이 활성화되어 있는 경우에는 코드를 실행할 때마다 Post 엔티티의 comment가 실제로 사용될 때 조회됩니다. 그러나 실제로 Fetch Join을 여러 컬렉션에 동시에 사용하려고 하면 MultipleBagFetchException을 만날 수 있습니다. 이는 Fetch Join이 여러 컬렉션에 동시에 적용되지 않기 때문입니다. 따라서 postPhoto에 대한 Fetch Join을 적용할 수 없었습니다. 이 문제를 해결하기 위해 batch_size를 사용하여 최적화했습니다.
batch_size를 1000으로 설정하여 100개에서 3개로 감소했습니다. 예를 들어, 설정값이 30으로 되어 있다면 이제는 한 번의 쿼리로 30개의 게시글에 연관된 댓글 엔티티를 한꺼번에 가져올 수 있습니다. 하지만, 만약 조회할 게시글 수가 1000개를 넘어가면 다시 쿼리가 여러번 실행됩니다. 이러한 문제를 해결하기 위해 DTO로 결과를 받는 방법을 선택했습니다.
QueryDsl에서 바로 DTO로 받아올 수 있게 수정하였습니다.
1. QueryDsl로 DTO 받기
![]() | ![]() |
---|
DTO로 바로 받아와서 쿼리가 한번만 발생하여 N+1문제가 발생하지 않았습니다.
2. JPQL로 DTO 받기
![]() | ![]() |
---|
querylDsl을 사용했던것과 같이 쿼리 하나만 나가는 것을 확인할 수 있었습니다.
💡 적용 전 테스트
초당 Request 요청: 1000회
테스트 시간: 60초
최소: 37ms, 최대: 179ms
http.200 응답률: 100%
💡 적용 후 테스트
초당 Request 요청: 1000회
테스트 시간: 60초
최소: 5ms, 최대: 40ms
http.200 응답률: 100%
N+1
가 발생했을 때의 성능 테스트를 해보았습니다. 현재 최소 37m, 최대 179ms가 발생했었습니다.
N+1
문제를 해결하고 나서 성능 테스트 결과, 최소 응답 시간은 5ms로 32ms 감소했고, 최대 응답 시간은 40ms로 139ms 감소했습니다. 이는 각각 약 86.49%와 77.65%의 개선을 보였습니다.
N+1
문제에 대해 조사하고 해결 방법을 적용하는 과정을 살펴보았습니다. 특히, 특정 엔티티의 1:N 관계에서 모든 컬렉션을 즉시 로딩하는 것이 불가능하다는 점이 N+1
문제를 발생시킬 수 있음을 인지했습니다. 이에 대응하여 아이돔 프로젝트에서는 DTO
와 batch size
를 함께 활용하여 N+1 문제를 해결했습니다. 이러한 접근 방식은 성능을 향상시키고 데이터베이스 부하를 줄일 수 있었습니다.
참고 자료