특정 가게 엔티티(store) 를 조회하는 기능에서
가게 주인 (owner), 카테고리 (category), 메뉴목록(menus) 를 함께 가져올 때 N+1문제가 발생했습니다.
가게와 각 연관관계는 다음과 같습니다.
//Store.class
@Entity
public class Store {
@Id
@Column(name = "store_id")
@GeneratedValue
private Long id;
@OneToOne(fetch = FetchType.LAZY)
@JoinColumn(name="owner_id")
private Owner owner;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "category_id")
private Category category;
@OneToMany(mappedBy = "store")
private List<Menu> menus = new ArrayList<>();
...
}
Store 엔티티를 설계할 당시 즉시로딩이 아닌 지연로딩으로 설정하여 연관 관계를 맺고 있는 owner, category, menus에 대한 객체는 일단 프록시 객체로 생성이 되도록 했습니다. 그래서 실제 사용될 때에만 데이터를 가져오도록 설계 했습니다. 만약 즉시로딩으로 설정했다면 Store 엔티티를 조회 시 모든 연관관계 엔티티를 다 가져오는 쿼리가 매번 발생하며 N+1문제도 발생할 수 있을 것입니다.
JPA에서는 다대다,일대다 관계에 경우에는 연관관계 패치 기본값을 지연로딩으로 설정하기 때문에 다대일, 일대일의 관계에 있는 Owner와 Category에만 지연로딩을 설정했습니다.
지연로딩은 다음과 같은 방식으로 프록시 객체를 생성 후 메서드가 호출될 시 초기화를 요청합니다.
Member member = em.getReference(Member.class, "id1");
member.getName();
문제가 되는 로직입니다.
@Service
@RequiredArgsConstructor
//StoreService.class
public class StoreService {
private final StoreRepository storeRepository;
public Store readOneById(Long storeId) {
//가게 데이터 조회 sql 1번 호출
Store findStore = storeRepository.findById(storeId);
//owner 조회 sql 1번 호출
Owner findOwner = findStore.getOwner();
//category 조회 sql 1번 호출
Category findCategory = findStore.getCategory();
//menu 목록 조회 sql 1번 호출
List<Menu> findMenus = findStore.getMenus();
}
가게 데이터를 한번 조회한 후 연관관계에 있는 owner, category, menu 조회 SQL을 추가로 실행하는 상황이 발생했습니다.
만약 100개의 가게 데이터를 조회하는 경우,
각 가게 데이터마다 owner, category,menu를 각각 100번씩 추가로 sql문이 실행될 것입니다.
이렇듯 한번 SQL을 실행한 후 조회된 결과수 n만큼 n번 추가적으로 sql이 실행되는 것을 n+1문제라고 합니다.
N+1문제를 해결하기 위해서는 바로 한번의 쿼리로 모든 store, category, owner, menus를 조회하도록 하는 것입니다.
JPQL에서는 N+1문제를 해결하기 위해 페치 조인이라는 기능을 제공했습니다. 페치 조인은 연관된 엔티티나 컬렉션을 SQL 한 번에 함께 조회하도록 합니다.
Select s from Store s join fetch s.category
또한 페치 조인의 특징으로는 side effect가 발생한다는 것입니다.
명시적으로는 select store 로 가게 조회 쿼리 결과를 리턴하는 것임에도 불구하고, 연관관계에 있는 category 엔티티를 가져옵니다. 이는 예상치 못한 side effect가 발생하는 것입니다.
public interface StoreRepository extends JpaRepository<Store, Long> {
@Query(value = "Select distinct s from Store s " +
"join fetch s.category" +
"join fetch s.owner" +
"join fetch s.menus" +
"where s.id = :id")
Store findStoreFetchJoinById(@Param("id")int id);
}
JPQL로 쿼리를 짜본 다면 위와 같습니다.
쿼리에 distinct를 붙인 이유는 일대다 관계에 있는 menu 리스트 때문에 사용했습니다. 컬렉션 페치 조인 시에는 레코드 수가 뻥튀기됩니다. 특정 store와 menu를 실제로 조인한다면 store는 한개여도 menu개수만큼 store와 menu를 조인한 row 결과가 나타날 것입니다.
그렇다면 store 엔티티도 menu개수만큼 있는 것이겠죠.
JPQL에서는 이러한 일대다 관계의 컬렉션을 패치 조인할 경우 해결책으로 distinct를 제공했습니다.
이 distinct는 SQL의 결과 row 중복을 제거하는 disticnt 쿼리의 역할 뿐 아니라
애플리케이션에서 같은 식별자(store pk)를 가진 엔티티를 중복 제거해주는 역할을 해줍니다.
따라서 위 페치 조인의 결과 store 엔티티는 한개로 중복된 엔티티가 제거됩니다.
하지만 이렇게 JPQL 방식의 쿼리 방식은 매번 쿼리를 작성하고 확인해야 하는 문제가 있습니다.
따라서 이러한 문제를 해결하기 위해서 Spring data jpa에서는 JPQL없이 페치 조인을 사용할 수 있는 EntityGraph를 제공했습니다.
차이점은 JPQL의 Fetch Join은 inner join ( 교집합 ) 이고, EntityGraph는 left outer join ( 왼쪽 기준으로 합집합 )입니다.
//StoreRepository.class
public interface StoreRepository extends JpaRepository<Store, Long> {
@EntityGraph(attributePaths = {"category", "owner", "menus"})
Optional<Store> findDistinctStoreFetchJoinById(Long id);
...
}
위와 같이 EntityGraph의 속성으로 연관관계에 있는 엔티티를 설정하면 됩니다.
@Service
@RequiredArgsConstructor
public class StoreService {
private final StoreRepository storeRepository;
public Store readOneFetchJoinById(Long storeId) {
return storeRepository.findDistinctStoreFetchJoinById(storeId)
.orElseThrow(RuntimeException::new);
}
위와 같이 EntityGraph를 사용하여 N+1 쿼리를 해결하고, 단 한번의 쿼리문으로 모든 데이터를 가져오도록 했습니다. 쿼리 호출 수를 줄여 DB와의 부하가 줄어들게 됩니다.
컬렉션 페치 조인인 경우 페이징 API 를 사용하면 문제가 발생할 수 있습니다.
일대다관계에 있는 메뉴 리스트의 경우에는 메뉴 리스트만큼의 row가 발생한다. 이 때 store 가게를 기준으로 페이징을 처리하려면 하이버네이트는 경고 로그를 남기고 일단 메모리로 데이터를 다 가져온 후에 메모리상에서 페이징 처리를 해버린다. 즉, 뻥튀기된 row를 메모리로 일단 다 가져온 후 그 다음에 페이징 처리를 하는 것이다.
1페이지의 데이터 , 2페이지의 데이터만을 가져오는 게 아니라 order by limit 과 같은 것도 쿼리 문도 적용되지 않은 채 전체 데이터를 가져와서 메모리 상에서 짜르는 것입니다.
페이징을 적용할 시에는 해결책으로는 2가지 방식이 있습니다.
또한, Entity Graph는 left outer join을 사용합니다.
JPQL의 fetch은 Inner Join으로 테이블 간의 교집합을 반환하지만,
Entity Graph는 left outer join으로 join 오른쪽 테이블 조회 결과에 null이 있을 수 있습니다
또한, attributePaths 속성에서 string 문자열로 엔티티를 설정합니다.
이는 테이블 명이 잘못되었을 경우에도 일단 프로그램이 실행이 됩니다.
그래서 이러한 것을 막기 위해 h2와 같은 테스트용 DB로 테스트를 진행해야 합니다.
위에서 얘기한 Batch Size로 N+1문제를 해결할 수 있습니다.
Store - menu 인 일대다 관계에서
즉시로딩에 batch size를 적용할 경우,
store를 조회한 후 -> 조회한 store들의 id를 모아서 SQL IN절로 menu들을 조회합니다.
지연로딩에 batch size를 적용할 경우,
menu 최조 사용시점에 batch size만큼 미리 로딩해놓고,
batch size 다음의 엔티티를 사용할 때 추가로 batch size만큼 menu들을 로딩해놓습니다.
BatchSize란 어노테이션을 통해 연관된 엔티티를 조회할 때 지정한 size만큼 sql IN절을 사용해서 조회
즉시 로딩
지연 로딩
인프런 - 김영한 Spring Data JPA
인프런 - 김영한 기본 JPA