본문 바로가기
Performance

내가 만든 서비스는 얼마나 많은 사용자가 이용할 수 있을까? - 3편(DB Connection Pool)

by Hooligans 2021. 1. 14.

개요

 지난 시간 현재 AGORA 서비스의 단일 피드 조회 기능에 대한 성능 테스트를 진행해보았습니다. 이번 시간에는 데이터베이스의 Connection Pool의 크기를 조절해보면서 발생하는 성능 변화에 대해 알아보겠습니다.

 

 먼저 Connection Pool이 무엇인지 알아볼까요?

 

Connection Pool은 무엇일까요?

 Connection? 연결? 어떤 연결을 의미할까 생각하실 수 있을 것입니다. 여기서 말하는 Connection이란 WAS와 데이터베이스 사이의 연결을 의미합니다. 해당 클라이언트와 서버 사이의 연결을 위해서는 아래 그림과 같이 3-way-handshaking이라는 작업이 필요합니다.

 

 3-way-handshaking은 3번의 패킷 교환을 통해 소켓을 형성하고 통신을 준비하는 과정을 의미하는데 이 과정이 쿼리를 요청할 때마다 반복적으로 실행된다면 이 또한 네트워크 구간에서 병목의 원인이 될 수 있습니다.

 

 실제 데이터베이스가 쿼리를 요청받고, 수행하는 과정을 간단하게 MySQL 공식문서의 내용을 참고하여 알아보겠습니다.

 

 아래 내용은 MySQL 8.0 기준으로 INSERT문을 수행하는 과정을 나타냅니다. 괄호 안의 숫자는 각 과정을 수행하는 데 필요한 비용의 비율을 의미합니다. 자세하게 확인하고 싶으신 분들은 아래 링크를 통해 참고 부탁드립니다.

 

  • Connecting: (3)
  • Sending query to server: (2)
  • Parsing query: (2)
  • Inserting row: (1)
  • Inserting index: (1)
  • Closing: (1)

 

가장 비용이 많이 드는 작업이 보이시나요?

 

 바로 서버가 DB 접속하기 위해서 Connection을 생성하는 작업이 가장 큰 비용을 차지합니다. 즉, Connection을 반복적으로 생성하는 것은 그만큼 큰 비용이 드는 작업임을 알 수 있습니다.

 

 이를 해결하기 위해서 사용하는 것이 Connection Pool 방식입니다. Connection Pool 방식은 매번 소켓을 생성하는 것이 아니라 미리 정해진 개수의 Connection을 생성해서 Pool에 보관하다가 재사용하여 데이터를 교환하는 방식입니다. 이러한 방식은 이미 형성된 Connection을 재사용한다는 점에서 반복적인 3-way-handshaking 과정을 제거할 수 있으므로 훨씬 더 빠르게 통신할 수 있습니다.

 

 지금까지 Connection 생성은 비싼 작업이라는 것을 알았고, 이를 위해 Connection Pool 방식을 사용한다고 알게 되었습니다.

 

 그렇다면 지금부터 Connection Pool이 어떻게 동작하는지 알아보겠습니다.

 

Connection Pool은 어떻게 동작할까?

 Spring의 default JDBC Connection Pool인 Hikari CP가 동작하는 방식을 통해서 Connection Pool이 동작하는 원리에 대해 간단히 알아보겠습니다. (해당 내용은 제가 이해한 내용을 알기 쉽게 표현한 것이라 구체적인 구현체 이름이나 객체 이름을 사용하지 않았습니다. 더 자세히 알고 싶으신 분은 아래 첨부한 링크를 활용해주시길 바랍니다.)

 

 그림에서 볼 수 있듯이 Thread가 Connection을 요청하면 Connection Pool의 각자의 방식에 따라 유휴 Connection을 찾아서 반환합니다. Hikari CP의 경우, 이전에 사용했던 Connection이 존재하는지 확인하고, 이를 우선적으로 반환하는 특징을 갖고 있습니다.

 

 

 만약에 가능한 Connection이 존재하지 않으면 HandOffQueue를 Polling 하면서 다른 Thread가 Connection을 반납하기를 기다립니다. 이를 지정한 TimeOut 시간까지 대기하다가 시간이 만료되면 Exception을 던집니다.

 

 최종적으로 사용한 Connection을 반납하면 Connection Pool이 사용내역을 기록하고, HandOffQueue에 반납된 Connection을 삽입합니다. 이를 통해 HandOffQueue를 Polling 하던 Thread는 Connection을 획득하고 작업을 이어나갈 수 있게 됩니다.

 

 이렇게 Thread들은 트랜잭션을 처리하기 위해서 Connection을 획득하고, 이를 반납함으로써 상대적으로 비싼 작업인 Connection 생성을 줄일 수 있었습니다.

 

 그러나, 위에서 보신 바와 같이 Connection Pool의 크기가 작으면 Connection을 획득하기 위해 대기하는 Thread가 많아지고, 이에 따라 성능적인 저하를 발생시킬 수 있습니다.

 

 이는 Connection Pool의 크기를 늘려주면 쉽게 해결할 수 있습니다.

 

 그렇다면 Connection Pool에 저장된 Connection 개수가 무제한으로 커지면 성능이 좋아질까요?

 

 지금부터 Connection Pool의 크기와 성능의 상관관계에 대해서 알아보겠습니다.

 

