티스토리 뷰
커넥션 풀 (Connection Pool)
커넥션을 여는 과정은 느리기 때문에 요청이 들어올 때마다 커넥션을 열게되면 성능이 떨어지게 된다. 따라서 미리 여러 개의 DB 커넥션을 열어두고 요청이 들어오면 커넥션 풀에서 커넥션을 하나 가져와서 사용한 뒤 반납을 하는 방식으로 성능을 최적화할 수 있다.
커넥션 풀 사이즈와 응답 시간의 상관 관계
커넥션이 많으면 동시에 커넥션을 열어 처리할 수 있으니 처리율이 증가한다고 생각할 수도 있다. 실제로는 어떤지 테스트를 통해 확인해보자
추가로 HikariCP의 minimumIdle와 maximumPoolSize를 똑같이하여 고정 크기로 사용하는 것을 권장한다.
트래픽이 급증할 때는 커넥션을 새로 열어 커넥션 풀의 크기를 maximumPoolSize로 만드는 과정도 오버헤드가 될 수 있기 때문이다.
커넥션 풀 Size에 따른 응답 시간 테스트
테스트를 위한 간단한 API를 작성했다.
@RestController
@RequiredArgsConstructor
public class BoardController {
private final BoardService boardService;
@GetMapping("/query")
public String queryTest(){
boardService.query();
return "ok";
}
}
@Service
@RequiredArgsConstructor
public class BoardService {
private final BoardRepository boardRepository;
@Transactional(readOnly = true)
public void query(){
boardRepository.findById(1L);
}
}
( HikariCP의 설정과 함께 DBMS의 max_connection도 함께 조정했다. )
K6를 사용하여 1000 TPS에 5초정도 부하를 가한 뒤, 평균 응답 속도를 구했다.

커넥션 풀 사이즈가 클 수록 응답 속도가 느렸고, 너무 작아도 응답 속도가 느려지는 것을 확인할 수 있다.
느려지게 된 원인

CPU 코어(이하 코어)는 한 번에 하나의 작업만 처리할 수 있다. 이때 컨텍스트 스위칭을 이용하면 여러 스레드를 동시에 처리하는 듯한 효과를 줄 수 있다. 여기서 느려지게 된 원인이 발생하는데 바로 짧은 트랜잭션을 기다리는 비용보다 컨텍스트 스위칭으로 인한 오버헤드가 더 비싸기 때문에 위와 같은 결과가 발생한 것이다.

오히려 커넥션 풀 사이즈가 1개라면 컨텍스트 스위칭 발생을 줄여 더 빠른 응답을 줄 수 있게 되는 것이다.
위 그래프에서는 커넥션 풀 사이즈가 6개 일 때, 가장 처리 속도가 빨랐고 3개일 때는 오히려 느려졌는데 그 이유는 바로 CPU 코어 수가 6개인 환경에서 테스트를 진행했기 때문이다. 따라서 풀 사이즈 = 코어 수 인 환경에서 가장 빠른 응답 속도를 보인 것이다.

위와 같이 코어 수가 3개라면 커넥션 풀 사이즈가 3개일 때 가장 빠른 속도를 보일 것이다.
사용자의 입장에서는 응답이 빠르기만 하면 상관이 없다. 내부적으로 내 요청이 커넥션 풀을 얻기 위해 대기 중인지, 아니면 컨텍스트 스위칭을 대기 중인지는 궁금하지 않으므로 CPU가 효율적으로 동작할 수 있게 커넥션 풀에서 대기하도록 하는 방법인 것이다.
커넥션 풀 사이즈 = 코어 수?
테스트를 통해 커넥션 풀 사이즈가 코어 수랑 같을 때 가장 최적의 처리량을 가진다는 것을 확인할 수 있었다. 하지만 트랜잭션의 길이에 따라 결과가 달라질 수 있다.
OLTP(온라인 트랜잭션 처리) 시스템에서는 대부분 트랜잭션이 빠르게 종료된다. 위 테스트에서는 PK를 이용해 조회하는 아주 효율적인 쿼리를 작성했었다. 하지만 만약 쿼리 속도가 느려 트랜잭션이 긴 경우에는 어떻게 될까?
커넥션 풀 Size에 따른 응답 시간 테스트
DB에서 0.5초간 I/O를 수행하는 것처럼 흉내내보았다.
public interface BoardRepository extends CrudRepository<Board, Long> {
@Query(value = "SELECT SLEEP(:seconds)", nativeQuery = true)
void sleep(@Param("seconds") double seconds);
}
@GetMapping("/slow-query")
public String slowQueryTest() {
boardService.slowQuery(0.5);
return "OK";
}

