Introduction
많은 개발자들이 데이터베이스 성능 향상을 위해 가장 먼저 시도하는 것이 커넥션 풀 크기를 늘리는 일입니다. "트래픽이 많으니 당연히 더 많은 연결이 필요하겠지"라는 직관적인 판단 때문이죠. 하지만 Oracle의 실제 성능 테스트 결과는 충격적이었습니다. 커넥션 풀 크기를 2,048개에서 96개로 줄이자 응답 시간이 100ms에서 2ms로 줄어든 것입니다. 아무런 코드 수정 없이, 단순히 숫자 하나를 조정했을 뿐인데 말이죠.
더 놀라운 것은 이것이 특수한 경우가 아니라는 점입니다. PostgreSQL 벤치마크에서도 비슷한 패턴이 발견되었고, 실제 현업에서도 수많은 사례가 이를 뒷받침하고 있습니다. 그렇다면 우리가 그동안 커넥션 풀에 대해 잘못 알고 있었던 것은 무엇일까요? 왜 '더 많은 연결 = 더 나은 성능'이라는 공식이 틀렸을까요?
이 글 [HikariCP의 About Pool Sizing]에서는 데이터베이스 커넥션 풀의 작동 원리를 CPU, 디스크, 네트워크 관점에서 심층적으로 살펴보고, 실제 환경에서 최적의 풀 크기를 결정하는 방법을 알아보겠습니다. 특히 놀라울 정도로 작은 풀 크기로도 큰 트래픽을 처리할 수 있는 이유와, 과도한 연결이 오히려 성능을 저하시키는 메커니즘을 이해하실 수 있을 겁니다.
커넥션 풀 크기 설정에 대하여
개발자들이 데이터베이스 커넥션 풀을 구성할 때 자주 실수를 하는 경우가 있습니다. 풀을 구성할 때는 몇 가지 중요한 원칙들을 이해해야 하는데, 이는 일부 개발자들에게 직관적이지 않을 수 있습니다.
동시 접속자 10,000명의 프론트엔드
페이스북 규모까지는 아니더라도 동시에 10,000명의 사용자가 데이터베이스에 요청을 보내는 웹사이트가 있다고 가정해보겠습니다. 이는 초당 약 20,000건의 트랜잭션이 발생한다는 의미입니다. 이런 경우 커넥션 풀의 크기는 얼마로 설정해야 할까요? 놀랍게도 '얼마나 크게' 설정할지가 아니라 '얼마나 작게' 설정할지를 고민해야 합니다!
Oracle Real-World Performance 그룹에서 제작한 다음 짧은 영상(약 10분)을 통해 놀라운 결과를 확인할 수 있습니다.
아직 영상을 보지 않으셨다면, 먼저 보고 와주세요!
영상에서 확인할 수 있듯이, 다른 설정은 전혀 건드리지 않고 커넥션 풀 크기만 줄였는데도 애플리케이션의 응답 시간이 약 100ms에서 약 2ms로 감소했습니다. 무려 50배 이상의 성능 향상을 보인 것입니다.
왜 이런 결과가 나왔을까요?
최근 컴퓨팅 분야에서 '적을수록 좋다'는 인식이 확산되고 있습니다. 왜 nginx 웹 서버는 단 4개의 스레드만으로도 100개의 프로세스를 가진 Apache 웹 서버보다 훨씬 뛰어난 성능을 발휘할 수 있을까요? 컴퓨터 과학 기초 과정을 떠올려보면 그 이유를 쉽게 이해할 수 있습니다.
CPU 코어가 하나뿐인 컴퓨터도 수십, 수백 개의 스레드를 "동시에" 실행하는 것처럼 보입니다. 하지만 우리 모두가 [알아야 하듯이] 이는 운영체제가 시분할(time-slicing)이라는 기법을 통해 만들어내는 환상에 불과합니다. 실제로 하나의 코어는 한 번에 하나의 스레드만 실행할 수 있으며, OS가 컨텍스트를 전환하면서 다른 스레드의 코드를 실행하는 방식으로 동작합니다. 단일 CPU 환경에서는 A와 B를 순차적으로 실행하는 것이 시분할을 통해 "동시에" 실행하는 것보다 항상 빠르다는 것이 컴퓨팅의 기본 원칙입니다. 스레드 수가 CPU 코어 수를 초과하면, 스레드를 추가할수록 성능이 향상되는 것이 아니라 오히려 저하됩니다.
이는 거의 사실입니다만...
광고: DevPill 유튜브 시작했습니다!(링크)
뉴스레터에 이어, 유튜브를 시작했습니다. 사실 뉴스레터와 동시에 시작했는데, 뉴스레터와 다르게 손이 잘 가지 않더라구요..ㅎ 그래도 간간이 찍기는 했으나, 제대로 소개하는 건 구독자 분들이 더 모였을 때 해야겠다 싶어 미뤄두고 있었습니다.
그러던 와중에 뉴스레터를 시작한지 어언 8개월, 어느덧 뉴스레터 구독자가 600명을 바라보고 있습니다. 누군가에게는 적은 숫자일 수 있지만 0에서 시작해서 여기까지 온 것만으로도 제게는 감개무량합니다. 사실 아무도 보지 않더라도 괜찮습니다(100%는 아닙니다만..). 어차피 제가 좋아하고 공부하는 콘텐츠거든요. 그런데 혼자 보는 것에서 그치지 않고 이를 번역하고 공유하니, 좋게 봐주시는 분들이 하나 둘씩 늘어나기 시작했습니다. 책임감도 같이 생기게 됩니다. 더 많이 도와드리고 싶구요. 그래서 유튜브 채널도 이 글을 읽는 구독자 분들에 한해 소개하게 되었습니다.
아래 영상은 2개월 전에 업로드했던 것인데요, 엊그제 오랜만에 업로드한 최신 영상은 아닙니다(시간 되시면 이것도 보러 와주세요). 다만 이게 여러분들에게 더 많이 도움되지 않을까 싶어 소개해봅니다. 자세한 이야기는 영상에서 봐주세요 :)
보통 좋아요 댓글 구독 이런 것들을 부탁하지 않는데, 이런 주제의 아티클을 읽을 정도로 개발에 관심이 많은 구독자 분들이라면 진심을 다해서 부탁드리고 싶습니다. 단, 콘텐츠가 마음에 드신다면요. 억지로 누르지는 않으셔도 됩니다 :) 앞으로도 좋은 콘텐츠 많이 올릴 수 있도록 노력해보겠습니다!
그럼, 다시 본문으로 돌아가볼까요? :)
제한된 리소스
실제로는 위 설명처럼 단순하지는 않지만, 비슷한 원리가 적용됩니다. 여러 가지 요소들이 복합적으로 작용하기 때문입니다. 데이터베이스의 주요 병목 현상은 크게 CPU, 디스크, 네트워크라는 세 가지 범주로 나눌 수 있습니다. 메모리도 포함시킬 수 있지만, 디스크나 네트워크와 비교하면 대역폭 면에서 수 차원의 차이가 있습니다.
디스크와 네트워크 요소를 제외한다면 상황은 단순해집니다. 8개의 컴퓨팅 코어가 있는 서버라면 연결 수를 8로 설정했을 때 최적의 성능을 얻을 수 있으며, 이를 초과하면 컨텍스트 전환에 따른 오버헤드로 인해 성능이 저하되기 시작할 것입니다. 하지만 현실에서는 디스크와 네트워크를 무시할 수 없습니다. 데이터베이스는 일반적으로 디스크에 데이터를 저장하는데, 전통적인 하드 디스크는 스테퍼 모터로 구동되는 암에 장착된 읽기/쓰기 헤드가 있는 금속 플래터로 구성되어 있습니다. 읽기/쓰기 헤드는 한 번에 한 위치에서만 데이터를 읽거나 쓸 수 있으며(단일 쿼리용), 다른 쿼리의 데이터에 접근하려면 새로운 위치로 "탐색"해야 합니다. 따라서 탐색 시간이 소요되며, 플래터가 회전하면서 원하는 데이터가 헤드 위치까지 "돌아오기를" 기다려야 하는 회전 지연 시간도 발생합니다. 물론 캐싱이 이러한 문제를 완화해주지만, 기본 원리는 여전히 유효합니다.
이러한 대기 시간("I/O 대기") 동안 해당 연결/쿼리/스레드는 디스크 작업이 완료되기를 기다리며 "블록" 상태가 됩니다. 이때 운영체제는 블록된 스레드 대신 다른 스레드의 코드를 실행함으로써 CPU 리소스를 더 효율적으로 활용할 수 있습니다. 즉, 스레드가 I/O 작업으로 블록되는 동안에는 물리적 CPU 코어 수보다 많은 연결/스레드를 가짐으로써 전체적인 처리량을 높일 수 있습니다.
그렇다면 얼마나 많은 연결이 필요할까요? 이는 디스크 시스템의 특성에 따라 달라집니다. 최신 SSD는 기계적인 "탐색 시간"이나 회전 지연이 없기 때문입니다. 하지만 "SSD가 더 빠르니까 더 많은 스레드를 사용해도 된다"는 생각은 완전히 잘못된 것입니다. 오히려 그 반대입니다. SSD는 더 빠르고 탐색이나 회전 지연이 없기 때문에 블로킹이 덜 발생하며, 따라서 더 적은 수의 스레드[코어 수에 가까운]가 더 많은 스레드보다 나은 성능을 보입니다. 스레드 수를 늘리는 것은 블로킹으로 인한 유휴 시간이 있을 때만 의미가 있습니다.
네트워크도 디스크와 비슷한 특성을 보입니다. 이더넷 인터페이스를 통해 데이터를 전송할 때도 송수신 버퍼가 가득 차면 블로킹이 발생할 수 있습니다. 10기가비트 인터페이스는 기가비트 이더넷보다 블로킹이 덜 발생하고, 기가비트 이더넷은 100메가비트보다 블로킹이 덜 발생합니다. 다만 네트워크는 리소스 블로킹 측면에서는 세 번째로 중요한 요소이며, 많은 경우 계산에서 제외되기도 합니다.
여기 있는 PostgreSQL 벤치마크를 보면 초당 트랜잭션(TPS) 처리량이 약 50개의 연결에서 정체되기 시작하는 것을 볼 수 있습니다. 앞서 본 Oracle 영상에서는 연결 수를 2,048개에서 96개로 줄이는 예시를 보여주었는데, 16코어나 32코어 시스템이 아니라면 96개조차도 너무 많을 수 있습니다.
최적의 연결 수 계산 공식
PostgreSQL 프로젝트에서 제시한 아래 공식은 시작점으로 활용할 수 있으며, 대부분의 데이터베이스에 일반적으로 적용 가능합니다. 실제 환경에서는 예상 부하를 시뮬레이션하고 이 기준점을 중심으로 다양한 풀 설정을 테스트해보는 것이 좋습니다.
활성 커넥션 수 = ((CPU 코어 수 * 2) + 유효 스핀들 수)
수년간의 벤치마크 결과를 통해 검증된 이 공식에 따르면, 최적의 처리량을 위한 활성 커넥션 수는 ((코어 수 * 2) + 유효 스핀들 수) 정도가 되어야 합니다. 여기서 코어 수는 하이퍼스레딩이 활성화되어 있더라도 HT 스레드를 제외한 물리적 코어 수만을 계산합니다. 유효 스핀들 수는 활성 데이터 세트가 모두 캐시에 있는 경우 0이 되며, 캐시 적중률이 낮아질수록 실제 물리적 스핀들 수에 가까워집니다. 아직 SSD 환경에서 이 공식의 유효성에 대한 체계적인 분석은 이루어지지 않았습니다.
이것이 실제로 의미하는 바를 살펴보겠습니다. 하드 디스크가 1개인 4코어 i7 서버의 경우, 다음과 같이 계산됩니다:
편의상 10으로 반올림하겠습니다. 너무 적어 보이나요? 한번 시도해보세요. 이 정도 설정으로도 간단한 쿼리를 실행하는 3,000명의 프론트엔드 사용자를 초당 6,000 트랜잭션의 속도로 충분히 처리할 수 있을 것입니다. 부하 테스트를 해보면 이 하드웨어 구성에서 연결 풀을 10개보다 크게 늘렸을 때 TPS가 감소하고 프론트엔드 응답 시간이 증가하기 시작하는 것을 확인할 수 있을 것입니다.
핵심 원칙: 연결을 기다리는 스레드들로 채워진 작은 크기의 풀이 필요합니다. 10,000명의 프론트엔드 사용자가 있다고 해서 10,000개의 연결을 설정하는 것은 완전히 잘못된 접근입니다. 1,000개도 너무 많고, 심지어 100개의 연결도 과도합니다. 최대 수십 개 정도의 연결로 구성된 작은 풀을 만들고, 나머지 애플리케이션 스레드들은 풀에서 연결을 기다리며 블록되도록 하는 것이 좋습니다. 풀이 적절하게 조정되었다면, 데이터베이스가 동시에 처리할 수 있는 최대 쿼리 수에 맞춰져 있어야 하며, 이는 앞서 설명했듯이 대체로 (CPU 코어 수 * 2)를 크게 넘지 않습니다.
우리는 종종 수십 명의 사용자만 주기적으로 접속하는 사내 웹 애플리케이션에서 100개나 되는 연결 풀을 설정한 경우를 보게 됩니다. 이처럼 데이터베이스 리소스를 과도하게 할당하는 것은 피해야 합니다.
"풀 잠금(Pool-locking)" 현상
다수의 연결을 한꺼번에 획득하는 단일 프로세스로 인해 발생할 수 있는 "풀 잠금" 문제가 제기되곤 합니다. 이는 주로 애플리케이션 레벨의 문제입니다. 물론 풀 크기를 늘리면 이러한 상황에서의 교착 상태를 해소할 수는 있지만, 풀 크기를 늘리기 전에 먼저 애플리케이션 레벨에서 어떤 개선이 가능한지 검토해보는 것이 좋습니다.
교착 상태를 방지하기 위한 풀 크기 계산은 다음과 같은 간단한 리소스 할당 공식을 따릅니다:
풀 크기 = Tn × (Cm - 1) + 1
여기서 Tn은 최대 스레드 수이고, Cm은 단일 스레드가 동시에 보유할 수 있는 최대 연결 수입니다.
예를 들어, 세 개의 스레드(Tn=3)가 각각 네 개의 연결(Cm=4)을 필요로 하는 경우, 교착 상태가 발생하지 않도록 하기 위한 최소 풀 크기는 다음과 같습니다:
풀 크기 = 3 × (4 - 1) + 1 = 10
다른 예로, 최대 여덟 개의 스레드(Tn=8)가 각각 세 개의 연결(Cm=3)을 필요로 하는 경우의 최소 풀 크기는 다음과 같습니다:
풀 크기 = 8 × (3 - 1) + 1 = 17
👉 이는 최적의 풀 크기가 아닌, 데드락을 방지하기 위해 필요한 최소한의 크기임을 유의하세요.
👉 일부 환경에서는 JTA(Java Transaction Manager)를 사용하면 현재 트랜잭션에서 이미 연결을 보유하고 있는 스레드에 대해 getConnection() 호출 시 동일한 연결을 반환함으로써 필요한 연결 수를 크게 줄일 수 있습니다.
주의 사항
풀 크기 설정은 결국 각 배포 환경의 특성에 따라 크게 달라질 수 있습니다.
예를 들어, 장시간 실행되는 트랜잭션과 매우 짧은 트랜잭션이 혼재된 시스템은 일반적으로 커넥션 풀 튜닝이 가장 까다롭습니다. 이런 경우에는 두 개의 별도 풀 인스턴스를 생성하는 것(예: 장시간 실행 작업용 풀과 "실시간" 쿼리용 풀을 분리)이 효과적일 수 있습니다.
주로 장시간 실행되는 트랜잭션으로 구성된 시스템의 경우, 필요한 연결 수는 종종 "외부" 제약 조건에 의해 결정됩니다. 예를 들어 한 번에 특정 수의 작업만 실행되도록 제한하는 작업 실행 큐가 있는 경우입니다. 이런 경우에는 풀 크기에 맞춰 작업 큐 크기를 "적절히 조정"하는 것이 바람직합니다(그 반대가 아님).
👥 더 나은 데브필을 만드는 데 의견을 보태주세요
Top 1% 개발자로 거듭나기 위한 처방전, DevPill 구독자 여러분 안녕하세요 :)
저는 여러분들이 너무 궁금합니다.
어떤 마음으로 뉴스레터를 구독해주시는지,
어떤 환경에서 최고의 개발자가 되기 위해 고군분투하고 계신지,
제가 드릴 수 있는 도움은 어떤 게 있을지.
아래 설문조사에 참여해주시면 더 나은 콘텐츠를 제작할 수 있도록 힘쓰겠습니다. 설문에 참여해주시는 분들 전원 1개월 유료 멤버십 구독권을 선물드립니다. 유료 멤버십에서는 아래와 같은 혜택이 제공됩니다.
- DevPill과의 1:1 온라인 커피챗
- 멤버십 전용 슬랙 채널 참여권
- 채용 정보 공유 / 스터디 그룹 형성 / 실시간 기술 질의응답
- 이력서/포트폴리오 템플릿
의견을 남겨주세요