진짜 개발자
본문 바로가기

Application Knowhow/Server

ApplicationKnowhow/Server - API 성능 개선기 2 (Query 개선)

728x90

이번에 테스트할 대상 API는 특정 영화의 당일 상영 목록을 반환하는 API입니다.

우선 어느정도의 성능이 측정 되는지 확인해보도록 하겠습니다.

 

약 평균 22 TPS, 660ms Latency 의 성능이 측정되었습니다. 이전 영화 목록 조회 API에 비해 더욱 성능이 감소되었음을 확인할 수 있습니다.

 

 

1차 개선(JPA 관련 성능 개선)

1. 원인 파악

Hibernate에 의해 발생하는 쿼리를 로그를 통해 확인해보니, seats에 대해서, N+1문제가 발생하는 것을 볼 수 있었습니다.

 

2. 개선 과정

좌석에 대해 fetch Join을 설정합니다. (추가로 MovieEntity에 대해서도 fetch Join을 합니다)

단 한번의 쿼리만 발생한 것을 확인할 수 있습니다. 바로 부하테스트를 다시 진행해보도록 하겠습니다.

 

3. 결과

N+1문제를 개선했지만 오히려 TPS가 더욱 감소한것을 확인할 수 있었습니다.

 

 

 

2차 개선 (쿼리 실행 계획 분석)

1. 원인 파악

우선, Fetch Join과, Fetch Join을 사용하지 않은 경우 두 쿼리의 실행 계획이 다른지를 살펴보았습니다. 위의 실행 계획이 Fetch Join을 사용했을때, 아래의 쿼리가 사용하지 않았을 때의 실행 계획입니다. 두 실행 계획은 screen_entity_seats 를 조인하지 않은 것을 제외하면 정확히 일치했습니다.

 

쿼리의 문제가 맞는가 ?

Fetch Join을 사용하지 않는 경우
Fetch Join을 사용하는 경우

N+1이 발생하는 경우가 Fetch Join보다 빠를 수 없다고 생각 했기에, 쿼리의 문제가 아닌가 싶어서, 쿼리의 실행 속도를 로깅해보았습니다. 애석하게도 쿼리 자체의 실행 속도의 문제가 맞았습니다.

 

데이터가 커서 정렬에 DISK를 사용하는가 ?

쿼리 자체에 차이는 없기 때문에 그렇다면 데이터가 너무 커서 정렬에 DISK를 사용하기 때문에 속도차이가 발생하는가? 라는 생각을 해봤습니다만. 쿼리 자체에 sort가 포함되지 않았으며, 실행 계획의 extra 란에도 별다른 특이점이 보이지 않았습니다.

혹시나 데이터가 적을 때와 쿼리의 속도 차이가 있는지 확인해 보았지만 Fetch Join을 사용하지 않을 때가 더 빨랐습니다.

 

Join의 차이다 !

위 쿼리 결과는 N+1이 발생하는 경우에 seats를 조회하는 쿼리입니다.

어떻게 생각해보아도 N+1이 더 빠를 수는 없기 때문에, JPA 쿼리 로그를 다시 확인해 보았습니다. 그런데, Screen의 좌석을 조회할 때 발생하는 쿼리가 Join을 하지 않고, where절에 단순히 id를 이용해 조회하는 것을 볼 수 있었습니다.

 

Fetch join시에는 세 테이블을 모두 조인한 결과를 filtering하여 가져오기 때문에, 당연히 데이터가 커질 수록 기하급수적으로 성능이 저하되었던 것 입니다.

반면, Seats와 Fetch Join을 하지 않았을 때에는, Screens 조회 쿼리 후, limit으로 특정 개수만큼 가져온 뒤, join이 아닌 where에 id를 이용해 Seats들을 조회하기 때문에 성능이 더 빨랐던 것입니다.

 

 

2. 개선 과정

따라서, Seats와는 Join을 하지 않도록 하고 상영 목록을 먼저 조회 한 뒤, N+1 문제가 발생하지 않도록 상영 목록의 Seats들을 in절을 이용해 한번에 조회를 하기로 했습니다.

 

 

3. 결과

hibernate에서는 Embedded가 조회의 시작점이 될 수 없다고 합니다. 또한 영속성 컨텍스트에서 관리를 해주지 않는다고 합니다. 따라서 이 방법은 사용할 수가 없었습니다.

 

 

 

3차 개선

차선책

Screens 와 Seats를 조인한 뒤 필터링하는 것은 너무 많은 데이터에 대해 조인을 하게 되므로, 조인 대상을 줄이는 방법을 생각해보았습니다.

 

 

