기존 DB 테이블 설계 구조는 Schedule이라는 월별 일정에 관한 테이블을 만들고, 그 아래에 Account(가계부)와 Daywork(할 일)라는 테이블을 두는 구조다. 다시 말해, Schedule : Account = 1: N, Schedule : Daywork = 1 : N 의 관계를 가진다.

그래서 캘린더 조회 api 요청 시 year과 month에 따라 특정 schedule을 조회한 후, 해당 schedule에 연관된 “할 일” 데이터를 DB에서 가져오는 것이다. 하지만 이런 방식은 한 가지 문제가 있다. 특정 월에 해당하는 데이터를 모두 가져오더라도 캘린더 조회 시 일별로 특정 개수만큼 데이터만 보여주고 싶기 때문에 데이터를 추출하는 로직을 추가해야 한다. 이를 구현하기 위해 아래와 같은 메서드를 만들었다.

//...

@RequiredArgsConstructor
@Service
public class ScheduleService {

    //...

    @Transactional(readOnly = true)
    public List<List<Daywork>> getDayworksBySchedule(Long scheduleId) {
        Schedule schedule = scheduleRepository.findById(scheduleId)
                .orElseThrow(() -> new CommonException(BError.NOT_EXIST, "schedule"));

        LocalDate lastDayOfMonth = LocalDate.of(schedule.getYear(), schedule.getMonth(), 1).plusMonths(1).minusDays(1);
        final Integer START_DATE = 1;
        final Integer END_DATE = lastDayOfMonth.getDayOfMonth();

        List<Daywork> dayworksOfMonth = dayworkService.findDayworkBySchedule(schedule.getId());
        if (dayworksOfMonth.isEmpty()) return null;

        List<List<Daywork>> filterDayworks = IntStream.rangeClosed(START_DATE, END_DATE + 1)
                .<List<Daywork>>mapToObj(ArrayList::new)
                .toList();

        IntStream.rangeClosed(START_DATE, END_DATE).forEach(currentDate -> {
            dayworksOfMonth.stream()
                    .filter(daywork -> daywork.getDateTime().getDate() == currentDate)
                    .limit(3)
                    .forEach(filterDayworks.get(currentDate)::add);
        });

        return filterDayworks;
    }

    //...

}

천천히 뜯어서 이해하면 그다지 어려운 코드는 아니지만, 서비스 로직에서 있어야 할 코드라기에는 한 눈에 들어오지 않고 코드의 가독성이 매우 떨어진다. 메서드를 분리해서 깔끔하게 정리할 수는 있지만, 더 단순하게 하기 위해 DB에서 데이터를 가져오는 시점에서 필요한 데이터만 가지고 오고 싶다.

또 다른 문제가 하나 더 있다. 최근 내역 조회 api에서는 가계부와 할 일 데이터를 page size=5로 조회하고 싶었다. 최근 5일에 대한 데이터를 보여주고, 사용자가 “더 보기”를 누르면 5일씩 데이터를 추가해서 보는 형식이기 때문이다. 그러나 실제로는 사용자가 하루에 생성할 수 있는 가계부나 할 일 데이터는 개수 제약이 없기 때문에 page size=5로 조회할 경우 원하는 데이터를 얻을 수 없다.

  1. 사용자가 1월에 schedule을 생성함
  2. 1월 1일에 가계부(account) 3개, 할 일(daywork) 2개 생성
  3. 1월 2일에 가계부(account) 1개, 할 일(daywork) 1개 생성
  4. 1월 4일에 할 일(daywork) 2개 생성
  5. 1월 5일에 가계부(account) 3개 생성
  6. 1월 6일에 가계부(account) 2개 생성, 할 일(daywork) 2개 생성

위와 같은 상황에서 최근 5일의 데이터를 조회하기 위해 AccountRepository와 DayworkRepository에서 page size=5로 조회한다면 account는 1월 5일과 1월 6일에 대한 데이터만 조회된다. 그리고 할 일은 1월 2일, 1월 4일, 1월 6일에 대한 데이터만 조회된다. 이는 원하는 결과가 아니다. page를 쓰지 않고 쿼리를 잘 조작하면 최근 5일에 대한 데이터를 가져올 수 있지만 “더 보기”의 기능을 유지하려면 page가 반드시 필요하다. 그리고 일별 가계부는 지출 → 수입 순으로 정렬해야 하고, 일별 할 일은 사용자 지정 우선 순위로 정렬해야 한다. 이러한 문제는 하나의 원인으로 귀결된다. 월별 관리자인 Schedule과 더불어 일별 관리자인 DaySchedule(?)이라는 엔티티가 필요하다.

현재는 Schedule 엔티티 자신이 관리하고 있는 해당 연월(year, month)의 모든 데이터를 한 번에 다 조회해서 JVM 메모리 위에 올려 작업을 해야하는 실정이다. 하지만 DaySchedule이라는 일별 관리자가 있다면 일별 Account, 일별 Daywork를 묶어서 관리하고 있기 때문에 Schedule은 일별 데이터에 대한 후처리 작업을 신경쓸 필요가 없어지게 된다. 그리고 DaySchedule은 해당 일에 존재하는 Account, Daywork를 DB에서 손쉽게 조회할 수 있기 때문에 쿼리도 가벼워진다.

//...

@RequiredArgsConstructor
@Service
public class ScheduleService {

    //...

		// 캘린더 조회 api에 필요한 메서드
		@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(daySchedule -> {
            dayworks.add(daySchedule.getDayworks());
        });
        return dayworks;
    }

		// 최근 내역 조회 api에 필요한 메서드
    @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(day -> {
            return bindDayworkAccountByDay(
                    daySchedule.getAccounts(),
                    daySchedule.getDayworks()
            );
        });
        return bundle;
    }

    //...

}

DaySchedule을 추가하고 난 뒤, 코드의 가독성이 개선된 모습을 볼 수 있다. 더욱이 Schedule과 DaySchedule의 책임과 역할이 명료하게 분리되었기 때문에 추후에 월별/일별로 로직을 만들어야 하는 부분에서 손쉽게 처리할 수 있을 것이다.

N + 1 문제 해결(1) (위 코드에서 발생하는 N + 1 문제를 해결)