본문 바로가기

Spring/Ticketing 프로젝트

N + 1 쿼리 개선하고 확장성 있는 코드 만들기

반응형

개요

  • Flab 프로젝트를 진행하던 중, 공연 상세 조회 API에서 N+1문제가 발생함을 알아차렸습니다.
  • 관련한 요구사항을 간단히 설명하자면, 먼저 공연에는 "공연 날짜" 엔티티가 1:N으로 연관을 맺고 있습니다. 각 공연 날짜에서 예매한 좌석의 수를 확인하기 위해 각 공연날짜마다 예약의 갯수를 조회해야 하는 로직이 필요했습니다.
  • 이해를 돕기 위해 Performance 상세 조회를 하기 위해 필요한 엔티티들의 관계를 도식화하면 아래와 유사한 형식이 나옵니다.

관련 코드

  • 이와 관련한 코드는 아래와 같습니다.
fun searchDetail(uid : String) : PerformanceDetailResponse{
    val performance = performanceRepository.findByUid(uid)

    val seatSize = performance.performancePlace.seats.size

    // N + 1 발생 지점
    val dateInfo = performance.performanceDateTime.map {
        PerformanceDetailResponse.DateInfo(
            it.uid,
            it.showTime.toLocalDateTime(),
            seatSize.toLong(),
            seatSize - reservationService.getReservationCount(it.uid),
        ) }

    // 생략..
}
  • 위 코드에서 각 PerformanceDateTime 마다 reservationService.getReservationCount(it.uid) 를 호출하기 때문에, SQL문이 PerformanceDateTime만큼 호출되는 문제가 있습니다. 각 DateTime 마다 호출되는 SQL은 아래와 같습니다.
SELECT count(r) FROM Reservation r
JOIN r.performanceDateTime 
WHERE r.performanceDateTime.uid = :uid
  • 처음에는 나중에 seat의 Count, Reservation의 Count 뿐만 아니라 실제 Seat마다 예약되었는지 안되었는지 확인해야 하기 때문에 엔티티를 사용하는 것이 코드 재사용성이 높을것이라 판단했었지만, N + 1문제가 발생해서 코드를 수정해야 할 것으로 보였습니다.
  • 이러한 N + 1 문제를 해결하기 위해 아래의 개선 과정을 거쳤습니다.

쿼리 개선하기

  • 먼저 처음으로 생각했던 방식은 DTO 프로젝션을 활용하여, 결과 값에 필요한 필드만 추출하는 방식이었습니다.
SELECT 
    new PerformanceDetailDto(p.uid,
    p.image,
    p.name as title,
    r.name as region,
    place.name as place,
    p.price,
    p.description,
    count(ps.id) as total,
    count(rv.id) as reservated)
FROM performances p
join performance_datetimes pd on p.id = pd.performance_id
join performance_places pp on p.place_id = pp.id
join performance_seats ps on ps.place_id = pp.id
join regions r on r.id = pp.region_id
left join reservations rv on ps.id = rv.seat_id
where p.uid = :uid
group by pd.id

 

  • 위 방식은 하나의 SELECT 문으로 필요한 데이터를 모두 가져올 수 있다는 장점이 있습니다. 하지만 아래의 치명적인 문제점이 하나 있을 것이라 판단되어 사용하지 않기로 하였습니다.
    • 위 쿼리의 결과는 Performance에 속한 PerformanceDate의 갯수만큼 나오게 되는데, 그러면 PerformanceDate외의 필드(Place, Region, Performance)등의 필드가 중복되어 조회됩니다.
  • 위 문제는 description과 같이 데이터의 크기가 큰 필드들로 인해 심각하게 메모리에 부하를 줄 수 있을 것이라 판단하였고, 위 방식을 개선해서 아래의 방식을 생각해 보았습니다.

