성능 개선과 코드의 가독성을 높이기 위해 이전에 DaySchedule 엔티티를 추가했다. 이로써 코드의 가독성을 개선하는 목표는 달성했다. 하지만 성능적인 면에서는 오히려 악화되는 문제가 발생했다. 소위 말하는 N + 1 문제다. 아래를 참고하면 원인과 해결 방법에 대해서 자세히 알 수 있다.

[JPA] N+1 문제가 발생하는 여러 상황과 해결방법

프로젝트에서는 N + 1 문제가 어떻게 발생하는지 알아보자. 우선 DaySchedule 엔티티에서는 Account나 Daywork를 참조하고 있다. 그리고 fetch type은 lazy loading을 default로 하고 있다.

//...

@Getter
@Builder
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor(access = AccessLevel.PROTECTED)
@Entity
public class DaySchedule {

    //...

    @OneToMany(mappedBy = "daySchedule")
    private List<Daywork> dayworks;

    @OneToMany(mappedBy = "daySchedule")
    private List<Account> accounts;

    //...
}

그리고 아래의 코드를 보자.

//...

@Slf4j
@RequiredArgsConstructor
@Service
public class ScheduleService {

    //...

    @Transactional(readOnly = true)
    public Slice<List<List<Object>>> findAllOnSchedule(Pageable pageable, ScheduleDto scheduleDto) {
        Schedule schedule = callSchedule(scheduleDto);
        Slice<DaySchedule> daySchedules = dayScheduleService.findDaySchedulesOnSchedule(pageable, schedule.getId());

        Slice<List<List<Object>>> bundle = daySchedules.map(daySchedule -> {
            bindDayworkAccountByDay(
                    daySchedule.getAccounts(),
                    daySchedule.getDayworks()
            );
        });

        return bundle;
    }

		//...

		private List<List<Object>> bindDayworkAccountByDay(List<Account> accounts, List<Daywork> dayworks) {
        List<List<Object>> bundle = new ArrayList<>();
        bundle.add(new ArrayList<>(accounts));
        bundle.add(new ArrayList<>(dayworks));
        return bundle;
    }

}

위의 코드는 최근내역 조회 api에 핵심적인 서비스 로직이다. 그리고 dayScheduleService의 findDayScheduleOnSchedule()을 역추적해보면 아래의 코드들이 등장한다.

@Transactional(readOnly = true)
public List<DaySchedule> findDaySchedulesOnSchedule(Long scheduleId) {
    return dayScheduleRepository.findAllOfMonth(scheduleId);
}
@Query("select d from DaySchedule d where d.schedule.id = :scheduleId order by d.day asc")
List<DaySchedule> findAllOfMonth(@Param("scheduleId") Long scheduleId);

따라서 DaySchedule은 Account와 Daywork를 lazy loading으로 참조하고 있기 때문에 ScheduleService에서 findDayScheduleOnSchedule()이 호출되더라도 그 시점까지는 DaySchedule 객체 안에는 프록시 객체만 존재할 뿐이다. 하지만 bindDayworkAccountByDay() 메서드가 실행되는 시점에서 실제 Account 와 Daywork 데이터를 가져오기 위해 DB에 쿼리를 보낸다. 문제는 쿼리의 수가 (DaySchedule의 개수 x 2)라는 점이다. 각 DaySchedule에 연관된 Account와 Daywork를 각각 조회하기 때문이다. 결국 한 달 안에 종속되는 모든 Account와 Daywork 데이터를 조회하기 위해 DaySchedule 쿼리 1번 (조회된 DaySchedule의 개수 = N) + Account 쿼리 N번 + Daywork N번 = 2N + 1 개의 쿼리가 발생했다(DaySchedule 조회 이전 시점에서 발생한 쿼리는 제외함).

이는 실제 서비스에서 반드시 성능 문제를 초래할 것이다. 이를 해결하기 위해 몇 가지 시도한 방법이 있다. 하지만 여러 방법을 시도해보며 시행착오를 겪게 되어서 이에 대한 자세한 과정을 따로 기록하였다.

N + 1 문제 해결(2)