Connection Pool이 커지면 성능은 무조건 좋아질까요?

 예상하셨겠지만 질문에 대한 답변은 No입니다.

 

 이에 대해 천천히 알아보겠습니다.

 

 우선, WAS에서 Connection을 사용하는 주체는 Thread입니다. 그렇다면 Thread Pool의 크기보다 Connection Pool의 크기가 크면 어떨까요?

 

 Thread Pool에서 트랜잭션을 처리하는 Thread가 사용하는 Connection 외에 남는 Connection은 실질적으로 메모리 공간만 차지하게 됩니다.

 

 그럼 Thread Pool의 크기와 Connection Pool의 크기를 둘 다 늘려주면 되지 않을까?라고 생각하실 수 있습니다. 하지만 Thread의 증가는 Context Switching으로 인한 한계가 존재하고, Connection Pool을 늘려서 많은 Thread를 받았더라도 다양한 원인으로 성능적인 한계가 존재합니다.

 

 먼저, Disk 경합 측면에서 성능적인 한계가 존재합니다. 데이터베이스는 하드 디스크 하나당 하나의 I/O를 처리하기 때문에 Blocking이 발생하게 됩니다. SSD를 사용하면 일부 병렬 처리가 가능하지만 이 또한 한계가 존재합니다. 즉, 특정 시점부터는 성능적인 증가가 Disk 병목으로 인해 미비해집니다.

 

 다음은 데이터베이스에서도 Context Switiching으로 인한 성능적인 한계가 존재합니다.

 

 Context Switching에 대해 간단히 살펴보면 우리는 멀티 스레드 환경에서 CPU가 수 십, 수 백 개의 Thread를 동시에 실행할 수 있다고 알고 있습니다. 하지만, 이는 CPU의 속도가 엄청나게 빨라서 동시에 사용되는 것처럼 보이는 것이고, 실제 CPU 코어는 한 번에 Thread 하나의 작업만 처리할 수 있습니다. 다음 Thread의 작업을 수행하기 위해서 Context Switching이 일어나는데 이 순간 작업에 필요한 Thread의 Stack 영역 데이터를 로드하는 등 추가적인 작업이 필요하기 때문에 오버헤드가 발생하게 됩니다.

 

 그러므로 이상적인 Thread의 수는 CPU의 코어 수와 동일할 때, Context Switching이 일어나지 않으므로 가장 빠른 성능을 보입니다. 이상적이라고 표현한 이유는 다양한 요소들로 인해 멀티스레드 작업이 가능하기 때문입니다.

 

 데이터베이스 입장에서 Connection은 Thread와 어느 정도 일치한다고 볼 수 있습니다. Connection이 많다는 의미는 데이터베이스 서버가 Thread를 많이 사용한다는 것을 의미하고, 이에 따라 Context Switching으로 인한 오버헤드가 더 많이 발생하기 때문에 Connection Pool을 아무리 늘리더라도 성능적인 한계가 존재합니다.

 

 이외에도 다양한 이유가 존재합니다. 추가적인 내용은 포스팅이 너무 길어질 것 같아 링크를 남겨두겠습니다.

 

 

지금까지 Connection Pool의 크기 증가에도 한계가 있다는 것을 알게 되었습니다.

 

그렇다면 Connection Pool의 적절한 크기는 어떻게 결정할까요?

 

