어플리케이션 서버의 성능을 높이려는 노력은 어느 코드 레벨에서나 있습니다. 코드레벨, DB레벨 등 다양하죠.
요즘 DB 관련해서 공부를 하고 있는데, 오늘은 데이터베이스 커넥션 관련해서 성능을 높여주는 데이터베이스 커넥션 풀에 관해 이야기해보려 합니다.
그리고 Spring에서 채택하고 있는 HikariCP에 관해서도 살펴보겠습니다.
DBCP(Database Connection Pool)은 데이터베이스 연결을 관리하고 효율적으로 재사용하기 위한 자바 라이브러리 또는 프레임워크입니다. DBCP는 데이터베이스와의 연결 생성 및 관리를 단순화하며, 다수의 클라이언트 또는 스레드에서 동시에 데이터베이스 연결을 사용할 때 발생할 수 있는 리소스 부족 문제를 방지하는 데 도움을 줍니다.
어플리케이션 서버에서 Spring 어플리케이션이 돌아가는데요. 이때 유저의 요청을 받은 Spring은 connection pool을 통해 데이터베이스와 미리 연결되어 있는 connection을 받습니다.
"미리 연결"되어 있다고 했는데, 이 부분이 connection pool의 존재 이유이고 성능을 높일 수 있는 핵심이라고 생각합니다.
그렇다면 왜 database connection을 미리 연결해두는 것일까요?
쿼리의 동작 방식을 생각해보겠습니다. Spring에서 쿼리를 작성하면, 그 쿼리는 데이터베이스에 명령을 보내고 쿼리문을 통해 원하는 데이터를 전달받을 것입니다. 이때 데이터베이스와의 connection을 통해 데이터베이스로 요청을 전달하고 데이터베이스한테 정보를 응답받습니다. 이 connection은 TCP 통신을 통해 이루어집니다. TCP 특징상 연결 시에 3-handshake를 진행하고 연결 해제 시에 4-handshake를 진행합니다. TCP의 이런 특징 때문에 connection의 연결과 해제 시에 많은 시간 비용이 듭니다.
connection pool은 데이터베이스와의 연결을 효율적으로 관리하는 데 도움을 준다고 했는데요.
데이터베이스와 TCP 연결을 미리 해놓은 connection들을 connection pool에 저장함으로써 속도에 대한 성능을 챙기게 됩니다.
따라서 connection pool의 존재 덕분에 Java 스레드는 connection을 연결하거나 연결 해제하지 않고, 연결되어있는 connection을 connection pool로부터 받기만 하면 됩니다. 작업이 완료되면 connection을 connection pool에 반납하는 방식이죠.
데이터베이스 Connection은 Spring과 Database를 연결하는 작업이기 때문에 개발자는 Spring 관련 설정과 Database 설정 모두 알고 있어야 합니다. 우선 Database 설정부터 알아보겠습니다.
Database 입장에서는 부하를 방지하기 위해 최대 연결 가능한 수를 제한할 필요가 있습니다. 이 제한을 max_connections
라고 합니다.
Connection Pool의 connection 수와 max_connections
수의 설정을 바꿔가며 상황을 살펴보겠습니다.
(Connection Pool에 저장할 수 있는 최대 connection 수를 HikariCP에서는 maximumPoolSize
라고 하는데, 편의상 maximumPoolSize
로 계속 지칭하겠습니다.)
max_connections
가 더 크거나 같은 경우에는 Database 측에서 낭비되고 있는 리소스가 생깁니다. 만약 낭비되는 리소스가 많다면 서버 비용이 더 부담되겠죠... HikariCP에서는 권장하지 않는 방법이라고 합니다.
그림처럼 connection pool에서 연결되지 못한 connection이 존재하게 됩니다. Database 입장에서는 최대 허용 기준치가 찼기 때문에 요청을 거절한 것입니다. 이런 경우에는 connection pool에 최대 4개의 연결을 허용했다고 하더라도 3개까지만 연결되는 현상이 발생합니다.
만약 max_connections
수치가 maximumPoolSize
보다 크면 리소스 낭비가 일어날 수 있다고 윗 부분에서 언급했는데요. 이것 말고도 다른 방법으로도 리소스 낭비가 일어날 수 있습니다.
만약 한 Connection이 중간에 비정상적으로 끊기는 경우가 그런 경우입니다. Database 입장에서는 한 Connection에 요청이 들어오지 않을 뿐 close에 대한 요청이 없었기 때문에 connection이 유지되고 있다고 생각할 것입니다. 실제로 요청을 보내고 있지 않은 경우라면 상관 없지만, 오류로 인해 연결이 끊긴지 모르는 것은 문제가 됩니다.
그래서 MySql에는 wait_timeout
이라는 옵션이 존재합니다. 이름에서 유추할 수 있듯이, Database에서 스프링 어플리케이션 connection의 대기 시간을 설정하는 변수입니다. 이 변수는 클라이언트와 Database 서버 간의 connection이 유효한 상태로 유지되는 시간을 초 단위로 지정합니다. 만약 클라이언트가 지정된 시간 동안 서버와 통신하지 않으면, 데이터베이스 서버는 해당 connection을 끊습니다. 그로 인해 connection 오류에 대한 해결을 할 수 있는 것이죠.
또, 만약 wait_timeout
을 60초로 설정했다고 가정했을 때 60초 안에 요청이 들어오면 Database는 시간 count를 다시 0초로 초기화시킵니다. 이렇게 요청이 계속 오면 60초가 지났다 하더라도 연결이 끊어지지 않고 계속 같은 연결된 상태로 있을 수 있는 것이죠.
이번에는 Spring Application 에서 Connection Pool을 설정하는 방법에 대해 알아보겠습니다.
앞으로 서술할 내용들은 Spring 2.0부터 지금까지 채택되고 있는 HikariCP 기준으로 작성할 예정입니다.
위에서 잠깐 살펴보았는데요. Connection Pool에 들어갈 수 있는 최대 connection 수를 의미합니다.
Connection Pool에서 유지하는 최소한의 idle connection 수를 의미합니다. (idle connection == 일 없는 connection)
Connection Pool은 일반적으로 최소한의 idle connection을 유지하여, 스프링 어플리케이션이 Database와의 연결을 필요로 할 때 추가 연결을 생성하는 시간과 비용을 절감하도록 도와줍니다. 예를 들어, minimumIdle
을 5로 설정하면 Connection Pool은 항상 최소 5개의 idle Connection을 유지하려고 노력합니다.
즉, TCP 연결이 끊기지 않도록 노력하는 connection 수 입니다.
그렇다면 의문이 생깁니다. 앞전에 Database에는 wait_timeout
이 존재한다고 했는데요. wait_timeout
이 60초로 설정되어있다고 가정하고, 60초동안 유저가 아무 요청도 보내지 않으면 결국 모든 connection이 끊어지는 것이 아닌가 의심이 들었습니다. HikariCP 코드를 보니 아래와 같이 health check를 하는 부분이 있었습니다. 주기적으로 health check를 하면서 idle connection을 유지하고 있었습니다.
자세한 스케줄링까지는 검색이 되지 않아 살펴볼 수 없었지만 해당 메소드를 통해 주기적으로 health check를 하는 요청을 Database로 보내고 있다는 것은 알 수 있었습니다.
maximumPoolSize
와 minimumIdle
을 커스텀 설정할 때 주의해야하는 부분이 있습니다. minimumIdle
은 절대로 maximumPoolSize
보다 크면 안됩니다. maximumPoolSize
가 minimumIdle
보다 우선 순위가 높기 때문인데요. 예시를 통해 조금 더 자세하게 살펴보겠습니다.
maximumPoolSize
를 4, minimumIdle
을 2로 설정했다고 가정하겠습니다.
minimumIdle
은 2이기 때문에 아무 요청이 없다면 위 그림과 같이 2개의 connection이 존재할 것입니다.
만약 이때 getConnection()
요청이 들어오면 어떻게 될까요?
Database와 연결되어있는 connection을 java thread에 빌려줍니다. 이 때, minimumIdle
이 위반되기 때문에 Connection Pool은 재빠르게 새로운 connection을 연결합니다.
그러면 만약 Connection Pool에 maximumPoolSize
인 4까지 다 채워지게 된다면 어떻게 될까요?
maximumPoolSize
가 4이기 때문에 이를 넘는 connection은 만들지 않습니다. 이 때 minimumIdle
는 2가 되어야하는 규칙은 maximumPoolSize
에 밀려 무시됩니다!
또 정말 중요한 점이 있습니다. HikariCp에서는 maximumPoolSize
와 minimumIdle
를 같게 두는 것을 권장합니다.
This property controls the minimum number of idle connections that HikariCP tries to maintain in the pool. If the idle connections dip below this value and total connections in the pool are less than maximumPoolSize, HikariCP will make a best effort to add additional connections quickly and efficiently. However, for maximum performance and responsiveness to spike demands, we recommend not setting this value and instead allowing HikariCP to act as a fixed size connection pool. Default: same as maximumPoolSize
트래픽이 확 몰리는 경우에 대비하기 위해 이렇게 default로 두는 것을 권장한다고 하네요.
Connection Pool에서 connection의 최대 수명을 뜻합니다. maxLifeTime
을 60초로 잡았다고 가정을 한다면 60초마다 새로운 connection을 만든다는 이야기입니다. 그렇다면 굳이 왜 살아있는 connection을 재설정하는걸까요?
크게 2가지 이유가 있습니다.
Connection Pool 내부에 있는 idle Connection은 60초로 설정된 maxLifeTime
이 지나면 새로 connection을 교체하는데, java thread가 사용하고 있다면 java thread로부터 반납 받아야 connection을 새롭게 설정할 수 있습니다.
그러면 여기서 문제점이 보이실텐데요. 만약 java thread의 오류로 connection을 계속 점유하고 있다면 어떻게 될까요?
Database의 wait_timeout
이 동작하여 connection이 끊어지게 될 것입니다.
그런데 만약 정말 오래걸리는 작업이어서 connection이 늦게 Connection Pool로 반환된 경우도 있겠죠. 그런 경우에는 끊어진 connection 이기 때문에 예외가 발생하게 됩니다.
따라서 Connection Pool로 잘 반환할 수 있도록 하는 것이 정말 중요하겠죠. 또 만약 admin을 위한 통계 기능 어플리케이션의 경우 넉넉하게 Database와 어플리케이션 모두 timeout을 넉넉하게 두면 좋을 것 같습니다.
HikariCP에서는 maxLifeTime
은 Database나 인프라의 timeout보다 몇 초 더 짧게 설정하는 것을 권장하고 있습니다.
This property controls the maximum lifetime of a connection in the pool. An in-use connection will never be retired, only when it is closed will it then be removed. On a connection-by-connection basis, minor negative attenuation is applied to avoid mass-extinction in the pool. We strongly recommend setting this value, and it should be several seconds shorter than any database or infrastructure imposed connection time limit. A value of 0 indicates no maximum lifetime (infinite lifetime), subject of course to the idleTimeout setting. The minimum allowed value is 30000ms (30 seconds). Default: 1800000 (30 minutes)
왜 그럴까요? 한 번 maxLifeTime
과 wait_timeout
을 60초로 같게 설정해보겠습니다. 그리고 Database와 어플리케이션 모두 0초에서 타이머를 시작했다고 가정하겠습니다.
만약 59.9초에 쿼리 요청을 Database로 전달하면 maxLifeTime
에 위배되지 않기 때문에 성공적으로 전송됩니다. 하지만 전송 도중에 60초가 땡하고 울리게되면 wait_timeout
에서 connection 자원을 정리할 것입니다. 결국 쿼리가 도착하면 connection 자원이 되었기 때문에 요청이 성공하지 않을 것입니다.
타이밍이 아주 잘 맞아야하는 경우긴 하지만 이를 방지하기 위해 HikariCP는 maxLifeTime
을 wait_timeout
보다 몇 초 작게 설정하라고 권장하고 있었던 겁니다.
Java thread가 Connection Pool에서 connection을 구하기 위해 대기하는 시간입니다. 이미 Connection Pool의 모든 connection이 사용중일 때 발생합니다. HikariCP는 default를 30초로 잡아두었는데, 이렇게 되면 30초동안 사용자는 아무 응답도 받지 않고 그저 로딩 화면만 보고 대기하게 됩니다. 저는 티켓팅이나 수강신청을 할 때 대기중인 화면이 바로 떠올랐습니다.
서비스에 따라 다르겠지만 티켓팅이나 수강 신청과 같은 서비스가 아니라면 30초보다 적게 설정하는 것이 좋을 것 같습니다. 유저를 대기시키기보다는 요청이 많다는 에러 메시지를 보여주는 것이 더 바람직해보이기 때문입니다.
변수를 적절하게 설정해보기 위해 미리 HikariCP에 대해 학습해보았습니다. 적절한 connection 변수를 찾는 방법도 부하테스트를 통해 알아보도록 하겠습니다.