Search
Duplicate
💪🏻

Fetch Join과 Bulk Update으로 N + 1 문제 해결하기

글감
BE
Spring
Java
DB
작성자
작성 일자
2023/12/24 03:28
상태
완료
공개여부
공개
Date
생성자
작업자

문제 발견

Cabi 구조를 리팩토링 하면서, 로컬 환경에서는 SQL이 발생하도록 수정된 이후 직접 출력되는 SQL을 분석해본 적이 있다. SQL을 직접 확인해보니 Admin으로 사물함을 일괄 반납하는 로직에서 N+1 동작이 발생하고 있었고, 이를 하나씩 고쳐가며 해결해보고자 한다.
Admin을 통해 특정 유저의 사물함을 반납하는 로직의 경우, 코드는 다음과 같다.
LocalDateTime now = LocalDateTime.now(); List<Cabinet> cabinets = cabinetQueryService.getCabinetsWithLock(cabinetIds); cabinets.forEach(cabinet -> { List<LentHistory> cabinetLentHistories = lentQueryService.findCabinetActiveLentHistories(cabinet.getCabinetId()); cabinetLentHistories.forEach(lh -> lentCommandService.endLent(lh, now)); cabinetCommandService.changeUserCount(cabinet, 0); cabinetCommandService.changeStatus(cabinet, CabinetStatus.AVAILABLE); lentRedisService.setPreviousUserName( cabinet.getCabinetId(), cabinetLentHistories.get(0).getUser().getName()); });
Java
복사
일단 코드 자체에서 List로 여러 cabinetId들로 Cabinet들을 조회한 이후, 조회한 Cabinet들을 순회하며 LentHistory를 가져와 각각의 Cabinet을 반납 및 상태 변경한다. 일단 코드의 로직 자체에서 N + 1로 쿼리 발생하는 구조로 작성되어 있다.
로컬 환경에서 10개 사물함을 선택해 반납하면 다음과 같이 동작한다.
CabinetId의 List를 통해 여러 Cabinet을 조회한다.
조회해온 각 Cabinet을 순회하며 LentHistory를 조회한다.(10회 발생)
거기에 추가적으로 LentHistory에서 getUser를 통해 User를 가져오는데, LAZY 로딩으로 인해 User에 대한 조회도 추가적으로 발생한다(10회)
이후 Cabinet을 업데이트하고(10회),
LentHistory를 업데이트 하게 된다.(10회)
최종적으로 위 로직에서 41개의 쿼리가 발생하고, 로컬 환경에서 응답까지 걸린 시간은 217ms정도 걸린다.

1차 개선

우선적으로 개선할 것은 CabinetId의 List를 통해 조회한 Cabinet들을 순회하며 LentHistory를 하나씩 조회하는 로직이다.
LocalDateTime now = LocalDateTime.now(); List<Cabinet> cabinets = cabinetQueryService.getCabinetsWithLock(cabinetIds); List<LentHistory> lentHistories = lentQueryService.findCabinetsActiveLentHistories(cabinetIds); Map<Long, List<LentHistory>> lentHistoriesByCabinetId = lentHistories.stream() .collect(Collectors.groupingBy(LentHistory::getCabinetId)); cabinets.forEach(cabinet -> { List<LentHistory> cabinetLentHistories = lentHistoriesByCabinetId.get(cabinet.getCabinetId()); cabinetLentHistories.forEach(lh -> lentCommandService.endLent(lh, now)); cabinetCommandService.changeUserCount(cabinet, 0); cabinetCommandService.changeStatus(cabinet, CabinetStatus.AVAILABLE); lentRedisService.setPreviousUserName( cabinet.getCabinetId(), cabinetLentHistories.get(0).getUser().getName()); });
Java
복사
CabinetId의 List를 통해 여러 Cabinet을 한번에 조회한 것 처럼, LentHistory에서도 IN 구문을 통해 한번에 다 조회해오도록 수정했다.
여러 CabinetId를 IN 구문으로 한번에 lentHistory 전부 조회해오는 것을 확인할 수 있다.
하지만 여전히 User와 AlaramStatus에 대한 N + 1 발생한다.(10회 발생) 또한 Cabinet과 LentHistory에 대한 Update가 각각 10회씩 발생한다.
최종적으로 발생하는 쿼리는 총 32회로 로컬 환경에서 응답까지 175ms정도 걸린다.

2차 개선