Connection Pool의 크기는 얼마가 적절할까?

 Hikari CP의 공식문서에 따르면 connections = ((core_count * 2) + effective_spindle_count)라고 명시되어 있습니다. 이는 PostgreSQL에서 Connection Pool Size를 선정하는 방식이지만 여러 DB에도 적용될 수 있다고 적혀있는 것을 보아 MySQL에서도 참고해볼 수 있을 것이라고 생각했습니다. 

 

 

 여기서 말하는 core_count는 현재 사용하는 Cloud 서버 환경에서는 논리 Cpu 개수와 동일합니다.

 

 core_count * 2를 하는 이유는 위에서 설명한 Context Switching 및 Disk I/O 와 관련이 있습니다. Context Switching으로 인한 오버헤드를 고려하더라도 데이터베이스에서 Disk I/O 혹은, DRAM이 처리하는 속도보다 CPU 속도가 월등히 빠르기 때문에 Thread가 Disk와 같은 작업에서 Blocking 되는 시간에 다른 Thread의 작업을 처리할 수 있는 여유가 생기게 됩니다. 이러한 여유 정도에 따라 멀티 스레드 작업을 수행할 수 있게 되고, Hikari CP가 제시한 공식에서는 계수를 2로 선정하여 Thread 개수를 지정했습니다.

 

  

 effective_spindle_count는 하드 디스크와 관련이 있습니다. 하드 디스크 하나는 spindle 하나를 가집니다. 이에 따라 spindle의 수는 기본적으로 DB 서버가 관리할 수 있는 동시 I/O 요청 수를 말합니다. 디스크가 16 개가 있는 경우 시스템은 동시에 16 개의 I/O 요청을 처리할 수 있습니다. 물론 RAID 구성 방식에 따라서 달라질 수 있습니다. 해당 공식에서 디스크의 효율을 고려하여 spindle_count를 더해준 것으로 보입니다.

 

 최종적으로 CPU의 처리 효율과 디스크 처리 효율을 고려한 결과, ((core_count * 2) + effective_spindle_count) 공식을 통해 Connection의 개수를 추정할 수 있다고 알게 되었습니다.

 

 이러한 공식이 무조건 성립하진 않지만 서버의 최초 pool size를 선정하는 기준으로써 사용한다면 유용하다고 생각합니다.

 

 다음은 실제 Agora 서비스의 Connection Pool을 조정하면서 성능 변화를 관찰하고 적절한 Pool Size를 찾아보겠습니다.

 

 실제 서비스에서 Connection Pool을 얼마나 영향을 미칠까?

 현재 Agora 서비스의 MySQL 서버는 vCPU 2개와 SSD 1개를 가진 서버입니다. SSD를 사용하기 때문에 공식이 완벽하게 성립하지는 않지만 공식에 대입해보면 (2*2) + 1 이 되어 5개의 Connection Pool 크기를 도출해낼 수 있습니다. 그럼 Connection Pool의 개수를 5개로 지정한 후, 성능을 측정해볼까요?

 

 결과를 보시면 약 2200 TPS 정도의 처리량과 210ms의 지연시간이 발생하는 것을 알 수 있습니다. (단, Thread Pool의 개수는 default로 Connection Pool의 크기에 따른 변화만 측정하기 위해 변경하지 않았습니다.)

 

MySQL 서버의 CPU 사용량을 한 번 볼까요?

 

 

 60% 정도의 CPU를 사용하는 것으로 볼 수 있습니다.

 

 그렇다면 Connection Pool을 HickariCP의 default 크기인 10개로 늘려서 테스트해보도록 하겠습니다.

 

 공식과는 다르게 10개로 늘리니 2600 TPS로 400 TPS가 증가하고, Latency도 180 ms로 감소했습니다. 성능이 더 좋아졌죠? (다양한 원인이 있겠지만 SSD를 사용하기 때문에 동시 I/O가 일부 가능해서 공식과 차이가 발생한 것이 아닐지 조심스레 추측해봅니다...ㅎㅎ)

 

 MySQL 서버의 CPU 사용량은 어떨까요?

 

 

 약 75% 정도로 증가함을 볼 수 있습니다. 그만큼 MySQL 서버가 더 많은 작업을 처리하고 있음을 알 수 있습니다.

 

다음은 Connection Pool을 50으로 늘려서 확인해보겠습니다.

 

 위의 결과에 따르면 처리량과 지연시간이 큰 변화가 없는 것으로 보아, Thread Pool의 크기가 default인 조건에서 현재 Connection Pool의 개수는 10개가 적절하다고 판단할 수 있었습니다.

 

 결과적으로 실제 Connection Pool의 크기를 조절하고 성능 테스트를 해봄으로써 현재 서비스에서 적절한 Connection Pool의 크기를 찾을 수 있었습니다. 공식을 사용하는 것도 좋지만 실제 성능 테스트를 해봄으로써 적절한 Connection Pool Size를 찾는 과정이 더 중요함을 알 수 있었습니다.

 

 

결론

  1. Connection을 생성하는 과정은 3-way-handshaking을 해야 하기 때문에 시간상 비용이 비싼 작업입니다.
  2. 반복되는 Connection 생성을 줄이기 위해서 Connection Pool 방식을 활용합니다.
  3. Connection Pool이 적으면 Thread의 대기시간이 길어져 성능 저하가 발생하고, Connection Pool의 크기 증가에도 Context Switching, Disk I/O 등 다양한 원인에 의해서 한계가 존재합니다.
  4. Connection Pool의 크기를 공식을 통해 추정할 수 있지만, 정확한 측정을 위해서는 성능 테스트를 진행해서 확인하는 과정이 필요합니다.

 

참고

 

프로젝트 참고

 

f-lab-edu/sns-agora

소셜 네트워크 서비스 AGORA입니다. Contribute to f-lab-edu/sns-agora development by creating an account on GitHub.

github.com

 

다음 포스팅에는

 다음 포스팅에는 Agora 서비스의 Thread Pool 크기를 조절해보면서 발생하는 성능 변화에 대해 분석해보고 이에 따른 적절한 Thread Pool 크기를 찾아보도록 하겠습니다.