JPA로 개발 과정에서 N+1문제를 만나면서 해맸던 부분들과 실제 프로덕션에서 이를 제대로 처리하지 못하면 성능적인 저하뿐만 아니라 장애로 이어질 수 있다는 부분을 깨닫고 정리 해보고자 합니다.
N+1 문제는 ORM 기술에서 특정 객체를 대상으로 수행한 쿼리가 해당 객체가 가지고 있는 연관관계 또한 조회하게 되면서 N번의 추가적인 쿼리가 발생하는 문제를 말합니다.
N+1문제가 발생하는 근본적인 원인은 관계형 데이터베이스와 객체지향 언어간의 패러다임 차이로 인해 발생합니다. 객체는 연관관계를 통해 레퍼런스를 가지고 있으면 언제든지 메모리 내에서 Random Access를 통해 연관 객체에 접근할 수 있지만 RDB의 경우 Select 쿼리를 통해서만 조회할 수 있기 때문입니다.
@Entity
public class Article extends BaseEntity{
@OneToMany(mappedBy = "article", fetch = FetchType.LAZY, cascade = CascadeType.REMOVE)
private List<Opinion> opinions = new ArrayList<>();
}
Hibernate:
select
article0_.article_id as article_1_2_,
article0_.created_at as created_2_2_,
article0_.updated_at as updated_3_2_,
article0_.created_by as created_4_2_,
article0_.updated_by as updated_5_2_,
article0_.anonymity as anonymit6_2_,
article0_.api_id as api_id7_2_,
article0_.content as content8_2_,
article0_.content_category as content_9_2_,
article0_.date as date10_2_,
article0_.is_complete as is_comp11_2_,
article0_.is_deleted as is_dele12_2_,
article0_.participant_num as partici13_2_,
article0_.participant_num_max as partici14_2_,
article0_.title as title15_2_,
article0_.version as version16_2_
from
article article0_
Hibernate:
select
opinions0_.article_id as article10_11_0_,
opinions0_.opinion_id as opinion_1_11_0_,
opinions0_.opinion_id as opinion_1_11_1_,
opinions0_.created_at as created_2_11_1_,
opinions0_.updated_at as updated_3_11_1_,
opinions0_.created_by as created_4_11_1_,
opinions0_.updated_by as updated_5_11_1_,
opinions0_.api_id as api_id6_11_1_,
opinions0_.article_id as article10_11_1_,
opinions0_.content as content7_11_1_,
opinions0_.is_deleted as is_delet8_11_1_,
opinions0_.level as level9_11_1_,
opinions0_.member_author_id as member_11_11_1_,
opinions0_.parent_opinion_id as parent_12_11_1_
from
opinion opinions0_
where
opinions0_.article_id=?
Hibernate:
select
opinions0_.article_id as article10_11_0_,
opinions0_.opinion_id as opinion_1_11_0_,
opinions0_.opinion_id as opinion_1_11_1_,
opinions0_.created_at as created_2_11_1_,
opinions0_.updated_at as updated_3_11_1_,
opinions0_.created_by as created_4_11_1_,
opinions0_.updated_by as updated_5_11_1_,
opinions0_.api_id as api_id6_11_1_,
opinions0_.article_id as article10_11_1_,
opinions0_.content as content7_11_1_,
opinions0_.is_deleted as is_delet8_11_1_,
opinions0_.level as level9_11_1_,
opinions0_.member_author_id as member_11_11_1_,
opinions0_.parent_opinion_id as parent_12_11_1_
from
opinion opinions0_
where
opinions0_.article_id=?
N+1문제가 발생하면 쿼리가 배수적으로 증가하면서 DB에 큰 부담이 발생하게 되고 장애 요인이 될 수 있습니다. 또한 사용자 관점에서 지연율 또한 크게 증가할 수 있습니다.
@Entity
public class Article extends BaseEntity{
@OneToMany(mappedBy = "article", fetch = FetchType.EAGER, cascade = CascadeType.REMOVE)
private List<Opinion> opinions = new ArrayList<>();
}
OneToOne관계에서 연관관계의 주인이 아닌 Entity에서는 Lazy Loading으로 설정하는 것이 불가능합니다. 이 경우를 제외하고는 모두 LAZY Loading으로 설정해야합니다.(JPA는 연관관계 객체가 존재하지 않으면 프록시 객체를 생성하지 않고 해당 연관관계 필드를 null로 비워 두려고 하는데 연관관계의 주인이 아닌 테이블에는 FK가 없기 때문에 연관관계 주인 테이블과 Join하지 않으면 이를 알 수 없기 때문입니다. 따라서 항상 Eager Loading됩니다)
Fetch Join은 Root Entity에 대해서 조회 할 때 Lazy Loading 으로 설정 되어있는 연관관계를 Join쿼리를 발생시켜 한번에 조회할 수 있는 기능입니다.
@Query("select Distinct a from Article a join fetch a.opinions")
List<Article> findAllArticleFetchJoinOpinion();
select
distinct article0_.article_id as article_1_2_0_,
opinions1_.opinion_id as opinion_1_11_1_,
article0_.created_at as created_2_2_0_,
article0_.updated_at as updated_3_2_0_,
opinions1_.article_id as article10_11_1_,
...
opinions1_.opinion_id as opinion_1_11_0__
from
article article0_
inner join
opinion opinions1_
on article0_.article_id=opinions1_.article_id
근본적인 차이는 Fetch Join은 ORM에서의 사용을 전제로 DB Schema를 Entity로 자동 변환 해주고 영속성 컨텍스트에 영속화 해준다는 부분에 있습니다.
설명에 앞서 Fetch Join을 Collection에 대해서 할 경우 SQL Native Join 쿼리가 발생하게 되고 이 경우 1:n관계이기 때문에 1쪽의 데이터는 중복된 상태로 조회하게 됩니다.
다음과 같은 레코드 형태로 데이터를 받아오고 이를 객체로 매핑하는 것이라는 점을 알고 있어야 이후의 설명을 이해할 수 있습니다.
레코드 | Article | Opinion |
---|---|---|
1 | Article1 | Opinion1 |
2 | Article1 | Opinion2 |
3 | Article2 | Opinion3 |
4 | Article2 | Opinion4 |
2022-01-16 12:37:18.309 WARN 39536 --- [ main] o.h.h.internal.ast.QueryTranslatorImpl : HHH000104: firstResult/maxResults specified with collection fetch; applying in memory!
레코드 | Article | Opinion |
---|---|---|
1 | Article1 | Opinion1 |
2 | Article1 | Opinion2 |
3 | Article2 | Opinion3 |
spring:
jpa:
properties:
default_batch_fetch_size: 100
데이터 전송량 관점에서는 Batch Size가 유리합니다. Fetch Join은 Join을 하고 나서 가져오기 때문에 중복 데이터를 많이 가져와야하기 때문입니다.
Fetch Join의 경우
레코드 | Article | Opinion |
---|---|---|
1 | Article1 | Opinion1 |
2 | Article1 | Opinion2 |
3 | Article2 | Opinion3 |
4 | Article2 | Opinion4 |
레코드 | Article |
---|---|
1 | Article1 |
2 | Article2 |
레코드 | Opinion |
---|---|
1 | Opinion1 |
2 | Opinion2 |
3 | Opinion3 |
4 | Opinion4 |
select new 패키지 경로.ArticleDto(원하는 필드)
from Article ar
join ar.opinions op
where op.article_id = ar.id
처음에는 이 기능을 FetchJoin을 Annotation방식으로 편리하게 사용하는 기능으로 알고 있었는데 사실은 Lazy Loading을 Eager Loading으로 부분적으로 전환하는 기능입니다.
public interface ArticleRepository extends JpaRepository<Article, Long> , ArticleRepositoryCustom {
void deleteByApiId(String apiId);
@EntityGraph(attributePaths = {"articleMatchConditions"})
Optional<Article> findEntityGraphArticleMatchConditionsByApiIdAndIsDeletedIsFalse(String articleId);
@EntityGraph(attributePaths = {"articleMembers"})
Optional<Article> findEntityGraphArticleMembersByApiIdAndIsDeletedIsFalse(String articleId);
}