백엔드 서버와 DB 서버는 각각 다른 컴퓨터에서 동작하기 때문에 백엔드 서버에서 쿼리를 요청하고 쿼리 응답을 받는 과정은 네트워크 통신을 하는 것입니다.
백엔드 서버와 DB 서버는 TCP 기반으로 네트워크 통신을 하게 됩니다.
TCP 기반으로 동작하기 때문에 높은 신뢰성을 가지고 데이터 송수신이 가능하게 됩니다.
TCP는 연결 지향적 특징을 가지고 있습니다.
때문에 본격적으로 데이터 송수신을 하기에 앞서 꼭 둘 사이에 connection을 맺는 과정이 필요하며, 데이터 송수신을 마친 이후에 connection을 끊어주는 작업 또한 필요합니다.
그런데 이러한 connection을 맺고 끊는 과정이 생각보다 간단하지 않으며 꽤나 시간이 드는 작업입니다.
TCP 통신에서 connection을 맺기 위해 3-way-handshake를 진행하고, connection을 종료하기 위해 4-way-handshake를 진행해야 합니다.
그런데 이런 handshake 또한 데이터를 주고 받고 하는 과정이기 때문에 시간을 잡아먹게 됩니다.
때문에 백엔드 서버 입장에서는 이러한 connection을 맺고 끊고 하는 과정에서 시간적으로 비용이 계속 발생하게 됩니다.
하지만 백엔드 서버는 많은 api 요청을 받게 되며, api마다 다르겠지만 어떤 API는 DB 통신을 한 번만 하는게 아니고 여러 번 수행해야 하는 api 요청도 있을 것입니다.
이럴 때마다 백엔드 서버가 connection을 맺고 끊는 과정을 반복하게 되면 connection을 맺고 끊는데 드는 시간적 비용 때문에 서비스 성능에 좋지 못한 영향을 끼치게 됩니다.
이러한 문제를 해결하기 위해서 나온 것이 database connection pool 입니다.
백엔드 애플리케이션을 띄우면서 미리 DB connection 여러 개 만들어 놓습니다. 이렇게 만든 여러 개의 connection들을 pool에 담아서 연결된 connection들을 관리합니다.
(connection을 여러 개 만들어서 pool에 저장하고 재사용하는 방식은 네트워크 통신을 자주 하는 어떤 존재와도 사용할 수 있는 방식입니다.)
이 상태에서 백엔드 서버가 api 요청을 받게 되면 api 요청에 따른 처리를 하다가 DB 통신을 하게 되는 일이 발생하면,
DB와 새로 connection을 맺는 것이 아니고 이미 만들어 놓은 연결된 connection들 중에서 놀고 있는 connection 하나를 잡아서 가지고 오고 이 집어온 connection을 가지고 DB 통신을 합니다.
그런데 앞서 말했듯이 connection을 맺은 후에는 끊는 작업이 필요하다고 했습니다. 따라서 DB 통신 완료 후에 connection을 끊는 작업을 진행해야 합니다.
하지만 이 DB connection을 끊는 작업 대신에 사용한 connection을 connection pool에 다시 반납합니다.
그리고 백엔드 서버는 다시 api 요청에 따른 처리를 마저 하게됩니다.
이렇게 하면 DB 통신을 할 때마다 connection을 맺고 닫는 과정을 할 필요 없이 미리 여려 개의 connection을 만들어 두고 이 connection들을 재사용 할 수 있습니다.
connection을 열고 닫는 시간을 절약할 수 있게 되기 때문에 백엔드 서버는 api 요청을 받아 처리하고 응답을 하는 과정에서 시간을 절약할 수 있게 됩니다.
여기서 connection들을 담아 관리하는 pool을 database connection pool이라고 합니다.
DB 종류와 백엔드 서버에서 사용하는 DBCP도 다양합니다.
DB: MySQL, DBCP: HikariCP 기준으로 설명하겠습니다.
DB connection은 백엔드 서버와 DB 서버 사이의 연결을 의미하기 때문에 백엔드 서버와 DB 서버 각각에서의 설정(configuration) 방법을 잘 알고 있어야 합니다.
포스팅에서 다룰 파라미터는 정말 중요하게 사용되는 파라미터에 대해 알아볼 것이고, 다른 파라미터 역시 필요한 경우 찾아보시는 것을 추천드립니다.
MySQL에는 중요한 두 가지 파라미터가 존재합니다.
max_connections란 클라이언트와 맺을 수 있는 최대 connection 수를 말합니다.
DB 서버 입장에서 클라이언트라 함은 DB 서버에게 요청을 보내는 모든 애들을 클라이언트라고 볼 수 있습니다.
❓max_connections와 DBCP의 최대 connection 수가 동일하다면?
DB 서버의 max_connections가 5고 DBCP의 최대 connection 수도 5라고 가정합시다.
우리의 백엔드 서버는 들어오는 api 요청을 계속해서 처리하며 필요할 때마다 DB 통신을 계속해서 진행합니다.
그런데 너무 많은 api 요청이 들어오다 보니 백엔드 서버의 cpu나 메모리 등 리소스 사용량이 너무 많아 과부하가 걸리고 있습니다.
그래서 새로운 백엔드 서버를 하나 더 띄워 api 요청을 분담하도록 하여 백엔드 서버의 부하를 줄여주기로 하였습니다.
우리의 새로운 백엔드 서버 또한 DBCP의 최대 connetion 수가 5입니다.
그런데 이미 DB 서버의 max_connections가 5이고 원래 있던 백엔드 서버와 connection을 맺고 있는 상태이기 때문에 새로운 백엔드 서버와 connection을 맺을 수 없습니다.
따라서 DB 서버의 max_connections를 적절하게 잘 선택해야 DBCP의 connection 수를 늘리거나, 새로운 백엔드 애플리케이션을 투입해도 에러 없이 잘 돌아갈 수 있습니다.
DB 서버에서 어떤 connection이 inactive 할 때 다시 이 connection에 요청이 오기까지 얼마나 기다릴지 결정하는 파라미터입니다. 정해진 시간 안에 다시 요청이 오지 않을 시 connection을 close 합니다.
DB 서버에서 어떤 connection이 idle상태(active는 아니지만 준비는 된 상태)입니다.
계속 요청을 기다리고 있는데 요청이 오지 않습니다.
이러한 이유는 여러 가지가 있을 수 있습니다.
DB 서버에서는 이 connection을 계속 열어놓은 상태로 기다리고 있기 때문에 이 connection은 당연히 DB 서버의 리소스를 잡아먹고 있습니다.
사용하지 않는 connection을 계속 가지고 있는 것은 DB 서버의 성능에 좋지 못한 영향을 끼치지 때문에 종료시켜 주어야 합니다.
wait_timeout 파라미터는 이렇게 얼마의 시간까지 요청이 오지 않을 시 DB 서버의 connection을 종료시켜 버립니다.
정해진 시간 내에 요청이 오게 되면 요청을 처리해주고 그 동안 기다린 시간은 0으로 다시 초기화 됩니다.
백엔드 서버에서 사용하는 DBCP의 설정에 대해서 알아봅시다.
connection pool에서 유지하는 최소한의 idle connection 수를 의미합니다.
idle -> connection 연결이 되어있지만 어떤 작업을 하게될 때까지 기다리는, 아무일도 하지 않는 놀고 있는 상태를 말합니다.
connection pool이 가지는 최대 connection 수를 의미합니다.
여기서의 connection은 idle 상태, active(in-use) 상태 connection을 모두 합친 최대 수를 의미합니다.
DBCP에서 idle connection 수가 minimunIdle보다 작으며, 전체 connection 수가 maximumPoolSize보다 작으면 추가로 connection을 생성합니다.
이는 maximumPoolSize의 우선 순위가 더 높은 것을 의미합니다.
❓만약에 minimumidle의 수가 2이고 maximumPoolSize가 4라고 가정합시다.
이 상태에서 백엔드 서버에 api 요청이 하나 들어왔고 이 api 요청을 처리하느라 DB 통신을 해야해서 하나의 connection을 빌려쓰고 있습니다.
그럼 현재 DBCP의 idle connection 수는 1개입니다. minimumIdle이 2니까 DBCP에 새로운 connection을 하나 더 만들어 놓습니다. 그럼 idle connection이 2개가 되겠죠.
근데 아직 사용하던 connection을 반납하지 않은 상태로 api 요청이 또 들어와 DB 통신을 하게 되었습니다.
그럼 다시 connection을 하나 빌려가 쓸 것이고 DBCP의 idle connection이 다시 1이 됩니다. 그래서 다시 새로운 connection을 생성하게 됩니다.
이 상태에서 또 요청이 와서 connection을 하나 가져와 쓰게 됐습니다.
그럼 DBCP의 idle connection이 다시 1이 되었지만 maximumPoolSize가 4인데 현재 생성된 connection이 4개이기 때문에 connection을 추가로 생성하지 않습니다.
자, 이제 사용했던 모든 connection을 반납하여서 DBCP의 idle connection이 4가 되었습니다. 우리는 minimumIdle을 2로 설정해두었기 때문에 두 개의 connection만 남기고 나머지 connection을 제거하게 됩니다.
HikariCP에서 minimumIdle의 기본값은 maximumPoolsize와 동일하며, 이렇게 사용하는 것을 권장하고 있습니다.
minimumIdle을 더 작게 설정해둔다면 트래픽이 몰려왔을 때 추가로 connection을 생성하는 일이 생기고 connection을 생성하는 작업이 생각보다 시간이 드는 작업이기 때문입니다.
따라서 애초에 적절한 connection 수를 선택해서 minimumIdle과 maximumPoolSize를 동일하게 설정하라는 것이 HikariCP에서 주는 설정 가이드 입니다.
connection pool에서 connection의 최대 수명을 의미합니다.
maxLifetime을 넘긴 idle connection을 connection pool에서 바로 제거하고, active connection인 경우 pool로 반환된 후에 제거합니다.
connection을 제거하고 DBCP 사이즈에 맞게 connection이 다시 생성됩니다.
❓connection이 반환되지 않은 상태로 maxLifetime을 넘긴 경우
connection이 active 상태라면 pool로 반환된 후에 제거한다고 하였습니다. 따라서 active 상태일 때는 maxLifetime이 동작하지 않습니다.
백엔드 서버에서 어떤 요청을 처리하기 위해 connection 하나를 가지고 갔습니다. 그렇게 요청을 다 처리했지만 connection을 반환하지 않았습니다.
이대로 계속 시간이 흐르다가 DB 서버에 설정한 wait_timeout 시간을 지나버려서 DB 서버는 해당 connection을 종료하고 리소스를 반환해버렸습니다.
그런데 운도 더럽게 없지, 이 connection을 물고 있는 어떤 코드가 이 connection에 대고 DB 통신을 요청하게 되었습니다.
DB 서버에서는 이미 connection이 끊겼기 때문에 이 요청은 DB 서버로 전달되지 못하고 Exception을 뱉게 됩니다.
따라서 maxLifetime이 잘 동작하기 위해서는 다 쓴 connection은 pool로 반환을 해주는 것이 매우 중요합니다.
DB의 connection_time_limit(MySQL의 wait_timeout)보다 몇 초 정도 짧게 설정한다.
❓DB의 wait_timeout, DBCP의 maxLifetime이 60초로 동일하다면?
백엔드 서버에 요청이 와서 해당 요청을 처리하기 위해 connection을 가지고 갔습니다.
가지고 간 connection은 생긴지 59.5초가 된 connection이었습니다. 아직 60초를 넘기지 않아서 사용할 수 있었습니다.
이 connection에 대고 DB 요청을 했는데 이 요청이 DB 서버까지 가는에 0.7초가 걸려서 60.2초에 DB 서버에 도착을 하게 됐습니다.
그런데 DB 서버의 wait_timeout도 60초였기 때문에 DB 서버에서는 이 connection을 끊어버리고 리소스를 반환했습니다.
따라서 이 요청이 제대로 처리되지 못했습니다.
이러한 일이 발생할 수 있기 때문에 maxLifeTime을 connection_time_limit 보다 몇 초 정도 짧게 설정하는 것이 좋습니다.
connection pool에서 connection을 받기 위한 대기 시간을 의미합니다.
백엔드 서버로 트래픽이 몰려서 DBCP의 모든 connection이 active 상태라고 가정합시다.
이때 몰려든 트래픽을 처리하기 위해서 CPU 사용량도 많아지고 DBCP의 회전율도 높아지게 됐습니다.
어찌되었든 DB 통신을 하기 위해서는 DBCP에서 connection을 빌려와야 하는데 트래픽이 몰려 모두 active 상태라면 connection 받기 위해 기다려야 합니다.
이렇게 기다리다가 어떤 api 요청은 운 좋게 connection을 받아서 DB 통신을 하게 될 수 있지만, 어떤 요청은 계속 기다려야 할 수도 있습니다.
하지만 이 connection을 받기 위해 무한정 대기할 수는 없습니다. 언젠가 끊어줘야 합니다.
이때 이 connection을 받기 위해 대기하는 시간을 connectionTimeout이라고 하며, connectionTimeout을 넘기면 기다리지 않고 Exception을 뱉게 됩니다.
일반적인 사용자가 요청을 하고 얼마나 기다릴까요? 제가 생각하기에는 보통 길어야 10초~15초 정도 될 거라고 생각합니다.
그런데 이 connectionTimeout을 30초로 잡아버리면 이미 사용자는 10초 정도 기다리고 나가버려서 클라이언트와의 연결이 끊겨버렸기 때문에
이 DB connection을 기다리다가 29초에 받게 되어서 DB 통신을 하고 데이터 받아서 응답값을 만들어도 응답을 줄 수가 없습니다.
그래서 적절한 connectionTimeout을 잡아주는 것이 중요합니다.
maximumPoolSize: 5, max_connections: 30 으로 세팅한 백엔드 서버 시스템이 있다고 가정합시다.
앞으로 이벤트를 하거나 신규 기능을 추가하려고 하는데 트래픽이 평소보다 두 배 혹은 세 배 이상 몰릴 것 같습니다.
그 때 이 트래픽을 처리하기 위해 우리가 설정한 파라미터 값들이 적절한 지 생각이 듭니다.
혹은 우리가 설정한 파라미터 값들이 하드웨어 리소스를 적절히 잘 활용하는지 혹은 너무 적게 파라미터 값들을 설정해서 하드웨어 리소스를 전혀 잘 이용하지 못하고 있는지 하는 생각이 들 수도 있습니다.
그럼 어떻게 적절한 connection 수를 찾을 수 있을까요?
모니터링 환경을 구축(서버 리소스, 서버 스레드 수, DBCP 등등) 하여 백엔드 시스템 부하 테스트를 진행합니다.(모니터링 환경을 통해 리소스 사용률을 계속해서 확인하며 부하 테스트를 진행해야 합니다)
네이버에서 만든 nGrinder 혹은 Jmeter 같은 부하테스트 툴이 존재합니다.
request per second(백엔드 서버가 초당 처리 가능한 요청 수)와 avg response time(요청 처리를 위한 응담의 평균 시간)을 주로 확인합니다.
부하가 늘어날수록 rps는 늘어나다가 어느 순간부터 rps가 늘어나지 않을 겁니다.
부하가 늘어나도 art가 유지가 되다가 어느 순간부터 art가 늘어날 것입니다.
그럼 이 시점을 기준으로 모니터링 툴을 사용해서 지표들을 살펴봅니다.
백엔드 서버의 CPU나 Memory 사용량이 60프로, 70프로 막 올라가면 지금 상태의 백엔드 서버 갯수로는 이 늘어난 트래픽을 버티기 힘들다는 소리이기 때문에 백엔드 서버를 추가해주어야 트래픽을 나누어 받을 수 있도록 해주어야 합니다.
만약 백엔드 서버의 리소스 사용률은 괜찮은데 DB 서버의 리소스 사용률이 너무 높다면 또 다른 대처 방안을 생각해야 합니다.
select 요청이 너무 많은 것이 문제라면 secondary(읽기 전용 복제본, replica)를 추가해줍니다.
백엔드 서버와 DB 서버 사이의 cache layer를 두어 DB가 받는 직접적인 부하를 줄여줍니다.
sharding을 해줄 수도 있습니다.
부하 테스트로 트래픽을 계속 높여도 백엔드 서버나 DB 서버의 리소스 사용률이 걱정할 만큼 높아지지 않는데도
그래프 상에서 rps나 art가 완만하게 진행되는 구간이 발생한다면 request를 처리하기 위한 thread 갯수를 확인합니다.
요청마다 쓰레드를 할당해서 처리하는 모델인 경우 요청 처리를 위한 쓰레드 풀의 사이즈가 너무 적은 것은 아닌지 확인할 필요가 있습니다.
실제로 쓰레드 풀 사이즈가 5개인데 active 쓰레드 또한 5개라면 쓰레드 풀 사이즈가 너무 작아서 병목현상이 발생한다고 의심할 수 있습니다.
(Spring webflux와 같이 이벤트 루프 방식에서도 스레드 수가 중요합니다.)
request를 처리하는 쓰레드 풀 사이즈가 100개 이고 active 쓰레드는 50개로 널널하다고 가정합시다.
백엔드 서버, DB 서버의 리소스 사용률도 별로 높지 않습니다.
그럼에도 불구하고 부하에 따른 rps, art가 잘 나오지 않는 구간이 발생한다면 DBCP 사이즈를 의심해보아야 합니다.
저렇게 rps, art가 잘 나오지 않는 시점(구간)에서 DBCP를 확인해보니 maximimPoolSize와 active connection 수가 동일합니다.
connection pool의 connection을 모두 사용하고 있는 상태이기 때문에 "connection의 갯수가 너무 적구나" 라고 생각할 수 있습니다.
따라서 maximumPoolSize를 올려서 부하 테스트를 또 해보고 이렇게 반복합니다.
maximumPoolSize와 DB서버의 max_connections 수 또한 고려하면서 계속 올리며 부하 테스트를 진행합니다.
이렇게 부하 테스트를 반복하며 이 정도의 maximumPoolSize와 max_connections로 설정하면 평소 트래픽의 두 배, 세 배 이상 몰려도 견딜 수 있다는 생각이 드는 경우 이 쯤 부하테스트를 종료하고 결정해도 됩니다.
그리고 DB 서버의 max_connections와 백엔드 서버 수를 고려하여 DBCP의 maximumPoolSize를 결정합니다.
예를 들어,
두 대의 백엔드 서버를 사용하고 DB 서버의 max_connections 수는 60, 백엔드 서버의 maximumPoolSzie는 25로 결정할 수 있습니다.
만약 트래픽이 몰렸을 때 백엔드 서버의 CPU 사용량이 조금 걱정되었다면 서버를 한대 더 추가하여 세 대로 결정하고 maximumPoolsize는 15로 결정할 수도 있습니다.
트래픽이 너무 많이 몰리는 경우를 대비하여 예비로 추가할 서버 수까지 고려하여 maximumPoolSize를 결정할 수 있습니다.
트래픽이 너무 많이 몰리면 백엔드 서버 수를 네 대로 늘리고 maximumPoolSize는 15로 똑같아도 max_connections는 60으로 서버를 추가해도 돌릴 수 있습니다.(max_connections 수는 어느 정보 여유분을 갖는게 좋습니다. 백엔드 서버 외에도 여러 클라이언트가 존재할 수 있기 때문입니다.)
참고
🔸네이버D2 Commons DBCP 이해하기 : https://d2.naver.com/helloworld/5102792
🔸hikariCP github : https://github.com/brettwooldridge/HikariCP
🔸MySQL 설정 페이지 : https://dev.mysql.com/doc/refman/8.0/en/server-system-variables.html