커넥션 풀과 부하테스트

안녕하세요. kernel360 4기 오승택입니다.

이번 글에서는 커넥션과 커넥션 풀에 대한 기본적인 이해,

커넥션 풀 사이징 전략,

부하테스트를 통한 지표 분석 및 성능 최적화 경험을 공유하고자 합니다.

1. 커넥션이란?

애플리케이션에서 커넥션(Connection)은 클라이언트(애플리케이션)와 데이터베이스(DB) 사이의 네트워크 채널을 의미합니다.

이 채널을 통해 SQL 쿼리를 전송하고 결과를 수신하며, 트랜잭션 경계를 관리합니다.

이것은 근본적으로 네트워크 연결이므로 연결 시마다 소켓 생성, 핸드셰이크 등의 기본적인 TCP 연결 과정이 필요합니다.

커넥션이 일으키는 문제

a. 높은 생성/종료 비용

TCP 핸드셰이크, 인증·SSL 설정 등으로 커넥션 하나당 수십~수백 밀리초 소요

b. 리소스 고갈 위험

동시 요청이 급증하면 DB 서버의 최대 연결 수 초과로 서비스 장애

c. 스레드 블로킹

커넥션을 기다리는 스레드가 대기 상태에 들어가면, 애플리케이션 전체 처리량 저하

d. 메모리 및 시스템 리소스 과다 사용

각 커넥션이 점유하는 메모리와 네트워크 소켓 자원이 누적되어, 사용 가능한 시스템 리소스가 빠르게 고갈될 수 있습니다.

이 모든 문제는 특히 짧고 잦은 트랜잭션을 다루는 마이크로서비스 환경에서 더욱 심각해집니다.

2. 커넥션 풀 이해하기

커넥션 풀링이란…

풀에 미리 생성된 커넥션을 재사용해, 매 요청마다 소켓 생성·종료 비용을 제거하는 기법

기존 방식

트랜잭션 시작 시 DriverManager.getConnection() 호출 -> 소켓 생성, 네트워크 연결

executeQuery()로 쿼리문 전송 및 ResultSet 수신

트랜잭션 종료 시 Connection.close() 호출 → 소켓 연결이 끊기고 Connection 객체는 GC에 의해 처리됨

매 요청 마다 연결 생성/종료로 인한 비용 발생, 최대 연결 제한이 없어 대규모 요청 시 DB 서버 리소스 고갈

커넥션 풀링

애플리케이션 시작 시 커넥션 풀에 미리 N개의 커넥션 생성

DB 작업 시 만들어놓은 커넥션을 “빌려 쓰기”

작업 완료 즉시 커넥션 객체를 풀에 반환

풀에 유휴 커넥션이 있으면 즉시 재사용

생성·종료 비용 감소, 동시 연결 제한 제어, 리소스 재활용 가능

3. HikariCP의 동작 원리

이제 커넥션과 커넥션 풀에 대한 기본적 이해가 생겼으니, Spring Boot 환경에서 주로 사용하는 HikariCP의 동작에 대해 알아보겠습니다.

HikariCP 사용 이유?

  1. 매우 낮은 커넥션 획득 지연(latency)
  2. 설정 및 책임이 커넥션 풀에만 집중된 경량 라이브러리
  3. 내부 하우스키퍼 스레드가 주기적으로 풀 상태를 점검 및 자동 최적화

시작 및 연결

애플리케이션 시작 시, HikariCP가 설정된 minimumIdle 수만큼 커넥션을 미리 확보

Connection 호출

DataSource.getConnection()을 호출하면, HikariCP는 풀 안에 사용 가능한 연결(idle 상태)이 있는지 확인

a. 유휴 커넥션(idleConnection)이 있다면 즉시 하나를 반환하여 해당 쓰레드가 사용

b. 유휴 연결이 없다면, HikariCP는 현재 풀의 maximumPoolSize 를 확인 -> maximumPoolSize보다 적다면 새로운 DB 연결을 생성하여 풀에 추가하고 그 연결을 반환

c. 현재 연결 수가 이미 maximumPoolSize에 도달했는데도 모든 연결이 사용 중이라면, 새로운 연결을 만들 수 없으므로 해당 요청은 대기 상태가 됨. 이때 대기 가능한 최대 시간은 connectionTimeout으로 지정

d. 시간을 초과하면 SQLTransientConnectionException (연결 획득 시간 초과 예외)을 발생시킴