커넥션 풀 사이즈가 작아질 수록 응답 속도가 느려지는 것을 확인할 수 있다. 참고로 30초 이후에는 timeout으로 실패했는데 커넥션 풀 사이즈가 6인 경우에는 요청의 91.93% 실패했고, 3인 경우에는 95.95%가 실패했다. (6개 이하의 풀 사이즈에서도 작아질수록 응답 속도가 길어졌다고 이해해주면 될 것 같다.)
또한 일정 커넥션 풀 사이즈(1000개) 이상에서는 더이상 응답 속도에 차이가 없는 것을 기억해두면 좋겠다.
느려지게 된 원인

I/O Call이 발생하면, 해당 시간동안 CPU 코어는 대기하게 된다. 코어가 일을 하지 않는 시간이 발생하게 되어 처리량이 떨어지게 된 것이다.

이런 경우에는 코어 수보다 많은 커넥션 풀 사이즈가 오히려 처리량을 높이는데 도움이 될 수 있다. 긴 트랜잭션을 기다리는 비용보다 컨텍스트 스위칭 오버헤드 비용이 더 저렴하기 때문이다.
풀 사이즈를 선택하는 방법
수 년간의 벤치마크에서 가장 최적의 처리량을 보인 공식은 ( 코어 수 * 2 + effective_spindle_count ) 이다.
effective_spindle_count는 실제 디스크 I/O가 병목되는 정도를 반영한 값으로 데이터가 전부 캐시되어 I/O가 발생하지 않으면 0에 가깝고 디스크 접근이 많으면 HDD의 플래터 수에 가까워진다.
effective_spindle_count에 대해 조금 더 이해해보자
앞선 테스트 결과에서 아무리 커넥션 풀 사이즈가 커지더라도 0.5초보다 빨라지지는 않았다. Sleep을 0.5초 걸어놔서 그런거 아니냐고 볼 수도 있지만, 이런 임계 지점은 HDD에서도 발생할 수 있다. 조금 더 자세히 알아보기 위해 HDD의 구조에 대해 살짝 알아보자

HDD는 플래터라는 디스크가 회전하고, 헤드를 통해 값을 읽는 방식으로 동작한다.

