DATA JPA + Transaction 성능 최적화

DATA JPA + Transaction 성능 최적화

JPA의 기본 사항을 정리한다.

DATA JPA + Transaction 성능 최적화

데이터 레이어에서 성능이란?

좋은 성능이란, 초당 트랜잭션 처리량이다. 장기 실행 트랜잭션보다 많고 짧은 트랜잭션으로 구축해야 한다.
그러나 ACID최적화와 MVCC 에러에 대처는 가능해야 한다.


먼저 Transactional 작동 특징과 주의사항에 대해 알아보자.

Transactional(Read-Write)

엔티티와 하이버네이트 상태를 모두 로드한다. 엔티티는 MANAGED 상태이다.

  1. 더티체킹 기능이 가능하다.
    • flush 시점에 페치 당시 하이드레이트 상태(Object[]로 매핑해옴) 와 변경점을 비교하여 UPDATE 트리거를 수행해준다.
  2. 버전 없는 낙관적 잠금 메커니즘 가능하다.
    • 이 또한 하이드레이트 상태를 이용하여 버전 없이 낙관적 잠금 메커니즘을 할 수 있게 도와준다.
  3. 하이드레이트 상태를 활용해 2차 캐시 항목 표현 필터링을 위한 where를 구성한다.

Transactional(Read-Only)

1차캐시에 read-Only 엔티티만 로드한다. 하이드레이트 상태가 로드되지 않기 때문에 위의 3가지 기법 사용이 불가능하다.

DTO Projection으로 가져오는 경우는 이 1차캐시도 바이패스하기 때문에 더 성능이 좋다.

스프링 5.1 미만에서는 읽기 전용도 하이드레이트 객체를 로드하는데, 스프링 FlushType이 MANUAL이라서 더티체킹을 하진 못한다.
그러나 GC가 하이드레이트 객체를 청소해야 해서 성능이 저하된다.

@Transactional 선언하지 않은 상황은 어떻게 될까?

먼저 명시적으로 Transactional을 선언하지 않는 경우 어떻게 작동될까?

쓰기 연산에서는 예외를 발생시키지만, 읽기 연산에서는 아래와 같이 작동한다.

  1. auto-commit 모드가 True로 설정된다.
    • 각 SQL마다 분리된 물리적 DB Connection으로 실행되어야 함을 의미한다. 이는 오버해드로 성능 저하를 야기한다.
  2. 읽기 전용 SQL문에 대해 ACID가 지원되지 않는다.
  3. 앞에 나온 DB Connection 지연 획득 최적화를 사용하지 못한다.
    • auto-commit 모드를 true로 설정해놓기 때문이다.
  4. readOnly로 선언하여 쓰기 처리를 하지 말라는 선언을 할 수 없다. (협업시)


선언하지 않은 비 트랜잭션 콘텍스트는 물리적 Db 트랜잭션이 없는게 아니라 명시적인 ‘경계’ 를 설정하지 않은 것이다.

그래서 명시적인 @Transactional을 선언해줘야 한다.

시작과 끝을 명확히 구분 짓고, 트랜잭션 내의 모든 SQL 문은 하나의 데이터베이스 연결과 영속성 컨텍스트에서 실행되어야 한다.
그래야 일관성과 무결성을 보장한다.
무엇보다 나의 의도에 맞춰 명시적인 설정을 해줘야 한다.

결론적으로 @Transcational으로 명시적인 컨텍스트를 잡아주는것이 좋다.
읽기 전용 데이터의 경우 ReadOnly를 쓰면 하이드레이트 상태를 로드하지 않아 성능상 이점이 있다.
DTO를 통해 가져오면 더 좋은데, 영속성 컨텍스트를 바이패스하기 때문이다.

[문제 상황] @Transactional 선언을 해도 무시되는 경우도 있다?

다음과 같은 상황에서는 명시적인 @Transactional을 선언해도 스프링이 무시해버린다.

  • private, protected, package-protected 메서드에 선언한 경우
  • 메서드가 동일한 클래스 내부에서 호출된 경우
    @Transactional(timeout = 10)
    public void mainAuthor() {
        Author author = new Author();
        System.out.println(authorRepository.count());
        persistAuthor(author);
        notifyAuthor(author);
    }

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    protected long persistAuthor(Author author) {
        authorRepository.save(author);
        return authorRepository.count();
    }

    private void notifyAuthor(Author author) {
        log.info(() -> "Saving author: " + author);
    }