반납 및 재사용

쓰레드가 DB 작업을 마치고 Connection.close()를 호출하면, 실제로 연결이 닫히는 대신 HikariCP는 해당 연결을 다시 풀에 반납하여 유휴 상태로 둠

다른 요청이 들어오면, 동일한 Connection 객체를 재사용하게 됨

오래된 연결 교체

HikariCP의 하우스키퍼(housekeeper) 스레드가 주기적으로 풀의 연결들을 점검하여, 오래 사용되지 않은 연결은 정리하고 (idleTimeout 설정), 너무 오래 살아있는 연결은 종료 후 새로운 연결로 교체(maxLifeTime 설정)

4. 부하테스트 환경

부하테스트는 실제 서비스 트래픽을 시뮬레이션해 응답 성능과 커넥션 풀 동작을 검증하는 단계입니다.

아래와 같은 구성으로 테스트를 진행했습니다:

테스트 도구: JMeter, Prometheus, Grafana

테스트 머신: 8코어 CPU, 16GB RAM, SSD 스토리지

테스트 환경: 로컬 Spring 서버, 로컬 MySQL 서버

시나리오:

동시 VU(Virtual Users) 1000, Ramp-Up 2초, 루프 10회

요청 하나당 INSERT 쿼리 60회 (hibernate의 saveAll())

@Async를 통한 비동기 처리

수집 지표:

유휴 커넥션 수(idle connection), 연결된 커넥션 수(active connection), 대기 스레드(pending threads)

연결 대기 시간(acquire time), 커넥션 사용 시간(usage time)

JVM 활성 스레드 수, JVM 프로세스 CPU 사용률

5. 커넥션 풀 설정 전략

커넥션 풀 설정을 최적화하기 위한 과정은 아래와 같이 나눌 수 있습니다.

초기 크기 계산

(CPU 코어 수 × 2) + 유효 디스크 스핀들 수

(공식에 대한 자세한 설명은 아래 참고 자료와 링크에서 확인하세요.)

풀 사이즈 조정

테스트 지표를 모니터링하며 minimumIdle과 maximumPoolSize 값 조정

세부 설정

서비스의 성격을 고려하여 connectionTimeout, idleTimeout, maxLifeTime 등 설정

5.1 풀사이징 공식에 대한 참고 자료

실제 HikariCP 위키에서는 “4코어 i7 서버 + 한 개의 하드디스크” 예시로 풀 크기 10개를 추천하면서, 이 경우 3000명의 동시 사용자가 초당 6000건의 간단 쿼리를 충분히 처리할 수 있었고, 풀 크기를 그 이상 (예: 20, 30…)으로 늘리면 오히려 TPS(초당 트랜잭션)가 떨어지고 응답 시간이 증가했다고 설명합니다.

이는 풀 크기를 지나치게 키워봤자 CPU와 디스크 자원의 한계로 인해 더 이상 처리량이 늘지 않고, 컨텍스트 스위칭 등의 오버헤드로 성능이 악화된다는 점을 보여줍니다.

고려해야 할 요소들

  1. 워크로드 특성:

쿼리 수행 시간이 매우 짧은 CPU 바운드 작업인지, 아니면 파일 I/O나 네트워크 I/O로 지연이 큰 작업인지에 따라 적정 풀 크기가 달라질 수 있습니다.

일반적으로 I/O 지연이 큰 작업일수록 좀 더 많은 연결을 활용하는 것이 유리하고, CPU 집중적 작업일수록 코어 수에 더 가까운 풀 크기가 유리합니다.

  1. 트랜잭션 모드:

만약 트랜잭션이 빈번하게 Idle 상태 (예: 어플리케이션에서 사용자 입력을 기다리는 사이 DB 연결을 오래 들고 있는 경우)라면, 공식 계산치보다 풀 크기를 좀 더 늘려야 할 수도 있습니다.

반대로, 모든 쿼리가 연속해서 즉시 실행되고 완료된다면 공식 값보다 줄여도 됩니다.

  1. DB 자체 한계 및 동시 처리 능력:

DBMS마다 동시 처리 최적 지점이 있는데, 아무리 풀에서 연결을 많이 열어줘도 DB가 동시에 감당할 수 있는 쿼리 수를 넘어서면 효과가 없습니다.

