현재 프로젝트에서 Writing, Category Entity는 각각 자기 자신과 @OneToOne
연관 관계를 가지고 이는 Eager Loading(즉시 로딩)으로 관리되고 있다.
해당 로직을 Lazy Loading(지연 로딩)으로 바꿔 성능 개선을 해보고자 한다.
발생 배경
동글 서비스에서는 글과 카테고리의 순서를 사용자가 관리할 수 있는 기능을 제공한다.
순서를 관리하기 위해서 단방향 연결리스트 구조
를 바탕으로 로직을 구현하였다.
public class Category {
@OneToOne(fetch = FetchType.EAGER)
@JoinColumn(name = "next_category_id")
private Category nextCategory;
...
}
현재 프로젝트의 Category
도메인은 다음과 같이 nextCategory
를 즉시 로딩으로 가져오고 있다.
System.out.println("===================start===================");
Category category = categoryRepository.findById(1L).get();
System.out.println("===================category===================");
System.out.println("category = " + category);
System.out.println("===================nextCategory===================");
System.out.println("nextCategory = " + category.getNextCategory());
이 때 하나의 카테고리를 조회하고 해당 카테고리와 다음 카테고리를 출력하는 코드를 실행시켜보겠다.
두둥… 🥺
하나의 카테고리만 조회했음에도 해당 카테고리의 nextCategory
를 join 해서 가져오고, nextCategory
의 nextCategory
를 계속해서 가져오고 있는 것을 확인할 수 있다.
저장되어 있는 Category의 개수의 1/2개 만큼의 쿼리가 실행되게 되는 것이다.
순서가 유지된 카테고리 리스트를 얻고 싶을 때 기본 카테고리(제일 상단의 카테고리)
만 찾고 조회하면 nextCategory
가 다 엮여서 조회되기 때문에 따로 정렬하는 로직이 필요없다는 장점을 가지고 있지만,
하나의 카테고리만 조회할 때도 엮여있는 모든 카테고리에 대한 조회하기 위한 불필요한 쿼리가 발생한다는 문제에 직면했다.
장점보다 단점이 더 크고, 즉시 로딩
으로 인해 추후 예상하지 못한 문제가 더 발생할 수 있다고 생각해 지연 로딩
으로 리팩토링을 진행하려고 한다.
Eager Loading → Lazy Loading
public class Category {
@OneToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "next_category_id")
private Category nextCategory;
...
}
nextCategory
의 @OneToOne
어노테이션의 fetch 설정을 FetchTaype.LAZY
로 바꿔주면 간단하게 지연 로딩으로 설정할 수 있다 !
코드를 변경한 뒤 다시 동일한 코드를 실행시켜 보겠다.
조회 요청을 한 카테고리에 대해서만 조회 쿼리를 보내는 것을 확인할 수 있다 🎉
하나의 조회 쿼리만 보내는 것 외에도 기존 코드에서 실행시킨 결과와 다른 부분이 하나 더 생겼다. 바로 nextCategory
에 대한 접근을 할 때 추가적인 조회 쿼리가 생긴다는 것이다.
지연 로딩의 경우 카테고리에 대한 조회 쿼리만 실행시키고 nextCategory
에 대해서는 프록시 객체를 생성해두고 프록시 객체가 사용되는 시점에 조회 쿼리를 통해 프록시 객체를 초기화시키기 때문에 category.getNextCategory()
이 실행되며 추가적인 조회 쿼리가 발생하게 되는 것이다.
N + 1 문제는 어떻게 해결할 수 있을까?
N + 1
N + 1의 해결책으로 잘 알려진 JPQL fetch join
, @EntityGraph
두 가지 방법이 있다.
1. JPQL fetch join
CategoryRepostiory
에 fetch join을 적용시켜보겠다.
public interface CategoryRepository extends JpaRepository<Category, Long> {
@Query("select c from Category c join fetch c.nextCategory where c.id = :id ")
Optional<Category> findById(@Param("id") Long id);
...
}
@Query
어노테이션에 join fetch
를 사용하여 JPQL 쿼리를 작성하여 적용시킬 수 있다 !
카테고리를 조회할 때 nextCategory
의 정보까지 join을 통해 한 번에 가져오는 것을 알 수 있다.
이후 category.getNextCategory()
가 실행될 때도 추가적인 쿼리가 발생하지 않는 것을 확인했다 👍
다만 이렇게 매번 JPQL 쿼리를 작성하기 귀찮기 때문에 @EntityGraph
방법을 사용하기로 했다.
2. @EntityGraph
public interface CategoryRepository extends JpaRepository<Category, Long> {
@EntityGraph(attributePaths = {"nextCategory"})
Optional<Category> findById(Long id);
}
@EntityGraph
는 스프링 데이터 JPA에서 제공하는 기능으로 fetch join
을 간편하게 사용할 수 있는 기능이라고 보면 된다. (엄밀히 따지자면 @EntityGraph는 left join으로 join을 하고, fetch join은 inner join으로 join을 한다.)
@EntityGraph
어노테이션의 attributePaths 속성을 join 하고자하는 객체의 이름으로 설정해주면 된다.
결과
지연 로딩으로 변경한 뒤 하나의 카테고리나 글을 조회할 때 엮여 있는 다른 카테고리들을 조회하는 불필요한 쿼리를 제거할 수 있었다 !
다만 N+1 문제가 발생하는 부분을 찾아 해결하는 과정에서 우리 코드 내에서는 해당하는 경우가 없다는 걸 확인했다 ^^…
추후 해당 문제가 발생한다면 @EntityGraph
를 적용시켜 볼 예정이다.