글 작성자: Key Ryung

개요

이전 포스팅에서 auto commit 설정을 통해 Connection의 획득을 지연시키는 설정을 추가하여 성능을 개선하려고 했습니다. 하지만 이 부분에 있어서 간과한 것이 있었습니다. Auto Commit 전에 이미 Connection이 필요하다면 어떻게 될까요? 당연히 지연된 획득이 아니라 기존과 동일하게 작동할 것입니다. 이전 포스팅에서는 Auto Commit을 기준으로 Bean들을 살펴보았다면 이번 포스팅에서는 Transaction부터 HikariCP까지의 동작을 살펴보면서 Auto Commit을 적용했을 때 달라지는 부분과 LazyConnectionDataSourceProxy의 동작도 살펴보겠습니다.

 

Transaction부터 HikariCP까지

이전 포스팅에서도 Connection을 언제 가지고 오는지에 대한 질문은 마찬가지로 있었지만 auto commit을 설정 시에 Connection이 필요하다는 사실만 알고 Connection이 Transaction 실행 시 언제 갖고 오는지에 대한 답변은 하지 못하고 있었습니다.

 

하지만 이번 LazyConnectionDataSourceProxy의 동작을 알아보는 과정과 Jpa, Transaction, HikariCP까지 연결되는 Bean 들간의 관계를 알아보는 과정에서 알게 되었습니다.

TransactionManger부터 HikariCP까지 Bean들간의 관계도

역시 역사가 깊은 Spring 답게 OOP 원칙을 충실히 반영하여 책임을 나누고 의존성은 낮추고 응집도는 높이기 위한 구조로 관계들이 생각보다 복잡했습니다. 이전 포스팅에서 보았던 `LogicalConnectionManagedImpl` Bean도 확인할 수 있었습니다.

 

@Transactional 어노테이션은 AOP를 통해 동작합니다. AOP를 사용하여 우리의 비즈니스 로직과 Transaction 로직을 분리했다는 것은 Spring을 공부하면서 자연스럽게 알게 되는 사실입니다. 그런데 여기서 JPA, Hibernate, HikariCP까지 더해지면서 코드의 흐름을 따라가는 부분이 당황스러웠습니다. Interface로 연결되어 있지 않은데 연결점이 있어서 보니 DI를 통해 연결되어 있거나 책임을 분리했기 때문에 다른 Class로 해당 동작을 Delegate 하는 과정을 통해 @Transaction에서 DataSource까지 이어져 있었습니다.

 

다시 본론으로 돌아가 전체적인 흐름을 보면 HibernateTransactionManager(JpaTransactionManager가 될 수도 있습니다.)에서 Transaction을 시작합니다. Transaction의 동작과 관련된 블로그 포스팅을 확인하면 @Transaction 어노테이션에 진입하면 Connection을 획득한다는 사실은 적혀져있지만 어느 과정에서 Connection이 획득되는지는 명시되어 있지 않습니다.

JpaTransactionManager begin()

먼저 이전 포스팅에서 적용했던 Auto Commit 설정을 초기 상태로 돌린 뒤에 다음 코드를 통해 Connection의 상태를 확인했습니다.

getConnectionTest()는 Database와의 connection이 전혀 필요없는 메서드지만 @Transaction 어노테이션을 추가한 뒤에 동작을 시켰습니다. logConnectionStatus()에서는 hikariPool의 connection을 Monitoring 할 수 있는 MXBean을 통해 볼 수 있도록 세팅했습니다.

 

getConnectionTest()를 실행하면 다음과 같이 Active Connection이 1개, Idle Connection은 9개인 것을 볼 수 있습니다.

초기 설정

그렇다면 Auto Commit 관련 설정을 적용하면 어떻게 되었는지 보겠습니다.

Auto Commit 관련 최적화 설정

이전과는 다르게 Active Connection은 없는 것을 확인할 수 있습니다. LazyConnectionDataSourceProxy를 사용하지 않아도 @Transactional 어노테이션을 사용했을 때 Database와 상관없는 connection이 점유되지 않는 것을 확인할 수 있었습니다. 따라서 기본 Auto Commit 설정 시 auto-commit을 읽는 과정에서 connection을 갖고 오는 것이 @Transactional과 연관있는 것을 확인했습니다.

 

LazyConnectionDataSourceProxy의 동작

다음으로 LazyConnectionDataSourceProxy의 Code를 보면 Connection을 반환하는 부분에서 Proxy를 생성하여 반환하는 방식으로 동작합니다. 주석에도 나와있듯이 PreparedStatement나 CallableStatement가 JDBC Connection을 요청할 때 지연되게 connection을 갖고 온다고 되어있습니다.

LazyConnectionDataSourceProxy의 getConnection

LazyConnectionDataSourceProxy Class의 주석을 보면 다음과 같이 적혀져있습니다.

Proxy for a target DataSource, fetching actual JDBC Connections lazily, i.e. not until first creation of a Statement. Connection initialization properties like auto-commit mode, transaction isolation and read-only mode will be kept and applied to the actual JDBC Connection as soon as an actual Connection is fetched.