예상 동작으로는 persistAuthor 메서드가 실행될 때

    public void mainAuthor() {
        Author author = new Author();
        persistAuthor(author);
        //helperService.persistAuthor(author);
        notifyAuthor(author);
    }

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public persistAuthor(Author author) {
        authorRepository.save(author);
        return authorRepository.count();
    }

    public void notifyAuthor(Author author) {
        log.info(() -> "Saving author: " + author);
    }
  • 호출된 동일한 클래스에 정의된 메서드에 추가된 경우
  • save시와 count 제각기 트랜잭션이 추가됐다. (메서드 단위로 하나로 안묶임)
Creating new transaction with name [org.springframework.data.jpa.repository.support.SimpleJpaRepository.save]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT
Opened new EntityManager [SessionImpl(591853434<open>)] for JPA transaction
On TransactionImpl creation, JpaCompliance#isJpaTransactionComplianceEnabled == false
begin
Exposing JPA transaction as JDBC [org.springframework.orm.jpa.vendor.HibernateJpaDialect$HibernateConnectionHandle@21780905]
    
[Hibernate] 
    insert 
    into
        author
        (age, genre, name, id) 
    values
        (?, ?, ?, default)

Initiating transaction commit
Committing JPA transaction on EntityManager [SessionImpl(591853434<open>)]
committing
Closing JPA EntityManager [SessionImpl(591853434<open>)] after transaction
Creating new transaction with name [org.springframework.data.jpa.repository.support.SimpleJpaRepository.count]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT,readOnly
Opened new EntityManager [SessionImpl(2088366799<open>)] for JPA transaction
On TransactionImpl creation, JpaCompliance#isJpaTransactionComplianceEnabled == false
begin
Exposing JPA transaction as JDBC [org.springframework.orm.jpa.vendor.HibernateJpaDialect$HibernateConnectionHandle@63551c66]
    
[Hibernate] 
    select
        count(*) 
    from
        author a1_0
Initiating transaction commit
Committing JPA transaction on EntityManager [SessionImpl(2088366799<open>)]
committing
Closing JPA EntityManager [SessionImpl(2088366799<open>)] after transaction
Saving author: Author{id=1, age=0, name=null, genre=null}
Closing JPA EntityManagerFactory for persistence unit 'default'

해결책 1. 단일 트랜잭션 사용

    @Transactional
    public void mainAuthor() {
        Author author = new Author();
        authorRepository.save(author);
        authorRepository.count();
        //persistAuthor(author); 로 메서드를 호출해도 동일한 결과이다. (persistAuthor에 @Transactional(propagation = Propagation.REQUIRES_NEW)이 붙어도)
        notifyAuthor(author);
    }
  • 정상적으로 1개의 트랜잭션 내에서 실행되는 모습이다.
Creating new transaction with name [com.example.practicepersistancelayer.chapter6.DelayConnection.service.IgnoreTransactionalTest.mainAuthor]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT
Opened new EntityManager [SessionImpl(1936689207<open>)] for JPA transaction
On TransactionImpl creation, JpaCompliance#isJpaTransactionComplianceEnabled == false
begin
Exposing JPA transaction as JDBC [org.springframework.orm.jpa.vendor.HibernateJpaDialect$HibernateConnectionHandle@66a8ff6d]
Found thread-bound EntityManager [SessionImpl(1936689207<open>)] for JPA transaction
Participating in existing transaction
    
[Hibernate] 
    insert 
    into
        author
        (age, genre, name, id) 
    values
        (?, ?, ?, default)

Found thread-bound EntityManager [SessionImpl(1936689207<open>)] for JPA transaction
Participating in existing transaction
        
[Hibernate] 
    select
        count(*) 
    from
        author a1_0
        
Saving author: Author{id=1, age=0, name=null, genre=null}
Initiating transaction commit
Committing JPA transaction on EntityManager [SessionImpl(1936689207<open>)]
committing
Closing JPA EntityManager [SessionImpl(1936689207<open>)] after transaction
Closing JPA EntityManagerFactory for persistence unit 'default'

해결책 2. 타겟 트랜잭션 외부 클래스로 분리

    @Transactional
    public void mainAuthor() {
        Author author = new Author();
        authorRepository.count();
        helperService.persistAuthor(author);
        notifyAuthor(author);
    }