예를 들어 PostgreSQL 벤치마크 그래프를 보면 약 50 connections 부근에서 TPS가 포화되는 경향을 보입니다. Oracle DB의 예시에서도 2048개의 연결로 운영하던 것을 96개로 줄여도 오히려 성능이 향상되는 결과를 보였다고 합니다.

따라서 DBMS의 권장 최대 동시 연결이나 모범 사례도 참고해야 합니다.

출처 : https://github.com/brettwooldridge/HikariCP/wiki/About-Pool-Sizing

유효 디스크 스핀들 수란?

디스크 I/O 병렬성 정도를 나타내는 값으로, 활성 데이터가 메모리 캐시에 충분히 올라와 있다면 0에 가깝고, 디스크에서 실제 데이터를 읽어오는 비율이 높다면 사용 중인 디스크 수에 가까운 값이 된다.

DB가 하나의 HDD에 의존하고 있고 캐시 히트율이 낮다면 스핀들 수를 1 정도로 볼 수 있고, SSD를 사용한다면 랜덤 액세스 지연이 매우 작으므로 스핀들 수를 0으로 취급하는 것이 일반적이다.

6. 테스트 지표 분석하기

본격적인 테스트에 앞서 각 지표들이 무엇을 의미하는지 알아 봅시다.

(idleConnection: 10, maxPoolSize: 20, 요청 스레드 수는 160으로 진행했습니다.)

Connections

img_2.png

초록색 부분은 active connections, 파란색 부분은 pending(대기) threads 입니다.

160개의 스레드가 커넥션에 몰리자 커넥션 풀은 즉시 maxPoolSize인 20개까지 activeConnection을 늘립니다.

연결된 커넥션은 20개이므로, 140개의 스레드가 대기 상태에 머물러 있게 됩니다. (커넥션 수를 스레드에 딱 맞추고 싶어집니다. 과연 그것이 효율적일지는 조금 뒤에 알아봅시다.)

Connection Time

img_3.png

초록색 부분은 usage time, 파란색 부분은 acquire time, 노란색 점은 creation time(커넥션 생성 시간) 입니다.

맨 처음 160개의 요청이 들어오자 커넥션 풀은 maxPoolSize를 체크하여 커넥션을 최대까지 생성했습니다.

우리는 앞에서 커넥션 연결에 상당한 비용이 든다고 배웠는데, 맨 앞부분의 usage time만 유독 치솟은 것이 그것을 뒷받침해주는 지표라고 할 수 있겠습니다.

이 지표에서 중요한 것은, 얼마나 낮은 usage time과 acquire time을 안정적으로 유지하는지 입니다.

커넥션 사용 시간이 짧다는 것은 그만큼 데이터 처리가 빠르고 효율적이라는 뜻이고, 대기 시간이 길어지면 일하지 않는 스레드가 생기고 timeout 예외나 커넥션 누수가 발생할 수 있기 때문입니다.

img.png ![img_1.png]https://github.com/Kernel360/blog-image/blob/main/2025/0430/img_1.png?raw=true)

JVM 프로세스의 CPU 사용률과 활성 스레드 수입니다.

위의 connection time과 비슷하게 첫 요청에는 많은 자원이 투입되고, 이후 점점 줄어드는 모습을 보입니다.

우리는 당연히 같은 작업에 대해 최대한 적은 자원을 사용하고 싶으므로, 위 지표도 낮고 안정적이기를 원합니다.

7.풀 사이즈 별 성능 지표

이제 풀 사이즈를 조정하며 풀 크기에 따라 작업 효율이 어떻게 달라지는지를 알아봅시다.

1. idleConnection=10, maxConnection=20

img_11.png

전체 처리시간(ms)은 모든 INSERT 작업이 끝난 시점, 즉 60만번의 데이터 save에 걸린 시간입니다.

바로 위에서 설명한 테스트 지표가 1번 케이스의 지표입니다.

요청 한 번에 평균적으로 100ms(0.1초) 정도 커넥션을 사용하고 있습니다.

다만 140개의 많은 스레드가 커넥션을 대기하고 있기 때문에, 스레드가 커넥션을 얻기까지의 시간이 꽤 오래 걸립니다.

만약 커넥션 풀의 사이즈를 조정해서 최대 커넥션 수를 늘린다면, 스레드의 대기시간이 줄어 전체 처리 시간이 감소하지 않을까요?

2. idleConnection=20, maxConnection=100

img_12.png

최대 커넥션 수를 100개까지 늘렸습니다.