User와 AlaramStatus를 fetch join으로 한 번에 불러오기
@Query("SELECT lh " + "FROM LentHistory lh " + "LEFT JOIN FETCH lh.user u " + "LEFT JOIN FETCH u.alarmStatus " + "WHERE lh.cabinetId IN (:cabinetIds) " + "AND lh.endedAt IS NULL ") List<LentHistory> findAllByCabinetIdInAndEndedAtIsNullJoinUser( @Param("cabinetIds") List<Long> cabinetIds);
Java
복사
이와 같이 fetch join을 통해 LentHistory를 조회할 때 User와 AlarmStatus를 같이 불러오도록 수정했다. 이 때, fetch join 해오는 user와 alarmStatus는 WHERE 조건이나 ON 조건을 걸려있지 않기 때문에 데이터 무결성을 해치지 않는다.
User와 AlaramStatus에 대한 N+1 문제를 해결하였고, 여전히 Update가 Cabinet과 LentHistory에서 각각 10회씩 발생한다.
최종적으로 쿼리는 22개 발생하고, 로컬 환경에서 응답까지 145ms정도 걸린다.

3차 개선

2번에 걸쳐 개선을 했음에도, 여전히 각각 Update를 10번씩 반복하는 문제가 있다.
기본적으로 JPA의 기본 설정은 Dirty Checking 방식을 사용해서 영속성 컨텍스트가 Flush 되는 시점에 변경된 엔티티를 감지해 update 수행하는 방식이다. 이를 개선하기 위해 Bluk Update를 적용하여, 여러 번 발생하는 update 쿼리를 줄여보고자 한다.
위와 같이 IN 구문을 통해 여러 Id를 받아 한번에 update하는 쿼리를 작성한다.
LocalDateTime now = LocalDateTime.now(); List<Cabinet> cabinets = cabinetQueryService.getCabinetsWithLock(cabinetIds); List<LentHistory> lentHistories = lentQueryService.findCabinetsActiveLentHistories(cabinetIds); Map<Long, List<LentHistory>> lentHistoriesByCabinetId = lentHistories.stream() .collect(Collectors.groupingBy(LentHistory::getCabinetId)); cabinets.forEach(cabinet -> { List<LentHistory> cabinetLentHistories = lentHistoriesByCabinetId.get(cabinet.getCabinetId()); lentRedisService.setPreviousUserName( cabinet.getCabinetId(), cabinetLentHistories.get(0).getUser().getName()); }); lentCommandService.endLent(lentHistories, now); cabinetCommandService.changeUserCount(cabinets, 0);
Java
복사
이를 forEach 밖에서 호출한다.
이처럼 기존의 쿼리는 잘 동작하고,
이처럼 Update 쿼리가 IN 절을 통해 여러 개를 한 번에 수정하면서, 2번만 발생하는 것을 확인했다.
최종적으로 쿼리는 4개 발생하고, 로컬 환경에서 응답까지 89ms정도 걸린다.
update 요청인만큼 테스트 환경을 통해 반복적으로 확인한 테스트 결과가 아니라서, 여러 요인에 의해 테스트의 오차가 발생할 수 있다. 하지만 그러한 점을 감안하더라도 충분히 유의미한 시간 감소를 확인할 수 있었다.

Modifying 애노테이션

JPA에서는 데이터를 수정하기 위해 기본적으로 select로 조회 후 update를 수행한다. @Modifying의 의미는 select 조회 없이 update만 하겠다는 의미의 애노테이션으로, 단독으로 사용될 수 없고 @Query 애노테이션과 함께 사용되어 JPQL 쿼리에 INSERT, UPDATE, DELETE 쿼리와 DDL 문법까지 사용할 수 있다.
다만 @Modifying 애노테이션을 추가하는 경우, 쿼리를 수행한 row의 수를 반환하기 때문에 반환 타입이 voidint, Integer 중 하나로 지정되어 있어야한다.
JPA에서는 엔티티를 영속성 컨텍스트로 관리하는데, @Modifying 애노테이션을 통해 데이터를 수정하게 되는 경우 영속성 컨텍스트가 관리하는 엔티티들의 데이터가 맞지 않을 수 있다. 이를 해결하기 위해서 clearAutomatically 속성과 flushAutomatically 속성을 지정해 영속성 컨텍스트를 관리할 수 있다.
@Modifying(clearAutomatically = true, flushAutomatically = true)
Java
복사
이는 쿼리를 수행한 후에 EntityManager의 clear(), flush()와 동일한 기능을 수행한다.

참고