이전 N + 1 문제 해결(1)에서 살펴 봤던 상황에서 해결 방법을 몇 가지 시도해보았다. 우선 이전에는 최근 내역 조회 api를 기준으로 문제를 파악했지만 이번에는 캘린더 조회 api를 기준으로 문제 해결법을 제시할 것이다. 아래 코드는 캘린더 조회 api에 핵심적인 서비스 로직이다.
//...
@Slf4j
@RequiredArgsConstructor
@Service
public class ScheduleService {
//..
@Transactional(readOnly = true)
public List<List<Daywork>> findDayworksOnSchedule(ScheduleDto scheduleDto) {
Schedule schedule = callSchedule(scheduleDto);
List<DaySchedule> daySchedulesOnSchedule = dayScheduleService.findDaySchedulesOnSchedule(schedule.getId());
List<List<Daywork>> dayworks = new ArrayList<>();
daySchedulesOnSchedule.forEach(day -> {
dayworks.add(dayScheduleService.findDayworksOnDay(day.getDay(), schedule.getId()));
// -> 위 로직을 주시하자.
});
return dayworks;
}
//...
}
캘린더 조회 시에는 매일 3개씩의 “할 일”을 보여주는 것이 목적이다. 그래서 위의 코드를 역추적하면 DayworkService에서 아래의 로직을 만나게 된다.
@Transactional
public List<Daywork> findDayworksByDayId(Long dayScheduleId) {
PageRequest pageRequest = PageRequest.of(0, 3);
return dayworkRepository.findByDayScheduleId(dayScheduleId, pageRequest);
}
위 코드에서 page size=3으로 설정한 이유는 예상할 수 있다시피 하루마다 3개씩의 데이터를 보여주기 위함이다. 이러한 로직은 기능상 문제는 없으나 성능에는 큰 문제가 발생한다. 만약 한 달 안에 종속되는 DaySchedule을 모두 조회할 때 조회된 DaySchedule의 개수가 30개(eg. 한 달 동안 매일 할 일 작성한 경우)라면 ScheduleService에서 findDayworksOnDay()를 호출을 30번 하게 될 것이고 이에 따라 총 30번 이상의 쿼리가 발생할 것이다. 이는 DB 네트워크에 큰 부하를 줄 것이다. 그래서 이와 같이 쿼리를 일별로 분리하지 않고 DaySchedule을 조회할 때 연관된 엔티티를 한 번에 가져오는 방향으로 문제를 해결해야 한다. 그리고 이 방향으로 해결하고 할 때 일별로 3개씩 조회하는 쿼리를 DB에 요청하는 것이 매우 까다로워진다. 그러므로 우선 findDayworksOnSchedule() 메서드 내의 코드를 아래와 같이 수정한다.
//...
@Slf4j
@RequiredArgsConstructor
@Service
public class ScheduleService {
//..
@Transactional(readOnly = true)
public List<List<Daywork>> findDayworksOnSchedule(ScheduleDto scheduleDto) {
Schedule schedule = callSchedule(scheduleDto);
List<DaySchedule> daySchedulesOnSchedule = dayScheduleService.findDaySchedulesOnSchedule(schedule.getId());
List<List<Daywork>> dayworks = new ArrayList<>();
daySchedulesOnSchedule.forEach(day -> {
// (변경 전)
// dayworks.add(dayScheduleService.findDayworksOnDay(day.getDay(), schedule.getId()));
// (변경 후)
dayworks.add(day.getDayworks());
});
return dayworks;
}
//...
}
이렇게 한다면 일별로 3개씩 데이터를 추출하는 일은 JVM 메모리 위에 올려서 수행해야 한다. 이는 일반적으로 메모리 초과에 대한 문제를 야기할 수 있지만, 여기서 가져오는 데이터는 문제를 일으킬 만큼 데이터가 크지 않을 것으로 예상된다. 예를 들어 한 달 동안 매일 저장된 Daywork가 10개(매일 10개의 Todo 리스트를 작성하는 셈)라고 가정하면 조회하는 데이터의 수는 약 300개 정도가 될 것이다. 그렇기 때문에 캘린더 조회를 한 번 할 때마다 30번 이상의 DB 네트워크를 이용하는 것보다 훨씬 부담이 적은 작업이라 생각한다.
이제 DaySchedule 조회 시 연관된 엔티티를 어떻게 한 번에 끌어올 수 있을지 고민해봐야 한다. 가장 먼저 시도해볼 수 있는 방법은 fetch join을 이용하는 것이다. 그러나 일대다 연관관계를 가지고 있는 DaySchedule에 적용하기에는 한계점이 있다. 그 이유는 아래 링크에 자세히 나온다.
따라서 @BatchSize 혹은 default_batch_fetch_size 등을 이용해야 한다. DaySchedule의 경우를 예로 들어서 살펴보겠다. 캘린더 조회를 위해 Schedule 1개(eg. 1월에 해당하는 schedule)를 먼저 찾고, 그 Schedule에 연관되는 DaySchedule이 30개가 존재한다고 가정해보자. DB에는 day_schedule_id 값이 1 ~ 30 으로 생성되어 있다. 이 때, batch fetch를 통해 DaySchedule과 연관된 Daywork를 조회하면 select ~ from daywork d where d.day_schedule_id in (~)과 같은 쿼리가 발생한다. batch fetch size가 30이라면 현재 해당하는 DaySchedule이 30개이므로 in 절 안에는 1부터 30까지의 day_schedule_id가 들어가게 된다. 이러한 방식으로 DaySchedule을 조회할 때 연관된 엔티티를 한 번에 끌고 오는 것이다. 그런데 실제로 batch fetch size를 설정해서 이 방식을 적용해보았을 때 예상하지 못한 문제가 발생하였다. 만약 day_schedule_id = 1~5에 연관된 Daywork가 생성되지 않았다면 어떻게 될까? 아래와 같은 추가 쿼리가 순차적으로 발생했다.
select ~ from daywork where d.day_schedule_id in (1, 2, 3, 4, 5); select ~ from daywork where d.day_schedule_id in (2, 3, 4, 5); select ~ from daywork where d.day_schedule_id in (3, 4, 5); select ~ from daywork where d.day_schedule_id in (4, 5); select ~ from daywork where d.day_schedule_id in (5, 5, 5, ..., 5); (5가 30개. batch fetch size만큼 맞춰서 생성됨)
아직 이에 대한 정확한 원인을 찾지 못하였지만 hibernate 내부에서 batch fetch를 할 때 in 절에 해당하는 데이터를 가져오지 못하면 id를 하나씩 삭제하면서 데이터를 찾는 것처럼 보인다. 그리고 마지막에 id의 개수가 1개가 될 때 batch fetch size에 맞춰서 추가 쿼리를 생성한다. 실제로 hibernate의 CollectionLoaderBatchKey라는 클래스에서 아래의 코드가 있었다.
//...
public class CollectionLoaderBatchKey implements CollectionLoader {
//...
@Override
public PersistentCollection<?> load(
Object key,
SharedSessionContractImplementor session) {
final Object[] batchIds = session.getPersistenceContextInternal()
.getBatchFetchQueue()
.getCollectionBatch( getLoadable().getCollectionDescriptor(), key, batchSize );
final int numberOfIds = ArrayHelper.countNonNull( batchIds );
if ( numberOfIds == 1 ) {
final List<JdbcParameter> jdbcParameters = new ArrayList<>( keyJdbcCount );
final SelectStatement sqlAst = LoaderSelectBuilder.createSelect(
attributeMapping,
null,
attributeMapping.getKeyDescriptor(),
null,
batchSize,
session.getLoadQueryInfluencers(),
LockOptions.NONE,
jdbcParameters::add,
session.getFactory()
);
new SingleIdLoadPlan(
null,
attributeMapping.getKeyDescriptor(),
sqlAst,
jdbcParameters,
LockOptions.NONE,
session.getFactory()
).load( key, session );
}
else {
batchLoad( batchIds, numberOfIds , session );
}
final CollectionKey collectionKey = new CollectionKey( attributeMapping.getCollectionDescriptor(), key );
return session.getPersistenceContext().getCollection( collectionKey );
}
//...
}
if (numberOfIds == 1)을 체크한 후에 batchSize만큼 select 쿼리를 생성하는 로직이 보인다. 그래서 select ~ from daywork where d.day_schedule_id in (5, 5, 5, ..., 5) 와 같은 쿼리가 발생한 것이다.
결론적으로 @BatchSize 혹은 default_batch_fetch_size를 사용하지 않고 @Fetch(FetchMode.SUBSELECT)를 사용하여 이 문제를 해결하였다. 이를 사용할 때 주의할 점은 실제로 요청된 쿼리가 select ~ from daywork where d.day_schedule_id in (select ds.day_schedule_id from day_schedule ds where ds.schedule_id=1) 와 같은 형태로 나타나므로 DB에서 in 절의 요소를 최대 몇 개까지 지원하는지 알아야 한다. 일반적으로 최대 1000개 정도 지원함을 고려하면 여기서는 하나의 Schedule에 연관된 DaySchedule이 최대 31개이므로 이에 대한 문제는 염려할 필요가 없다.