1. 개선 과정

override fun findByMovieIdAndDate(
        movieId: Long,
        date: LocalDateTime,
        pagingRequest: PagingRequest
    ): List<ScreenEntity> {
        val screenIds = query.selectFrom(screenEntity)
            .join(screenEntity.movie).fetchJoin()
            .where(movieIdEq(movieId), dateEq(date), cursorPosition(pagingRequest.lastId))
            .limit(pagingRequest.size)
            .fetch()
            .map { it.id!! }

        return query.selectFrom(screenEntity)
            .join(screenEntity.screenRoom.seats).fetchJoin()
            .where(idIn(screenIds))
            .distinct()
            .fetch()
    }

    private fun idIn(screenIds: List<Long>): Predicate? {
        return screenEntity.id.`in`(screenIds)
    }

위와 같이 해당 날짜의 상영 목록을 먼저 조회한 뒤, id들을 이용해 별도로 fetchJoin해서 조회하는 것입니다. 이렇게하면, limit으로, 필터링 된 Screen에 대해서만 join을 하기 때문에 Join 대상을 급격히 줄일 수 있습니다. (distinct를 사용하지 않으면, 1에 해당하는 데이터가 N의 데이터만큼 복사되어 조회됩니다.)

 

 

하지만, join을 사용해 조회해오기 때문에, Fetch Join을 사용하지 않고 N+1이 발생하는 경우가 성능이 더 좋을 것으로 예상됩니다.

 

2. 결과

기존에는 Screens, Movies, Screen_entity_seats 를 모두 조인한 결과를 필터링 해 제공하였습니다.

개선한 기능에서는 Screens와 Movies를 조인한 뒤 Paging Size 만큼 필터링한 뒤 결과를 Screen_entity_seats와 조인하기 때문에, 조인에 발생하는 데이터를 줄여 성능을 높일 수 있었습니다.

 

모든 테이블을 조인해 필터링 하는 것보다는 훨씬 나은 성능을 보였지만, N+1 발생하는 것 보다는 역시 낮은 성능을 보였습니다.

 

 

 

4차 개선(Default Batch Fetch Size)

1. 개선 과정

Join으로 인해 성능이 매우 느려지기 때문에, N+1이 발생할 때의 쿼리와 같이 각각의 Seats 데이터를 screen_id를 이용해 조회해올 방법이 필요했습니다.

2차 개선에서 살펴 봤듯이 아쉽게도, Embedded들의 경우에는, Entity가 아니기 때문에 조회를 별도로 해오더라도 영속성 컨텍스트에서 관리되지 않는다고 합니다.

 

따라서 N+1이 발생하지 않도록 하면서 성능저하가 발생하지 않기 위해서는 별도로 ElementCollection의 데이터를 초기화 해줄 방법이 필요했습니다. 그러던 중 공식 문서에서 @BatchSize 설정을 발견했습니다. 해당 설정을 하면, 1:N 관계의 데이터에 대해 초기화가 필요한 시점에 지정한 size 만큼 where in 쿼리를 이용해 미리 조회해 올 수 있습니다.

 

@Embeddable
class ScreenRoom(
    var roomId: Long,
    var numRow: Int,
    var numCol: Int,
    @Enumerated(value = EnumType.STRING)
    var roomType: RoomType,
    @ElementCollection
    @BatchSize(size=30) // 처음 초기화 시 미리 30개씩 fetch해서 초기화 함
    var seats: List<Seat>
)

초기화를 미리 진행할 대상 Collection에 @BatchSize를 설정합니다.

 

 

2. 결과

where in 쿼리를 이용해 미리 가져와 한번의 지연로딩 이후 N+1문제가 발생하지 않는 것을 확인할 수 있습니다.

 

성능이 증가한 것을 확인할 수 있습니다.

 

 

 

5차 개선

1. 원인 파악

                        영화 목록 조회 API                                                                                                 상영 목록 조회 API                               

N+1 문제를 해결했지만, 영화 목록 요청 API에 비해TPS가 10배 가까이 저하한것이 조금 이상했습니다.

 

 

  1. Join 발생
  2. Data 양의 차이

위와 같이 크게 2가지 차이점이 존재했으며, 이를 토대로 하나씩 적용하며 어느 부분에서 이를 기반으로 분석을 진행 했습니다.

 

 

Join 발생

Simple Query
Join Query