초당 한바퀴를 돌고, 한 트랙에 데이터가 1이 들어있다고 가정해보자
플래터가 1장인 HDD에서는 1초에 1만큼의 데이터를 읽을 수 있을 것이다.
플래터가 N장인 HDD에서는 1초에 N만큼의 데이터를 동시에 읽어올 수 있다.
이런 이해를 바탕으로 다시 주제로 돌아가자면, 결국 커넥션 풀 사이즈가 계속 커지더라도 HDD의 처리 성능의 한계로 인한 임계지점이 존재하기 때문에 응답 속도가 계속 빨라지지는 않는다는 것이고, 따라서 effective_spindle_count의 최대 값이 HDD 플래터 수인 것이다.
코어가 5개이고, 플래터가 5장인 환경에서
모든 데이터가 캐시되어 있다면 5 * 2 + 0 = 10이고,
모든 데이터를 HDD로부터 읽어와야 한다면 5 * 2 + 5 = 15이다.
개발자는 서비스의 환경(트랜잭션 길이 혹은 I/O가 병목되는 정도)에 따라 10~15 정도의 커넥션 풀 사이즈 크기를 기준으로 조금씩 튜닝해보며 최적의 사이즈를 설정하면 된다. 일반적으로 커넥션 풀 사이즈의 튜닝이 잘 된 경우 코어 수 * 2 보다 훨씬 큰 경우는 드물다.
이 공식은 HDD를 사용하는 환경에서만 효과적이다. SSD의 경우 HDD에 비해 훨씬 빠른 동시 처리 성능과 Random Access 성능을 보여주기 때문에 이 공식을 사용하는 것은 부적절할 수 있다.
커넥션 풀 사이즈와 교착상태(deadlock)
커넥션 풀 사이즈가 부족하면 교착상태가 발생할 수 있다.
교착상태를 피하기 위한 최소한의 풀 크기를 구하는 공식이다.
풀 크기 = 최대 스레드 수 X ( 스레드 별 최대 동시 연결 수 - 1 ) + 1
Spring 환경에 대입해보자면,
http 요청을 최대 2개 받을 수 있고, 각 요청은 트랜잭션을 사용하는데, 내부적으로 또 다른 트랜잭션을 REQUIRES_NEW 전파방식을 이용하여 트랜잭션을 최대 2개까지 열 수 있다고 가정해보자
풀 크기가 2개라면 어떻게 될까?
첫 번째 http 요청 -> 트랜잭션 시작(커넥션 1개 사용) -> 새로운 트랜잭션을 열기 위해 대기 중...
두 번째 http 요청 -> 트랜잭션 시작(커넥션 1개 사용) -> 새로운 트랜잭션을 열기 위해 대기 중...
두 요청 모두 커넥션을 얻기 위해 계속 기다리는 교착 상태가 발생한다.
풀 크기 = 최대 스레드 수 X ( 스레드 별 최대 동시 연결 수 - 1 ) + 1
3 = 2 X ( 2 - 1 ) + 1
공식에 따라 커넥션 풀 사이즈를 3 이상으로 설정하면 교착상태를 피할 수 있게 된다.
Transaction Propagation과 병목 현상에 대해 더 알아보고 싶으면 아래의 Spring Docs를 참고하자
혼합된 시스템에서의 처리 방법
짧은 트랜잭션과 긴 트랜잭션이 혼합된 시스템에서는 커넥션 풀을 분리하는게 효과적일 수 있다.

긴 트랜잭션 요청이 모든 커넥션을 차지하고 있다면, 빠르게 처리될 수 있는 짧은 트랜잭션 요청이 블로킹 될 수 있다.

긴 트랜잭션 용 커넥션 풀과 짧은 트랜잭션 용 커넥션 풀을 분리하여 사용한다면, 위와 같은 문제에서 효과적일 수 있다.
주의할 점
커넥션 풀 사이즈를 튜닝하거나, 커넥션 풀을 분리하는 과정을 수행하기 전에 선행되어야 할 작업은 현재 시스템에서 쿼리가 효율적으로 동작하는지를 점검하는 것이다. 커넥션 풀을 분리하는 것 보다 애초에 긴 트랜잭션이 발생하지 않도록 쿼리 튜닝, 인덱스 튜닝이 우선되어야 한다.
참고
https://github.com/brettwooldridge/HikariCP/wiki/About-Pool-Sizing
'DB' 카테고리의 다른 글
| [DB] RDBMS의 버퍼캐시 히트율이 100%라면 Redis를 대체할 수 있을까? (0) | 2025.09.02 |
|---|
- Total
- Today
- Yesterday
- 타임머신
- 6539
- C++
- 벨만포드
- 스택
- 1738
- 중위 표기식 후위 표기식으로 변환
- 11657
- 6018
- 7511
- 상범빌딩
- 1004
- 후위 표기식
- tea time
- 어린왕자 C++
- 골목길
- 백준
- 소셜네트워킹어플리케이션
- 골목길C++
- 벨만-포드
- 1918
| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | ||||
| 4 | 5 | 6 | 7 | 8 | 9 | 10 |
| 11 | 12 | 13 | 14 | 15 | 16 | 17 |
| 18 | 19 | 20 | 21 | 22 | 23 | 24 |
| 25 | 26 | 27 | 28 | 29 | 30 | 31 |