쿼리 개선하기 - 2

  • 위 방식의 문제점은 중복되는 데이터가 존재한다는 점이었습니다. 그래서 생각했던 방식은, SELECT 문을 두개로 나누어 중복이 발생하는 부분, 중복이 발생하지 않는 부분을 나누는 것이었습니다. 이를 도식화하면 아래와 같습니다.

  • 이렇게 두개의 쿼리로 나눔으로서 N + 1문제나 데이터 중복문제를 모두 해결할 수 있다고 판단하여, 쿼리를 바로 작성해보았습니다.
SELECT new PerformanceDetailSearchResult(
    p.uid, 
    p.image,
    p.name, 
    r.name, 
    pp.name, 
    p.price, 
    p.description
) FROM Performance p 
JOIN p.performancePlace pp
JOIN pp.region r 
WHERE p.uid = :uid
SELECT new PerformanceDateInfo(
    pd.uid,
    pd.showTime,
    count(ss),
    count(rs.seat)
) FROM Performance p 
JOIN p.performanceDateTime pd 
JOIN p.performancePlace pp
JOIN pp.seats ss
LEFT JOIN Reservation rs ON ss = rs.seat
WHERE p.uid = :uid
GROUP BY pd.uid
  • 위 방식에 따라 기존의 코드를 변경하면 다음과 같습니다.
fun searchDetail(uid : String) : PerformanceDetailResponse{
    val performance = performanceRepository.findByUid(uid) ?:
        throw NotFoundException(PerformanceErrorInfos.PERFORMANCE_NOT_FOUND)

    val dateInfo = performanceRepository.getDateInfo(uid).map {
        PerformanceDetailResponse.DateInfo(
            it.uid,
            it.showTime.toLocalDateTime(),
            it.totalSeats,
            it.totalSeats - it.reservatedSeats
        )
    }

    return PerformanceDetailResponse(
        performance.uid,
        performance.image,
        performance.title,
        performance.regionName,
        performance.placeName,
        performance.price,
        performance.description,
        dateInfo
    )
}

 

확장성 개선하기

  • 위 문제가 해결되고 코드도 정상 작동하지만, 위 코드는 작은 문제가 하나 존재합니다!
    • 만약 Reservation이 Performance와 다른 DB(Redis 등..)을 쓰게 된다면, 코드는 어떻게 바뀌어야 할까요?
  • 변경하기 전의 코드는 Reservation을 조회하는 부분과 Date를 조회하는 부분이 분리되어 있었기 때문에 위 문제가 발생하지 않지만, performanceRepository를 사용해 바로 getDateInfo() 를 호출하게 되는 부분은 변경에 닫혀있어 보인다고 생각을 했습니다.
  • 이를 개선하기 위해, 아래와 같이 DateInfo를 조회하는 부분을 PerformanceDateReader 에게 부여하여 코드를 변경하였습니다.
@Service
@Transactional(readOnly = true)
class PerformanceDateReader(
    private val performanceRepository: PerformanceRepository
) {

    fun getDateInfo(performanceUid: String) : List<PerformanceDateInfo>{
        return performanceRepository.getDateInfo(performanceUid)
    }

}
fun searchDetail(uid : String) : PerformanceDetailResponse{
    val performance = performanceRepository.findByUid(uid) ?:
        throw NotFoundException(PerformanceErrorInfos.PERFORMANCE_NOT_FOUND)

    val dateInfo = performanceDateReader.getDateInfo(uid).map {
        PerformanceDetailResponse.DateInfo(
            it.uid,
            it.showTime.toLocalDateTime(),
            it.totalSeats,
            it.totalSeats - it.reservatedSeats
        )
    }

    return PerformanceDetailResponse(
        performance.uid,
        performance.image,
        performance.title,
        performance.regionName,
        performance.placeName,
        performance.price,
        performance.description,
        dateInfo
    )
}
  • 지금은 PerformanceDateReader 가 같은 DB에서 데이터를 가져오지만, 나중에 Redis같은 DB를 사용하게 된다 해도 서비스 코드의 변경없이 기능 확장이 가능합니다.
반응형