Join이 단순히 발생하는 것으로 어느 정도의 성능 차이가 발생하는지 감이 없었기 때문에 이 역시 Local에서 테스트 해보기로 했습니다. 물론 실제 Product 환경과 차이가 있기 때문에 감소 비율을 확인하도록 했습니다.

상영 목록에서 단순히 영화 엔티티와 join 쿼리만을 발생하도록 한 경우에도 TPS가 500 가량이 저하되었습니다. 데이터 양이 많을 수록 더 큰 차이를 보일 것입니다.

 

 

Data 양의 차이

screen_entity_seats 테이블에는 각 상영별 예약 가능한 좌석의 상태를 나타내는 데이터가 저장됩니다. 따라서, 상영관의 크기가 클 수록 조회할 데이터가 많았으며, 15행 15열을 기준으로 한 영화의 상영이 하루 31개라고 가정했을 때 6975행 의 데이터를 조회하고 있었습니다.

 

2행 2열 데이터 조회
10행 10열 데이터 조회

2행 2열로 Seats를 구성한 결과 TPS가 어느정도 개선된 것을 확인할 수 있었습니다.

 

 

2. 개선 과정

  1. 좌석 정보 관리 방법의 변경
  2. 좌석 정보 조회 방법의 변경

생각 해본 방법들은 위와 같습니다.

 

 

좌석 정보 관리 방법의 변경

기존에는, 각각의 좌석 정보를 위와 같이 에 따라 구분하여 저장하는 방식으로 좌석마다 레코드가 하나씩 존재하는 형태로 관리하고 있었습니다.

때문에 15행 15열의 크기만 되더라도 225개의 레코드가 발생하고, 하루에 영화별로 30개의 상영만 존재해도 6,750 개의 레코드가 생성되었습니다.

 

 

screen_entity_id | row 
               1 | 00,MIDDLE,FREE#01,MIDDLE,FREE#02,MIDDLE,FREE ...

따라서 위와 같이 한 행에 해당하는 좌석들을 직렬화해 저장하는 방법을 생각해보았습니다.

이렇게 저장한다면 레코드를 제곱승으로 줄일 수 있기 때문에 데이터 조회에 발생하는 시간이 줄어들 수 있을 것입니다.

하지만, 데이터 split, 및 직렬화데이터 관련한 작업들을 어플리케이션에서 떠맡게 되기 때문에, 유지보수성과 생산성에 문제가 발생할 것이라고 생각이 들었습니다.

또한, 영화 예매 시 여러 사람이 같은 좌석을 예매하지 못하도록 잠금이 필요한데 한 행에 대한 레코드를 어플리케이션에서 split하기 전까지는 어떤 좌석이 예매 가능한지를 알 수 없고, 비관적 잠금 을 제공할 수 없기 때문에, 사용자 경험이 크게 낮아지게 될 것입니다.

 

 

좌석 정보 조회 방법 변경

data class GetScreenResponse(
    val screenId: Long,
    val screenDateTime: LocalDateTime,
    val roomSeatNum: Int,
    val remainSeatNum: Int,
    val roomType: RoomType,
)

상영 목록 조회 API 에서 조회해야 하는 데이터를 다시 한번 살펴보겠습니다. 상영 날짜, 좌석 수, 남은 좌석 수, 그리고 상영관의 타입이 필요한 데이터입니다.

 

Screen_entity_seats 에서 필요로하는 데이터는 남은 좌석 수입니다. 사실 남은 좌석 수 조회를 위해서 상영과 연관된 모든 Seats를 조인해서 조회할 필요가 없었습니다. 따라서 이를 개선하여 성능을 높일 수 있을 것 같습니다.

방법은 다음과 같습니다.

  1. Screens 테이블에 남은 좌석 수 컬럼을 추가합니다.
  2. 예약 및 예약 취소 시 Screens의 남은 좌석 수를 업데이트 합니다.

이렇게 함으로써, 상영 목록 조회 시 남은 좌석 수를 확인하기 위해 Seats 테이블에 쿼리를 하지 않아도 되기 때문에 성능이 증가할 것입니다.

 

 

3. 결과

거의 5배 가량이 성능 개선이 되었습니다.

 

 

 

결과

 

배운점

  • N+1은 당연히 개선해야할 점이지만 무분별하게 join을 사용하는 것이 아닌 쿼리를 분석하여 정확하게 쿼리를 개선해야 성능을 개선할 수 있다. (즉, 한번의 쿼리보다, 여러번의 쿼리가 성능이 더 좋을 수 있다.)
  • Join에 너무 많은 비용이 발생한다면, 역정규화도 방안이 될 수 있다