사이드 프로젝트 진행 중에 JPA의 N+1 문제가 발생하였다.
매장 목록 조회 시 매장에 등록된 해쉬태그를 가져오기 위해 일대다 관계를 맺고 있는데 이것 때문에 데이터당 한번씩 더 조회가 되고 있었다.
@ManyToMany(fetch = FetchType.EAGER, cascade = { CascadeType.PERSIST, CascadeType.REMOVE })
@JoinTable(name = "tag_of_store", joinColumns = @JoinColumn(name = "store_id"), inverseJoinColumns = @JoinColumn(name = "store_tag_id"))
private Set<StoreTag> tags;
지금은 데이터가 적어서 문제가 안되지만 데이터 100개를 조회한다면 100번의 SQL이 더 실행되는 것이다.
물론 지연 로딩으로 설정해 조회가 아예 안되게 할 순 있지만 나의 경우는 조회 후 DTO로 반환해 Response으로 내보내야 하기 때문에 의미가 없었다.
해결법 1. QueryDSL fetchJoin
public Page<Store> getStoreListPage(PagingRequest pagingRequest) {
Pageable paging = pagingRequest.getPageable();
QueryResults<Store> result = queryFactory
.selectFrom(store)
.leftJoin(store.tags, storeTag).fetchJoin()
.orderBy(store.id.desc())
.offset(paging.getOffset())
.limit(paging.getPageSize())
.fetchResults();
return new PageImpl<>(result.getResults(), paging, result.getTotal());
}
나의 경우는 Page 인터페이스로 반환하기 위해 limit와 offset을 지정하였는데 경고 로그가 찍히고 실행된 쿼리를 보니 limit가 없다....
그래서 구글링 해보니 결과물을 모두 가져온 후 애플리케이션 메모리에서 직접 골라내기 때문에 데이터 수가 많다면 OutOfMemory(OOM) 이슈가 발생한다고 한다.. 그리고 조회된 데이터의 갯수가 매장 데이터가 수가 아닌 해시태그 수로 나오기 때문에 fetchJoin과 Pageable은 함께 사용할 수 없다는 것이다..
해결법 2. BatchSize + 즉시 로딩(EAGER)
@BatchSize(size = 5)
@ManyToMany(fetch = FetchType.EAGER, cascade = { CascadeType.PERSIST, CascadeType.REMOVE })
@JoinTable(name = "tag_of_store", joinColumns = @JoinColumn(name = "store_id"), inverseJoinColumns = @JoinColumn(name = "store_tag_id"))
private Set<StoreTag> tags;
BatchSize 설정을 하면 설정한 수 만큼은 IN을 사용해 매핑된 데이터를 조회해온다.
데이터가 10개인 상태에서 BatchSize를 5로 설정하고 테스트를 해보았다.
2번으로 나눠서 IN을 사용해 해시태그 데이터를 조회하는 것을 확인할 수 있다.
데이터 갯수도 정상적으로 맞게 나온다!!
하지만 영한님께서 말씀하시길 데이터 개수가 설정한 BatchSize보다 월등하게 많을땐 OOM 이슈가 생긴다고 한다.
만약, 데이터가 일정 개수 이하라는 보장이 있다면 Batch Size 적용만으로도 충분하지만 데이터가 많다면 문제가 될 가능성이 충분하다.
해결법 3. 조회 방향을 반대로 조회
해당 방법은 OneToMany 상황에서 Many쪽 데이터를 먼저 조회하고 One쪽 데이터를 List로 묶어서 조회하는 방법이다.
하지만 이 방법도 Inner Join 상황에서만 가능하므로 난 패스하였다..
아래 블로그 글을 참고했다.
해결한 방법
사실 해결은 하지 못하였다....
BatchSize를 설정하면 당장은 문제가 없겠지만 근본적인 문제 해결은 아니라고 생각했다.
나의 경우는 어짜피 해시태그 엔티티에 한 필드만 가져오면 되기 때문에 left join + group by + select절에 group_concat을 사용해서 조회한 후
public Page<StoreDTO> getStoreListPage(PagingRequest pagingRequest) {
Pageable paging = pagingRequest.getPageable();
QueryResults<StoreDTO> result = queryFactory
.select(Projections.constructor(StoreDTO.class,
store.id, store.storeType, store.storeName, store.address, store.tel,
store.homepageUrl, store.reservationUrl, store.introduction,
Expressions.stringTemplate("group_concat({0})", storeTag.title)
))
.from(store)
.leftJoin(storeTag).on(store.tags.contains(storeTag))
.orderBy(store.id.desc())
.groupBy(store.id)
.offset(paging.getOffset())
.limit(paging.getPageSize())
.fetchResults();
return new PageImpl<>(result.getResults(), paging, result.getTotal());
}
DTO 객체에 Get 메서드를 사용할때 Set<String> 형태로 반환하였다.
public class StoreDTO {
private Long id;
private StoreType storeType;
private String storeName;
private Address address;
private String tel;
private String homepageUrl;
private String reservationUrl;
private String introduction;
private String tags;
public Set<String> getTags() {
if (StringUtils.isBlank(this.tags)) return new HashSet<>();
return new HashSet<>(Arrays.asList(this.tags.split(",")));
}
}
어쨋든 쿼리 한번으로 내가 원하는 데이터를 얻을 수 있었다.
group_concat을 사용하려면 데이터베이스 방언을 커스텀해서 설정해줘야 한다.
public class CustomMySQL57Dialect extends MySQL57Dialect {
public CustomMySQL57Dialect() {
super();
this.registerFunction("group_concat", new StandardSQLFunction("group_concat", new StringType()));
}
}
더 좋은 방법이 있다면 공유해주세요... 제발.....
'JPA' 카테고리의 다른 글
벌크 연산, JPQL과 영속성 콘텍스트, Flush Mode (1) | 2022.01.17 |
---|---|
JPA 스토어드 프로시저(Stored Procedure) (1) | 2022.01.17 |
자주 사용하는 JPQL 문법 정리 (0) | 2022.01.14 |
JPQL 경로 표현식 (0) | 2022.01.14 |
JPQL 페치 조인 (0) | 2022.01.14 |