이번 부하테스트 대상 API는 DB 참조가 포함된 API입니다.
최초 단순 문자열("OK")을 반환하는 API에 대해 부하테스트를 진행하여 900 TPS, 9ms Latency
정도의 성능을 내는것을 파악했습니다.
DB 조회가 추가 되었을 때 어느 정도의 성능 저하가 발생하는지 확인을 위해 부하테스트를 진행해보겠습니다.
1. 원인 파악
TPS가 정말 급감하는 것을 볼 수 있습니다. 여러번의 테스트 결과 평균 195TPS, 76ms Latency
의 성능을 보였습니다.
병목 현상이 어느 곳에서 발생하는지를 관찰하기 위해 Thread Dump
를 분석하기로 했습니다.
당연하겠지만, 실행중인 대부분의 thread
들은 위와 같이 SQL관련
메소드들을 실행중이었습니다.
Thread Dump
분석 중 이상한 부분을 발견했습니다. 제가 테스트하고 있는 api는 현재 상영중인 영화 목록
을 반환하는 API였으며, write 연산은 일체 일어나지 않는 API였습니다.
하지만, 위의 그림을 보면 알 수 있다시피, hibernate의 doFlush
메소드가 호출되는 것을 볼 수 있습니다.
변경감지(Dirty Checking)와 Flush에 관해서
변경감지
는 Hibernate에서 제공하는 기능으로, flush
가 발생하는 시점에 최초의 Entity와의 차이를 감지하고 필요한 SQL을 자동으로 호출해주는 기능입니다.
Hibernate의 경우 변경감지
를 제공하기 위해 최초 Persistence Context에 로드된 엔터티의 스냅샷을 저장하고, Flush되는 시점에 스냅샷과 현재 Entity상태를 비교하여 변경사항에 대한 Query를 생성하여 DB에 flush를 하게 됩니다. 따라서, 기본적으로는
Entity의 상태를 Hibernate Session이 닫힐 때 까지 메모리에 유지하게 됩니다.
flush
메소드는 Persistence Context
에 존재하는 Entity에 변경이 생긴 경우 이것을 DB에 반영하기 위해 필요한 메소드입니다. 따라서, 읽기 요청만 하는 메소드내에서는 불필요한 호출이기 때문에 이 부분을 최적화 할 수 있는 방법을 찾아보기로 했습니다.
2. 개선 과정
hibernate의 flush를 발생하지 않도록 하기 위해서는 flush가 언제 발생하는지를 먼저 알아야겠다고 생각이 들었습니다. JPA ORM 기술 표준
책에서는 다음의 경우에 flush가 발생한다고 합니다.
- JPQL 호출
- entitymanager의 flush() 메소드를 직접 호출
- transaction이 끝나는 시점에 자동으로 호출
위의 세가지 경우중, 테스트 대상 API는 3. transaction이 끝나는 시점에 자동으로 호출의 경우
입니다. 따라서 자동으로 호출되지 않도록 하는 설정을 찾아보기로 했습니다.
Transactional(readOnly=true)
readOnly=true
옵션의 경우, 읽기 전용 힌트를 제공하는 옵션으로, 의도치 않게 write가 발생하는 경우를 방지하는 옵션입니다. 물론 db drvier에 따라 상이합니다. 테스트 해 본결과 Mysql은 정상 동작하며 h2의 경우 동작하지 않았습니다.
또한 이 옵션은 hibernate
에서 조금 더 특별한 동작을 합니다. 바로 session의 FlushMode.MANUAL
로 설정을 하도록 합니다. 이렇게 되면, 직접 flush를 호출할 때에만 flush 가 이루어지도록 할 수 있습니다.
또한, Read-Only로 Entity를 로딩하기 때문에, Hibernate는 변경감지를 제공하지 않아도 되기 때문에, 해당 엔티티의 상태를 메모리상에 유지 하지 않기 때문에 메모리상의 이점도 취할 수 있습니다.
기존 평균 195TPS, 76ms Latency
에서 readOnly 설정을 한 뒤 부하테스트를 한 결과, 평균 217TPS, 69ms Latency
의 성능을 보였습니다.
3. 정리
- 단순히 조회만 발생하는 쿼리의 경우 Hibernate의
변경 감지
기능을 제공받을 필요가 없습니다. - Hibernate는 기본적으로 변경 감지를 제공하며, 트랜잭션이 끝나는 시점에 doFlush()를 호출합니다.
- Spring의
@Transactionl
어노테이션에readOnly
설정을 하면, DB 연결 session을 readOnly로 연결하여 Hibernate에게 힌트를 제공하며, hibernate에서 변경감지를 위한 오버헤드가 발생하지 않도록 할 수 있습니다. - ReadOnly 설정을 통해 약간의 성능 개선을 할 수 있었습니다.
4. Kotlin 관련 N+1 개선
Kotlin
으로 개발을 진행하는 도중 N+1
이 발생하지 않아야 하는 경우에도 N+1
문제가 발생하는 문제가 있었습니다. 분명히, 연관 관계의 fetch Mode를 LAZY
로 설정을 하였고, movie를 참조하는 코드 또한 존재하지 않았습니다. 하지만 우측의 그림과 같이 계속해서 N+1이 발생했습니다.
Kotlin에서는 기본적으로 프록시를 만들 수 없다
JPA에서는 프록시
객체를 만들어 제공함으로 써, 지연로딩
을 제공합니다.
https://kotlinlang.org/docs/inheritance.html
하지만, 위의 Kotlin 공식 문서를 확인하면, 기본적으로 Kotlin의 모든 클래스는 final
로 정의가 된다고 합니다.
따라서, JPA에서 Entity의 프록시를 만들 수 없기 때문에
, 모든 프로퍼티가 설정된 엔티티를 만들고, 이 때문에 N+1
이 발생하는 것이었습니다.
하지만, 그렇다고 개발하는 entity들마다 class와, method에 open을 붙이는 것은 번거롭고 에러가 발생할 확률이 높아졌습니다.
plugins {
...
// all open
kotlin("plugin.allopen") version "1.4.32"
}
allOpen {
annotation("javax.persistence.Entity")
annotation("javax.persistence.Embeddable")
annotation("javax.persistence.MappedSuperclass")
}
앞선 문제는, all open
plugin 을 이용해서 해결할 수 있었습니다. 위 와 같이 플러그인을 적용하면, allOpen
내부에 정의된 annotation들에는 open
키워드가 적용되어 JPA에서 proxy 객체 생성을 할 수 있습니다.
결과적으로 불필요한 N+1 문제를 줄일 수 있었습니다.
'Application Knowhow > Server' 카테고리의 다른 글
ApplicationKnowhow/Server - API 성능 개선기 2 (Query 개선) (0) | 2021.11.09 |
---|---|
ApplicationKnowhow/Server - 게시판 조회수 기능 성능 최적화 (4) | 2020.11.23 |
ApplicationKnowhow/Server - 사용자 입력 값 검증방법과 노하우 (0) | 2020.07.01 |
ApplicationKnowhow/Server - 공인 IP없이 외부에서 접속 가능하게 만들기(Ngrok란?) (0) | 2020.06.15 |
ApplicationKnowhow/Server - Image와 Json을 함께 서버에 업로드하는 대표적 3가지 방법 (0) | 2020.04.16 |