Statement가 생성되기 전까지 target DataSource에 대해 Proxy로 실제 JDBC Connection을 지연되게 갖고 온다. Connection의 초기화는 auto-commit 모드, transaction isolation, read-only mode 같은 속성들은 실제 connection을 갖고 오면 바로 적용된다.

 

LazyConnectionDataSourceProxy의 동작까지 살펴보면서 다음과 같은 결론을 내릴 수 있었습니다.

 

@Transaction 어노테이션을 통해 Transaction을 시작하면 여러 속성들을 통해 Connection의 획득 시점이 달라질 수 있습니다. 현재는 auto commit만을 가지고 코드를 보았지만 Transaction Isolation, Read-Only 모드를 기준으로 Code를 본다면 Connection 시점이 달라질 수 있을 것이라는 생각입니다.

 

따라서 추후 기능 확장, 추가를 고려했을 때 Lazy 한 Connection 획득을 유지하기 위해 LazyConnectionDataSourceProxy를 적용하는 것이 맞다고 판단했습니다.

 

그리고 Test...

Monitoring System 추가

이전 포스팅에서는 다루지 못했던 테스트 결과를 분석해보겠습니다. 이전 실험들과는 다르게 Pinpoint 뿐만 아니라 Database를 Monitoring 하기 위한 Prometheus와 Grafana를 추가하여 기록했습니다.

Database Server Resource Monitoring

Prometheus를 도입하여 CPU 사용량을 본 결과 Scale-Up을 한 뒤 Docker Stat을 통해 확인한 90%의 CPU 사용량은 잘 못 되었다는 것을 알 수 있었습니다. PoolSize가 20인 경우에도 20% 미만의 사용량을 보이는 것으로 보아 Scale-Up 한 Application Server도 Database의 자원을 모두 사용하지 못한다고 결론 내릴 수 있었습니다.

 

MySQL Monitoring

Max Connection의 수는 기본 설정이므로 151개이고 가장 많이 사용한 Connection의 수는 23개인 것을 확인했습니다.

 

LazyConnection 설정 후 TPS 비교

Login API Test 왼쪽 - Lazy Connection X, 오른쪽 - Lazy Connection

실험 결과 Lazy Connection을 설정함으로써 성능 향상 폭은 4% 정도로 유의미한 차이는 보이지 않았습니다. 해당 테스트를 진행하고 왜 유의미한 차이를 보이지 않는지 생각해보았습니다.

 

Connection 획득 시기 조정은 필요 없는 것인가?

먼저 처음으로 돌아가 왜 Connection의 점유시간이 길어지면 성능이 안 좋아질까요?

 

Transaction A와 Transaction B는 같은 기능을 한다고 가정합니다.

Connection의 획득시기와 Transaction

위 그림을 보면 Transaction B가 connection을 먼저 획득하고 로직들을 실행합니다. 여기서 Database와의 connection은 언제 필요할까요? 이전 포스팅부터 지금까지 보았듯이 쿼리를 수행할 때입니다. 어떤 비즈니스 로직인가에 따라 connection이 필요할 수도 있겠지만 현재는 예시를 위해 connection이 필요 없는 로직이라고 가정하겠습니다.

 

Transaction A는 connection이 진입부터 필요하므로 Transaction B가 connection을 반환할 때까지 계속 대기합니다. Transaction A 입장에서는 비즈니스 로직을 실행할 때 connection이 필요 없지만 대기하면서 비효율이 발생합니다. 따라서 auto commit 설정과 LazyConnectionDataSourceProxy를 사용함으로써 쿼리가 수행될 때 connection을 획득하도록 최적화를 진행한 것입니다.

 

최적화가 이루어지는 부분을 생각해보면 비즈니스 로직에서 소모되는 시간이 길어질수록 성능 향상 폭이 높아질 것입니다. 하지만 실험을 진행했던 로그인 API의 경우 현재 비즈니스 로직이라고 생각되는 부분이 DTO와 Entity의 변환 밖에 없습니다. 이런 점을 감안했을 때 4%의 성능 향상도 유의미하다고 생각할 수 있다고 결론 내렸습니다.

 

이제는 진짜 Scale-Out이 필요할 때!

Auto Commit 설정부터 LazyConnectionDataSourceProxy까지 적용해보면서 Transaction이 어떻게 동작하고 HikariCP까지 이어지는지 살펴볼 수 있는 기회였습니다. 더불어 Docker Stat을 통해 살펴본 결과가 Monitoring Tool의 결과가 달랐을 때 충격에 빠지기는 했지만 아직 개선할 포인트가 더 남았다는 사실에 안도하기도 했습니다.

 

Scale-Up 개선 과정에서 다뤘듯 이제는 Scale-Out으로 성능 개선이 이루어져야 하는 분기점이라고 생각되었습니다. Scale-Out을 위한 사전작업들이 이루어져야 하고 아직까지 해결하지 못한 `CannotAquireLockException`이라는 녀석의 정체를 파헤쳐 해결하여 구매에 대한 API 들도 정확하게 테스트해야 합니다.

 

그럼 다음 포스팅을 통해 Scale-Out 사전 작업과 CannotAquireLockException의 원인과 해결책을 적용해보겠습니다.