JPA를 사용할 때 흔히 발생하는 성능 이슈 중 하나는 N+1 문제이다.
예를 들어, 게시글 목록 10개를 조회하면서 각 게시글의 작성자 정보를 함께 사용해야 하는 상황을 생각해보자.
postRepository.findAll()
호출 시, 게시글 10개는 한 번의 쿼리로 가져온다.post.getUser()
를 호출하면, 작성자 정보를 가져오기 위한 쿼리가 게시글 수만큼 추가로 실행된다.즉, 게시글 10개를 조회하면 다음과 같은 쿼리 흐름이 발생한다.
게시글 10개 조회 → 1번의 쿼리
Post 엔티티에 연관된 User 엔티티가 @ManyToOne(fetch = FetchType.LAZY)
로 설정되어 있으면, 처음에는 아래 쿼리만 실행된다.
SELECT * FROM post;
각 게시글에 연결된 사용자 조회 → 최대 10번의 쿼리
그런데 post.getUser()
가 DTO 변환 과정(post.getUser().getNickname()
)에서 호출되면 내부에서 그때마다 LAZY 로딩으로 User를 가져오기 위해 N번의 쿼리가 추가로 실행된다.
public static ResPostListDTO of(PostEntity postEntity) {
return ResPostListDTO.builder()
.id(postEntity.getId())
.title(postEntity.getTitle())
.content(postEntity.getContent())
.nickname(postEntity.getUser().getNickname())
.build();
}
→ 총 최대 11번의 쿼리가 실행
JPA는 엔티티 연관관계에서 지연 로딩(LAZY) 을 사용하게되면 post.getUser()
처럼 연관된 엔티티에 실제로 접근할 때까지는 해당 데이터를 불러오지 않는다. (현재 시점에서 필요한 데이터만 불러옴)
이로 인해 작성자가 동일한 게시글이 있더라도, 캐시에 존재하지 않는 경우에는 중복 쿼리가 발생한다.
select
pe1_0.id,
pe1_0.content,
pe1_0.created_at,
pe1_0.created_by,
pe1_0.deleted_at,
pe1_0.deleted_by,
pe1_0.is_deleted,
pe1_0.modified_at,
pe1_0.modified_by,
pe1_0.title,
pe1_0.user_id
from
posts pe1_0
order by
pe1_0.created_at desc
limit ?, ?
select
count(pe1_0.id)
from
posts pe1_0
Hibernate:
select
ue1_0.id,
ue1_0.created_at,
ue1_0.created_by,
ue1_0.deleted_at,
ue1_0.deleted_by,
ue1_0.email,
ue1_0.is_deleted,
ue1_0.modified_at,
ue1_0.modified_by,
ue1_0.nickname,
ue1_0.password,
ue1_0.role,
ue1_0.username
from
users ue1_0
where
ue1_0.id=?
Hibernate:
select
ue1_0.id,
ue1_0.created_at,
ue1_0.created_by,
ue1_0.deleted_at,
ue1_0.deleted_by,
ue1_0.email,
ue1_0.is_deleted,
ue1_0.modified_at,
ue1_0.modified_by,
ue1_0.nickname,
ue1_0.password,
ue1_0.role,
ue1_0.username
from
users ue1_0
where
ue1_0.id=?
Hibernate:
select
ue1_0.id,
ue1_0.created_at,
ue1_0.created_by,
ue1_0.deleted_at,
ue1_0.deleted_by,
ue1_0.email,
ue1_0.is_deleted,
ue1_0.modified_at,
ue1_0.modified_by,
ue1_0.nickname,
ue1_0.password,
ue1_0.role,
ue1_0.username
from
users ue1_0
where
ue1_0.id=?
...