목차
스레드가 DB를 부르려면 연결이 필요하다
Thread Pool의 끝에서 흘렸던 이야기다. 스레드 풀 뒤에는 대개 DB가 있고, 스레드가 DB에 쿼리를 보내려면 먼저 **연결(connection)**을 맺어야 한다.
연결을 맺는다는 건 DB와 TCP로 이어지고, 로그인해 인증받고, 세션을 여는 것이다. 이 연결이 있어야 쿼리를 주고받는다.
그러면 이 연결을 요청마다, 혹은 쿼리마다 새로 맺으면 어떻게 될까. 어디서 본 문제가 그대로 반복된다.
DB 연결은 만드는 것도 무제한도 비싸다
첫째, 만드는 게 비싸다. 연결 하나를 맺는 데 TCP 핸드셰이크 + 인증 + 세션 셋업이 든다. 네트워크 왕복이 껴서 스레드 만드는 것보다도 느릴 수 있다. 쿼리 자체보다 연결 맺기가 더 오래 걸리는 일도 흔하다.
둘째, 무제한이면 DB가 죽는다. DB는 받을 수 있는 연결 수에 상한(max_connections)이 있고, 연결마다 DB 쪽에서 메모리와 프로세스를 쓴다. 앱이 연결을 마구 열면, 앱이 아니라 DB가 먼저 쓰러진다.
Thread Pool에서 스레드가 겪은 두 문제와 똑같다. 그러니 해법도 똑같다.
Connection Pool: 미리 열어 재사용
연결을 미리 몇 개 열어두고 재사용한다. 스레드가 DB를 쓸 때 풀에서 연결을 빌리고, 쿼리가 끝나면 닫지 않고 반납한다. 다음 스레드가 그 연결을 다시 쓴다. 스레드 풀과 발상이 같다.
Java에서는 DataSource가 이 풀을 감춰준다.
try (Connection conn = dataSource.getConnection()) { // 풀에서 빌린다 (새로 안 맺음)
// 쿼리 실행
} // try 끝 → close()가 실제로 닫지 않고 풀에 반납getConnection()이 연결을 새로 맺는 것처럼 보이지만, 풀이 있으면 이미 열린 연결을 빌려준다. close()도 진짜 닫는 게 아니라 반납이다.
스레드 풀과 다른 점: 연결은 DB의 자원이다
여기서 스레드 풀과 결정적으로 갈린다. 스레드는 앱 안의 자원이라 앱이 크기를 정하면 그만이었다. 그런데 연결은 앱 밖, DB의 자원이다. 상한을 DB가 정한다.
그래서 제약이 하나 더 붙는다.
- 커넥션 풀 크기는 DB의
max_connections를 넘으면 안 된다. - 게다가 앱이 여러 인스턴스로 떠서 같은 DB를 본다면, 인스턴스들의 풀 크기 합이
max_connections를 넘으면 안 된다. 앱 3대에 풀 20씩이면 DB는 60개를 감당해야 한다.
스레드 풀에는 없던 제약이다. 커넥션 풀 크기는 앱 혼자 정하는 게 아니라 DB와 나눠 쓰는 예산이다.
두 풀이 맞물린다
그래서 스레드 풀과 커넥션 풀이 직렬로 놓인다. 요청은 스레드 풀을 지나고, 그 스레드가 DB를 쓰려면 커넥션 풀을 또 지난다.
두 크기가 서로 맞물린다.
- 커넥션이 스레드보다 적으면(M < N) - 스레드 N개가 동시에 DB를 쓰려 해도 M개만 쓰고, 나머지는 커넥션을 기다린다. 커넥션 풀이 병목이 된다.
- 커넥션이 스레드보다 많으면(M > N) - 동시에 DB를 쓰는 스레드가 최대 N개뿐이라, N을 넘는 커넥션은 놀기만 한다. 낭비다.
그래서 커넥션 풀 크기는 스레드 수와 DB 상한 사이 어딘가에서 정해진다.
풀은 작을수록 좋다
직관은 “스레드가 많으니 커넥션도 많이 열면 빠르겠지”인데, 반대다. HikariCP 문서의 유명한 조언이 “풀을 작게 하라”다.
왜냐하면 DB가 진짜로 동시에 처리하는 쿼리는 결국 CPU 코어와 디스크 수에 묶이기 때문이다. 커넥션을 100개 열어도 DB가 실제로 동시에 굴리는 건 몇 개뿐이고, 나머지 커넥션의 쿼리는 DB 안에서 줄을 선다. 커넥션이 많을수록 DB의 컨텍스트 스위칭과 락 경합만 늘어 오히려 느려진다.
그래서 커넥션 풀은 “DB를 실제로 바쁘게 쓸 만큼”이면 충분하고, 그 이상은 해가 된다. 작게 잡고, 스레드가 잠깐 커넥션을 기다리는 것(큐잉)을 받아들이는 편이 낫다. 이건 Thread Pool에서 “너무 크게 잡지 말라”고 한 것과 같은 결이지만, 이유는 다르다. 스레드는 컨텍스트 스위칭 때문이었고, 커넥션은 DB 내부의 경합 때문이다.
빌렸으면 반납해야 한다
풀에는 전제가 하나 있다. 빌린 연결을 반드시 반납해야 한다. 반납하지 않으면(커넥션 누수, connection leak) 그 연결은 풀에서 영영 빠져나가, 풀이 조금씩 마른다.
그러다 빌릴 연결이 하나도 안 남으면, 모든 요청이 커넥션을 기다리다 타임아웃된다. DB는 멀쩡한데 앱이 멈추는 상황이다. 원인을 찾기도 어렵다.
그래서 반드시 반납되게 짠다.
- try-with-resources -
close()가 자동 호출되니 곧 반납이다. 위 예제가 그 형태다. - Spring에 맡기기 -
@Transactional·JdbcTemplate·JPA는 트랜잭션 경계에서 알아서 빌리고 반납한다. 오히려getConnection()을 직접 불러 손으로 관리하는 게 누수 위험이 크다.
요점은 빌리는 코드와 반납하는 코드가 짝을 이뤄야 하고, 그 짝을 프레임워크에 맡기는 게 안전하다는 것이다.
실무: HikariCP
Spring Boot의 기본 커넥션 풀이 HikariCP다. 따로 붙이지 않아도 이미 풀을 쓰고 있다.
spring.datasource.hikari.maximum-pool-size=10
spring.datasource.hikari.connection-timeout=30000- maximum-pool-size - 풀의 최대 크기. 위에서 “작게 잡으라”던 그 숫자다.
- connection-timeout - 풀에서 연결을 못 빌리고 기다리는 최대 시간(ms). 이 시간을 넘으면 예외가 난다. 누수나 과부하를 빨리 드러내주는 안전장치다.
HikariCP가 기본인 건 빠르고 가벼워서다. 대개 손대는 건 maximum-pool-size 하나이고, 그 값을 정하는 감각이 이 글에서 본 것들이다.
트리오는 한 뼈대였다
여기까지 세 편이 사실 같은 이야기였다.
- Concurrency - 요청을 동시에 처리해야 한다 (그리고 공유 상태를 조심한다).
- Thread Pool - 그 동시 처리의 단위인 스레드를 미리 만들어 재사용하고, 개수로 제한한다.
- Connection Pooling - 그 스레드가 쓰는 DB 연결도 미리 만들어 재사용하고, 개수로 제한한다.
뼈대가 하나다. 비싼 자원은 미리 만들어 재사용하고, 개수로 상한을 그어 뒤에 있는 것을 보호한다. 스레드 풀은 CPU를, 커넥션 풀은 DB를 보호한다. 층만 다를 뿐 같은 도구가 반복된다.
정리
| 왜 필요 | DB 연결은 맺는 게 비싸고, 무제한이면 DB가 죽는다 |
| Connection Pool | 연결을 미리 열어두고 재사용한다 (빌리고 반납) |
| 스레드 풀과 차이 | 연결은 DB의 자원. 상한을 DB가 정한다(max_connections) |
| 여러 인스턴스 | 인스턴스들의 풀 크기 합이 DB 상한을 넘으면 안 된다 |
| 두 풀 | 스레드 풀과 커넥션 풀이 직렬로 맞물린다. 크기가 서로를 제약 |
| 크기 | 작을수록 좋다 - 커넥션이 많으면 DB 내부 경합만 는다 |
| 누수 | 반납 안 하면 풀이 마른다. try-with-resources·@Transactional에 맡긴다 |
| 실무 | HikariCP (Spring Boot 기본), maximum-pool-size가 핵심 |
연결을 아끼자는 게 요점이 아니다. 스레드 풀과 똑같이 미리 만들어 재사용하고 개수로 제한하되, 그 개수가 이번엔 DB가 허락하는 만큼이라는 것 - 그게 커넥션 풀이 스레드 풀과 다른 점이다.