(표에는 생략했으나, 커넥션 풀은 160개의 요청을 받자마자 커넥션을 최대치인 100개까지 생성하여 작업에 투입했습니다.)

그런데 당황스럽게도, 전체 처리 시간이 오히려 미미하게 상승한 것을 볼 수 있습니다.

active connection 수가 5배나 늘었지만 동시에 평균 커넥션 사용시간도 5배가 늘었네요.

작업자는 많아졌는데, 그만큼 능률이 낮아진 상황입니다.

우연일 수도 있으니 커넥션 수를 160개, 즉 스레드 수와 딱 맞춰 대기 시간 없이 커넥션을 사용할 수 있게 해보겠습니다.

3. idleConnection=20, maxConnection=160

img_10.png

전체 처리 시간이 조금 낮아진 것을 볼 수 있습니다. 사용 시간은 여전히 길고 불안정 하네요.

그런데 더 중요한 것은, 대기중인 스레드가 31개나 있는데 실제로 사용된 커넥션은 129개에 그쳤다는 것입니다.

그리고 매 테스트마다 creation time이 생깁니다. 이는 커넥션 풀이 생성한 커넥션을 원활히 재사용하지 못하고 있다는 것을 뜻합니다.

원인으로 생각할 수 있는 것은 다음과 같습니다.

  1. 과도하게 많은 커넥션이 락 경합, 디스크 I/O 병목, 그리고 스레드 간 컨텍스트 스위칭 오버헤드를 증가시켜 처리 성능이 저하되었다.

  2. 저하된 성능으로 인해 HikariCP의 풀 확장 속도가 수요를 따라가지 못했거나, 반환이 제대로 이루어지지 않아 커넥션을 새로 생성해야 했다.

  3. 혹은 테스트 스크립트의 동시성 한계로 인해 반환된 커넥션이 재사용되면서 풀 확장이 자연스럽게 멈췄다.

정확한 원인은 더욱 심도있는 테스트가 필요하겠으나, 우리가 확실히 알 수 있는 것은 무조건적인 풀 크기 확장이 성능과 비례하지 않는다는 것입니다.

또한 테스트 환경이 8코어 CPU 였다는 점을 감안하면, 풀 사이즈를 무작정 늘림으로써 발생하는 오버헤드가 매우 큰 영향을 미칠 것이라는 것을 예상할 수 있습니다.

4. 스레드 수도 조정해보기

마지막으로 비동기 스레드 풀과 커넥션 풀 사이즈를 모두 최대 20개로 제한해 보겠습니다.

img_13.png img_14.png

(사진에는 NaN으로 표시되었으나 usage time이 50대로 줄어들었습니다.)

평균 처리 시간은 60,885ms로 풀 사이즈가 160개일 때보다 10초정도 늘었습니다.

그러나 커넥션 사용 시간은 10배 가까이 감소했으며, JVM 스레드도 100개 가까이 덜 쓰고 있습니다.

스레드 설정을 조정함으로써 처리 시간과 서버 부하를 어느정도 등가 교환했다고 볼 수 있겠네요.

이제 우리는 실제 서버의 CPU 성능에 따라 스레드, 커넥션 풀 사이즈를 조정해가며 합의점을 찾아낼 수 있게 되었습니다.

8. 결론

결론이 다소 흐지부지된 것처럼 보일 것이라고 생각합니다.

“그래서 도대체 무슨 설정을 몇으로 설정하라는건데?”

이것에 대한 답은, 결국 서비스의 환경과 요구사항에 따라 개발자 본인이 찾아야 합니다.

어떤 부분의 성능을 개선하고자 할 때,

  1. 성능 목표치와 유효한 지표, 환경에 따른 적절한 초기값을 설정하고,

  2. 테스트를 통해 지표들의 의미를 분석해가며 점진적으로 목표에 가까워져야 합니다.

저는 이 글을 작성하면서, 위와 같은 과정을 커넥션 풀 사이징을 예시로 직접 겪어 보았습니다.

굉장히 번거롭고 오래 걸리는 작업이었지만, 동시에 좋은 서비스를 개발하기 위해서는 필수적인 작업이라고 느꼈습니다.

이 글을 통해 많은 분들이 테스트를 통한 성능 개선을 시도해보고, 자신만의 최적화 방식을 찾아내셨으면 좋겠습니다.

긴 글 읽어주셔서 감사합니다.