동글 Lazy Loading 적용기

토리
Dong-gle
Published in
7 min readSep 7, 2023

현재 프로젝트에서 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());

이 때 하나의 카테고리를 조회하고 해당 카테고리와 다음 카테고리를 출력하는 코드를 실행시켜보겠다.

두둥… 🥺

하나의 카테고리만 조회했음에도 해당 카테고리의 nextCategoryjoin 해서 가져오고, nextCategorynextCategory를 계속해서 가져오고 있는 것을 확인할 수 있다.

저장되어 있는 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

CategoryRepostioryfetch 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를 적용시켜 볼 예정이다.

--

--