Creating new transaction with name [com.example.practicepersistancelayer.chapter6.DelayConnection.service.IgnoreTransactionalTest.mainAuthor]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT
Opened new EntityManager [SessionImpl(698263942<open>)] for JPA transaction
On TransactionImpl creation, JpaCompliance#isJpaTransactionComplianceEnabled == false
begin
Exposing JPA transaction as JDBC [org.springframework.orm.jpa.vendor.HibernateJpaDialect$HibernateConnectionHandle@3aed692d]
Found thread-bound EntityManager [SessionImpl(698263942<open>)] for JPA transaction
Participating in existing transaction
    
[Hibernate] 
    select
        count(*) 
    from
        author a1_0
        
Found thread-bound EntityManager [SessionImpl(698263942<open>)] for JPA transaction
Suspending current transaction, creating new transaction with name [com.example.practicepersistancelayer.chapter6.DelayConnection.service.IgnoreTransactionOuterService.persistAuthor]
Opened new EntityManager [SessionImpl(60426688<open>)] for JPA transaction
On TransactionImpl creation, JpaCompliance#isJpaTransactionComplianceEnabled == false

    begin
    Exposing JPA transaction as JDBC [org.springframework.orm.jpa.vendor.HibernateJpaDialect$HibernateConnectionHandle@3dbf3bc]
    Found thread-bound EntityManager [SessionImpl(60426688<open>)] for JPA transaction
    Participating in existing transaction
    
[Hibernate] 
    insert 
    into
        author
        (age, genre, name, id) 
    values
        (?, ?, ?, default)

    Found thread-bound EntityManager [SessionImpl(60426688<open>)] for JPA transaction
    Participating in existing transaction
        
[Hibernate] 
    select
        count(*) 
    from
        author a1_0
        
    Initiating transaction commit
    Committing JPA transaction on EntityManager [SessionImpl(60426688<open>)]
    committing
    Closing JPA EntityManager [SessionImpl(60426688<open>)] after transaction
Resuming suspended transaction after completion of inner transaction
Saving author: Author{id=1, age=0, name=null, genre=null}
Initiating transaction commit
Committing JPA transaction on EntityManager [SessionImpl(698263942<open>)]
committing
Closing JPA EntityManager [SessionImpl(698263942<open>)] after transaction
Closing JPA EntityManagerFactory for persistence unit 'default'

이제 무시되지 않고 의도대로 호출부 트랜잭션 (내부 트랜잭션 new ) 으로 작동하는 모습이다.

또 한가지로 호출되는 method에는 @Transactional을 붙이지 않아도 호출부의 Transactional을 따라가는 모습이다.

    @Transactional
    public void mainAuthor() {
        Author author = new Author();
        System.out.println(authorRepository.count());
        helperService.persistAuthor(author);
        notifyAuthor(author);
    }
//외부 클래스의 메서드, No Transactional 설정
    public long persistAuthor(Author author) {
        authorRepository.save(author);
        long count = authorRepository.count();
        System.out.println(count);
        return count;
    }

그러나 어지간하면 외부 클래스의 메서드로 분리해도 @Transactional 붙여주는게 옳다.


@Transactional 옵션인 timeout 테스트

@Transactional(timeout = 10) 을 서비스 메서드에 붙였다고 생각해보자.
보통 생각하는 오해로는 총 서비스 메서드의 작동 시간이 10초를 넘으면 timeout이 날 것이라는 생각이다.

@Transactional(timeout = 10)
public void newAuthor() throws InterruptedException{
    ///
    authorRepository.saveAndFlush(author);
    Thread.sleep(15_000)//15초
}

그러나 우리 예상과는 다르게 메서드는 15초가 지난 후 commit 된다.
Thread.sleep은 Spring의 트랜잭션 매니저가 시간을 감지하지 않는다.
즉, 메서드의 지연시간과 timeout 시간은 관계가 없다.

데이터베이스 지연 과 관계가 있다.
위 경우 saveAndFlush가 10초 이내에 작동해라와 같은 말이다.

timeout 전역설정

spring.transaction.default-timeout: 10

성능 개선 방법 1. DB Connection 최적화

각 트랜잭션은 DB Connection을 맺게 되는데, 기본 Resource-local(단일 Datasource)의 경우 스프링에서 @Transactional 어노테이션이 지정된 메서드의 호출 직후에 바로 DB Connection을 획득한다.

현재 트랜잭션의 첫 번째 JDBC 구문이 실행될 때 까지 DB Connection은 열려있을 필요가 없다. 이를 JDBC 구문이 실행될 때 Connection을 가지게 하는게 최적화 방법 중 하나다.

하이버네이트 5.2.10 이상인 경우에만 적용 가능하다.

