이 글의 위치: 기술 블로그 → Backend · 설계 판단. 작성·검증 방식은 Editorial Policy 참조.
본 글은 커넥션 풀 크기 결정의 업계 표준 공식을 1차 출처(PostgreSQL Wiki·HikariCP)에서 확인해 정리하고, 처리량 관점(Little’s Law)으로 검산하는 방법을 다룬다.
질문: 커넥션 풀, 몇 개가 적당한가
서비스가 느려지면 가장 먼저 떠오르는 직관은 "동시 요청이 많으니 커넥션 풀을 키우자"다. 50으로 부족하면 100, 200으로. 그런데 이 직관은 자주 틀린다. 풀을 키울수록 오히려 느려지는 구간이 존재하기 때문이다.
그렇다면 풀 크기는 감이 아니라 무엇을 기준으로 정해야 하나. 다행히 이 문제에는 널리 인용되는 표준 공식이 있다. 같은 "기준을 먼저 세우고 판단한다"는 결의 글로는 RPC를 언제 쓰는가 — 7가지 결정 기준도 함께 보면 좋다.
업계 표준 공식 — PostgreSQL / HikariCP
가장 널리 인용되는 공식은 PostgreSQL 프로젝트에서 나와 커넥션 풀 라이브러리 HikariCP의 "About Pool Sizing" 문서가 정리·전파한 것이다.
connections = ((core_count * 2) + effective_spindle_count)
용어 정리:
| 항목 | 의미 |
|---|---|
| core_count | 물리 CPU 코어 수. 하이퍼스레딩 논리 코어가 아니라 실제 코어 기준 |
| effective_spindle_count | 동시에 seek 할 수 있는 디스크(스핀들) 수. HDD 1개면 1 |
HikariCP 문서의 예시를 그대로 옮기면, 4코어 + HDD 1개 서버의 권장 풀은:
9 = ((4 * 2) + 1) → 넉넉히 잡아 10
코어/디스크 조합별로 계산하면 이렇다.
| 코어 | 디스크(스핀들) | 공식 결과 | 권장(올림) |
|---|---|---|---|
| 4 | 1 | 9 | 10 |
| 8 | 1 | 17 | 18 |
| 8 | 2 | 18 | 20 |
| 16 | 2 | 34 | 35 |
핵심은 대부분의 직관보다 훨씬 작은 숫자라는 점이다. 16코어 서버여도 30대면 충분하다는 얘기다.
왜 작은 풀이 더 빠른가
공식보다 중요한 건 그 뒤의 논리다. 어떤 워크로드든 병목은 결국 CPU · 디스크 · 네트워크 셋 중 하나다. 그리고 CPU 코어가 N개라면 진짜로 동시에 실행되는 작업은 N개뿐이다. 그 이상은 시분할(time-slicing)로 "동시처럼 보이게" 돌릴 뿐이고, 작업을 번갈아 끼우는 컨텍스트 스위칭 비용이 든다. HikariCP 문서의 표현을 빌리면 — A와 B를 순차로 실행하는 것이, 시분할로 "동시에" 실행하는 것보다 언제나 빠르다.
스레드가 디스크 seek나 네트워크 응답을 기다리며 블록되는 동안에만 추가 스레드가 이득이다. 하지만 그 이득에도 한계가 있어서, 일정 지점을 넘으면 컨텍스트 스위칭·락 경합(lock contention)·메모리 사용이 커넥션 수에 비례해 늘며 처리량을 갉아먹는다.
이게 추상론이 아니라는 건 HikariCP 문서가 인용하는 Oracle의 부하 테스트 데모가 보여준다. 다른 건 그대로 두고 커넥션 풀 크기만 줄였더니 응답 시간이 ~100ms에서 ~2ms로, 50배 이상 개선됐다. 풀을 키워서가 아니라 줄여서 빨라진 것이다.
"동시성을 늘리면 항상 빨라진다"는 가정이 깨지는 건 풀 사이징만의 얘기가 아니다. 멀티 프로세스가 싱글을 항상 이기지는 못한다는 같은 결의 실측 사례는 PDFium 벤치마크 — single vs multi 성능 비교에 정리해 두었다.
그래서 HikariCP가 내세우는 공리는 이렇다:
작은 풀을, 연결을 기다리는 스레드로 포화시켜라.
프런트엔드 사용자가 1만 명이 동시에 요청해도, 필요한 건 "수십 개" 수준의 작은 풀이다. 나머지 요청 스레드는 큐에서 잠깐 기다렸다가 연결을 받아 빠르게 처리하고 반납한다. HikariCP 문서는 4코어 풀 10개가 단순 쿼리 기준 3,000명 사용자를 6,000 TPS로 감당할 수 있다고 말한다.
처리량으로 검산 — Little’s Law
위 공식은 하드웨어(코어·디스크)에서 출발한 상한이다. 반대 방향, 즉 트래픽에서 출발해 필요한 풀 크기를 추정하고 싶다면 큐잉 이론의 Little’s Law가 유용하다. (HikariCP 문서에 직접 등장하는 건 아니고, 일반 큐잉 이론에서 가져오는 검산법이다.)
L = λ × W
필요 연결 수 ≈ 초당 쿼리 수(λ) × 쿼리 1건이 연결을 점유하는 시간(W)
예를 들어 초당 2,000 쿼리, 쿼리 한 건이 커넥션을 잡고 있는 평균 시간이 5ms(0.005초)라면:
2,000 × 0.005 = 10 개
여기에 지연 변동·버스트 대비 헤드룸을 얹어 12~15개로 잡는다. 하드웨어 공식(≈10)과 처리량 검산(≈10)이 비슷한 수로 수렴한다면, 그 값은 꽤 믿을 만한 시작점이다. 둘이 크게 어긋난다면 — 예컨대 Little’s Law가 200을 가리킨다면 — 그건 풀을 키울 신호가 아니라 쿼리 점유 시간(W)이 너무 길다는 신호다. 느린 쿼리를 먼저 잡아야 한다.
SSD·클라우드 시대 보정
표준 공식의 effective_spindle_count는 회전 디스크(스핀들)가 한 번에 하나의 seek를 한다는 HDD 시대의 전제에서 나왔다. SSD·NVMe는 내부 병렬성이 높아 "스핀들 1개"로 못 박기 애매하다. 그래서 SSD 환경에서는 디스크 항을 그대로 적용하기보다, core_count × 2 ~ × 4 범위를 시작점으로 두고 측정으로 좁히는 편이 현실적이다.
매니지드 DB(RDS·Cloud SQL 등)를 쓴다면 인스턴스 등급별로 max_connections 상한이 정해져 있으니 이를 먼저 확인해야 한다. 아무리 공식이 작은 수를 권해도, 앱 인스턴스가 늘면 총 연결이 그 상한에 부딪힌다.
실무 보정 체크리스트
공식이 준 시작점을 실제 환경에 맞춰 조정할 때 빠뜨리기 쉬운 항목들:
- 총 연결 = 풀 크기 × 앱 인스턴스 수. 오토스케일로 인스턴스가 10개로 늘면, 풀 10짜리도 DB 입장에선 100개 연결이다.
max_connections를 넘지 않게 역산한다. - DB의
max_connections에서 운영 몫을 빼고 배분. 마이그레이션·모니터링·관리자 접속·백업 도구가 쓸 연결을 남겨 둔다. - 풀은 고정(min = max) 권장. 가변 풀은 부하 급증 순간 연결을 폭증시켜 오히려 DB를 흔든다.
- 모니터링 지표는 active / idle / pending(대기). pending이 계속 쌓이면 우선 의심할 건 "풀이 작다"가 아니라 "쿼리·DB가 느리다"다. 풀을 키우기 전에 점유 시간부터 본다.
설정 자체는 라이브러리마다 한 줄이다. 어려운 건 값이 아니라 그 값을 정하는 근거다.
# 예: Spring Boot + HikariCP
spring:
datasource:
hikari:
maximum-pool-size: 10 # (코어×2)+디스크 → Little's Law 검산 → 측정 보정
minimum-idle: 10 # 고정 풀 (min = max)
Go에서는? — database/sql이 곧 커넥션 풀이다
Java에서는 JDBC 자체에 풀이 없어 HikariCP 같은 별도 풀 라이브러리를 얹는다. Go는 다르다. 표준 라이브러리 database/sql의 *sql.DB 객체 자체가 이미 커넥션 풀이다. sql.Open()으로 받은 *sql.DB 하나를 앱 전체에서 공유하면 그게 풀이고, HikariCP 같은 외부 의존성을 추가할 필요가 없다.
그래서 "Spring처럼 풀을 만든다"는 건 Go에선 *sql.DB에 설정 메서드 몇 개를 호출하는 것으로 끝난다. HikariCP 옵션과 거의 1:1로 대응된다.
| HikariCP (Java) | Go database/sql |
역할 |
|---|---|---|
maximumPoolSize |
db.SetMaxOpenConns(n) |
풀 최대 크기 — 위 공식이 정하는 그 값 |
minimumIdle |
db.SetMaxIdleConns(n) |
유지할 idle 연결 수 |
maxLifetime |
db.SetConnMaxLifetime(d) |
연결 최대 수명 |
idleTimeout |
db.SetConnMaxIdleTime(d) |
idle 연결 정리 시간 (Go 1.15+) |
connectionTimeout |
쿼리 context 데드라인 |
연결 획득 대기 한도 |
import (
"database/sql"
"time"
_ "github.com/jackc/pgx/v5/stdlib" // pgx 를 database/sql 드라이버로
)
db, err := sql.Open("pgx", dsn)
// ...
db.SetMaxOpenConns(10) // = maximumPoolSize. (코어×2)+디스크로 정한 값
db.SetMaxIdleConns(10) // = minimumIdle. 고정 풀이면 MaxOpen 과 동일하게
db.SetConnMaxLifetime(30 * time.Minute) // = maxLifetime
db.SetConnMaxIdleTime(5 * time.Minute) // = idleTimeout
놓치기 쉬운 기본값 두 개 — 이게 Go 풀의 함정이다:
SetMaxOpenConns기본값은 무제한(0)이다. 설정을 빼먹으면 부하가 몰릴 때 연결이 끝없이 열려 DB 의max_connections를 터뜨린다. 반드시 명시해야 하는 이유다.SetMaxIdleConns기본값은 2다. MaxOpen 이 10인데 MaxIdle 이 2면, 쓰고 반납된 연결 8개가 idle 로 돌아갈 때마다 닫히고 다음 요청에서 다시 여는 churn 이 생긴다. 고정 풀을 원하면MaxIdle = MaxOpen으로 맞춘다.
PostgreSQL 을 pgx 로 네이티브하게 쓴다면 database/sql 대신 전용 풀 pgxpool 을 쓸 수도 있다. 획득 대기·헬스체크 등 인터페이스가 더 풍부하다.
import "github.com/jackc/pgx/v5/pgxpool"
cfg, _ := pgxpool.ParseConfig(dsn)
cfg.MaxConns = 10 // = maximumPoolSize
cfg.MinConns = 10 // 고정 풀
pool, _ := pgxpool.NewWithConfig(ctx, cfg)
핵심은 사이징 공식 자체는 언어와 무관하다는 것이다. (코어×2)+디스크 도 Little’s Law 도 DB 의 처리 능력을 기준으로 한 값이라, 클라이언트가 Java 든 Go 든 같은 수에서 출발한다. 바뀌는 건 "어떻게 설정하느냐"(라이브러리 API)일 뿐 "몇 개로 정하느냐"(공식)가 아니다.
정리
- 시작점은 표준 공식
(코어 × 2) + 디스크. - 트래픽 쪽에서 Little’s Law(λ × W)로 검산해 두 값이 수렴하는지 본다.
- SSD·인스턴스 수·
max_connections로 보정하고, 최종은 측정으로 좁힌다. - 기억할 한 줄: 풀 사이징의 목표는 "더 많이"가 아니라 "충분히 작게 두고, 스레드를 기다리게" 하는 것이다.
더 많은 백엔드 설계 판단 글은 Backend 카테고리에서 이어진다.
참고
- HikariCP — About Pool Sizing (Brett Wooldridge) · github.com/brettwooldridge/HikariCP/wiki/About-Pool-Sizing
- PostgreSQL Wiki — Number Of Database Connections · wiki.postgresql.org/wiki/Number_Of_Database_Connections
- Little’s Law (queueing theory) · en.wikipedia.org/wiki/Little%27s_law
- Go
database/sql— Managing connections · pkg.go.dev/database/sql (SetMaxOpenConns·SetMaxIdleConns·SetConnMaxLifetime) - pgx · github.com/jackc/pgx (
pgxpool)
답글 남기기