진짜 개발자
본문 바로가기

Application Knowhow/Server

ApplicationKnowhow/Server - API 성능 개선기 1 (JPA 성능 개선(kotlin))

728x90

이번 부하테스트 대상 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가 발생한다고 합니다.

  1. JPQL 호출
  2. entitymanager의 flush() 메소드를 직접 호출
  3. 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 문제를 줄일 수 있었습니다.