# disable auto-commit
spring.datasource.hikari.auto-commit: false
spring.jpa.properties.hibernate.connection.provider_disables_autocommit: true


# Enable logging for HikariCP to verify that it is used
logging.level.org.hibernate.orm.jdbc.bind: TRACE
logging.level.com.zaxxer.hikari.HikariConfig: DEBUG
logging.level.com.zaxxer.hikari: TRACE
  • hikari와 hibernate connection provider의 auto-commit 설정을 false로 꺼주면 실제 JDBC 구문이 나올 경우에 Connection을 가져온다.
    @Transactional
    public void doTimeConsumingTask() throws InterruptedException {
    //보통의 Transactional이 DB Connection을 획득하는 지점
        System.out.println("Waiting for a time-consuming task that doesn't need a database connection ...");
        Thread.sleep(40_000);

        System.out.println("Done, now query the database ...");
        System.out.println("The database connection should be acquired now ...");

        //지금의 설정이 DB Connection을 획득하는 지점
        Author author = authorRepository.findById(1L).get();
        Thread.sleep(40_000);

        author.setAge(44);
    //Connection이 닫히는 시점은 Transactional의 닫히는 시점과 똑같다.
    }


--file: `DBConnection 획득 시점 로그`

Waiting for a time-consuming task that doesn't need a database connection ...
HikariPool-1 - Pool stats (total=10, active=0, idle=10, waiting=0)
HikariPool-1 - Fill pool skipped, pool has sufficient level or currently being filled (queueDepth=0).
Done, now query the database ...
The database connection should be acquired now ...
[Hibernate] 
    select
        a1_0.id,
        a1_0.age,
        a1_0.genre,
        a1_0.name 
    from
        author a1_0 
    where
        a1_0.id=?

HikariPool-1 - Pool stats (total=10, active=1, idle=9, waiting=0)
HikariPool-1 - Fill pool skipped, pool has sufficient level or currently being filled (queueDepth=0).

[Hibernate] 
    update
        author 
    set
        age=?,
        genre=?,
        name=? 
    where
        id=?


Closing JPA EntityManagerFactory for persistence unit 'default'
HikariPool-1 - Shutdown initiated...
HikariPool-1 - Before shutdown stats (total=10, active=0, idle=10, waiting=0)
  • 쿼리가 나가는 시점에 HikariPool에서 active count가 1로 증가한 모습
  • 종료 시점은 동일하다.

이로써 쿼리가 불필요한 로직은 최상단으로 몰거나 분리하고, 하위에 DB Connection이 필요한 작업을 놓아 짧게 DB Connection을 유지한다.
최 상단에 조회부터 시작하면 소용없으니 참고할 것.

왜 AutoCommit설정이 지연 Db Connection과 상관이 있나요?

하이버네이트는 db 연결의 autoCommit설정을 트랜잭션 시작시 최초 확인을 해야해서 이 때 DB Connection이 열리게 된다.


성능 개선 방법 2. Repository Interface 단위에서 @Transactioanl 선언

  • Repository interface단위에 @Transactional(readOnly = true) 지정
  • DML 메서드에 @Transactional 오버라이드
    • 서비스 메서드 @Transactional 미지정시에도 방어해주기도 한다.
    • 개별 Repository Query단위로 트랜잭션 적용이 용이해진다.(save, findById에는 이미 있다.)
    • 이미 제공되는 SimpleJpaRepository 구현 방식이기도 하다.

성능 개선 방법 3. 각 서비스 메서드 검토

서비스 메서드의 기능과 로직에 따라 @Transactioanl을 검토해봐야 한다.
핵심은 장기 트랜잭션을 방지하고 ACID 최적화를 위한 지침이다.

사례 1. 일반적인 트랜잭션 상황

@Transactional
public void test(){
    Author author = authorRepository.fetchByName("hyun");
    author.setGenre("History");
    
   //Exception 로직 
}
  • Exception시에 메서드 단위로 롤백이 잘 되는지 고려
  • 한 트랜잭션 컨텍스트에서 Fetch 후 Update까지 잘 되는가 고려
  • ACID 특성의 장점을 잘 이용했는가 고려

사례 2. 쿼리 전 지연로직 (단순 1회 쿼리시)

@Transactional(readOnly = true)
void test() {
    //40초짜리 지연 로직 (쿼리 없음)
   authorRepository.fetchByName("hyun");//sql 최초 실행
}

메서드가 실행하면서 Transaction이 함께 실행되고, 즉시 DB Connection을 획득하여 Open 상태로 지연된다.
이런 상황에 경우 MVCC에 불리한 장기 지연 트랜잭션이 된다.

  • Service에 @Transactional을 제거하고 Repository에 붙어있는 것으로 처리

사례 3. 여러 쿼리를 사용하는 트랜잭션 작업 단위

ACID 특성을 지키기 위해 Service 메서드에 무조건 붙여야 하는 상황은 어떻게 해야 할까?

기본적으로 Repository 단위에 붙여놨으면 Service 실행시에 생긴 트랜잭션에 Repository가 가진 트랜잭션들을 참여시킨다.
기본 전파 속성이 Propagation.REQUIRED이기 때문이다.
Participating in existing transaction 로그가 남는 것을 확인할 수 있다.

먼저, 고려해야 할 점은 지연 DB Connection을 쓰더라도 한 번 Connection이 열리면 트랜잭션 종료까지 열려있다.
그래서 무거운 비즈니스 로직을 각 쿼리 사이에 집어넣는 일은 없어야 한다.
되도록 앞으로 쿼리가 필요없는 무거운 로직을 몰아넣고 쿼리를 메서드 실행순서 뒤로 미루던지, 메서드 분리도 답이 될 수 있다.

지연 커넥션도 소용 없이 Service에서 @Transactioanl을 빼는 경우

void Royalty 로얄티 계산()  {
    Author author = authorRepository.fetchByName("hyun");
    //오래걸리는 외부 API 계산 호출
   return author와 함께한 로얄티 계산 로직;
}
  • 서비스 메서드에서 트랜잭션을 제거해줘 authorRepository 단위로 트랜잭션이 실행되고 종료된다.
  • 읽기 전용이라 롤백의 리스크도 없다.

DB와 상호작용하지 않지만 Exception 가능성이 높은 경우

void 에러나기쉬움(){
    //Exception 유발 조직
   //SAVE 혹은 DML 쿼리
}
  • 단순히 작동 순서를 수정하는 것과 지연 DB Connection 획득으로 트랜잭션을 짧게 유지했다.

1:N관계 Cascade ALL or Persist와 Service 메서드의 경우

void AuthorAndBooks(){
    Author author = new Author();//부모
   Book b1 = new Book();
   Book b2 = new Book();
   
   author.addBook(b1);
   author.addBook(b2);
   
   authorRepository.save(author);
}
  • 3개의 INSERT를 발생시키는데, 굳이 @Transactioanl을 붙이지 않아도 된다.
  • 이유는 이미 전이가 사용되었기 때문에 논리적으로 묶여있다.

[중요] 보통의 조회/수정/저장 + 조회 쿼리 정보를 사용한 무거운 비즈니스 로직

//file :`문제상황 긴 트랜잭션`
@Transactional
void 껴있는경우(){
    //authorRepository Fetch 조회 쿼리
   
   //fetch 데이터를 사용하는 무거운 비즈니스 로직
   
   //DML 저장 로직
}

이 상황에서는 서비스 메서드에 @Transactional을 쓰지 않고, fetch와 저장로직의 트랜잭션을 분리하는게 좋다.
그럴 경우 위 코드에서 예상되는 문제로 SELECT와 UPDATE사이에 다른 트랜잭션에 의해 수정될 수 있는 문제가 있다.

  • 버전 기반 낙관적 락을 설정하고, @Retry를 사용한다.
  • @Retry@Transactional과 사용 불가하기 때문에 아주 적절하다고 볼 수 있다.
//file: `MVCC 와 간결 트랜잭션으로 개선된 코드`
@Retry(times=10, on=OptimisticLockingFailureException.class)
void 껴있는경우(){
   //authorRepository Fetch 조회 쿼리

   //fetch 데이터를 사용하는 무거운 비즈니스 로직

   //DML 저장 로직
}

결론

트랜잭션 지속기간과 동작을 최대한 짧고 간결하게 유지해야 한다.
제시한 해결책 말고도 각자의 판단에 맞게 무궁무진하게 해결을 할 수 있다고 본다.

  • DB Connection 획득시 트랜잭션 종료까지 열려있음 주의
  • Controller, Service의 클래스 단위에서 @Transactional 선언은 하지 말자.
    • 불필요한 메서드에 트랜잭션과 DB Connection 발생 위험이 있다.

https://github.com/HomoEfficio/dev-tips/blob/master/JPA-GenerationType-별-INSERT-성능-비교.md