커넥션 풀 크기를 몇으로 둘까 — pool_size·워커 수 계산법

커넥션 풀 크기를

커넥션 풀 크기를 정할 때 “일단 넉넉하게 잡아두면 안전하다”고 생각하는 경우가 많습니다. 하지만 코어 4개짜리 서버라면 HikariCP가 제시하는 공식으로 계산했을 때 적정 pool_size는 9 정도에 불과합니다. 100, 200씩 크게 잡아둔 풀이 오히려 DB 서버의 컨텍스트 스위칭 부하를 키우고 응답 속도를 떨어뜨리는 경우가 흔합니다.

이 글에서는 pool_size·워커 수·max_connections를 어떤 순서로 맞춰야 하는지, 실제 공식과 숫자로 짚어보겠습니다. 어떤 조건에서 이 숫자가 맞고, 언제 다시 계산해야 하는지도 함께 보시면 좋습니다.

커넥션 풀을 크게 잡을수록 빨라질까요?

DB 서버가 처리할 수 있는 동시 작업량은 CPU 코어 수와 디스크 I/O로 정해져 있습니다. 풀 크기를 늘려서 애플리케이션이 더 많은 연결을 맺어도, DB 쪽에서 동시에 실행할 수 있는 쿼리 개수가 늘어나는 건 아닙니다. 코어 4개짜리 서버에 커넥션 200개가 동시에 쿼리를 던지면, 그 200개는 결국 4개 코어 안에서 순서를 기다리게 됩니다.

필요한 커넥션 수는 처리량과 쿼리 시간으로 계산할 수 있습니다. 초당 요청 200개를 받고 쿼리 평균 처리 시간이 25ms라면, 동시에 필요한 커넥션 수는 200 × 0.025 = 5개 정도면 충분합니다. 이 계산은 대기 중인 작업 수를 처리율과 평균 체류 시간의 곱으로 구하는 리틀의 법칙에서 나온 접근입니다. 필요량보다 훨씬 큰 풀을 만들어 둬도 처리량이 그만큼 늘지는 않습니다.

pool_size 계산 공식 — 코어 수와 디스크로 구하는 법

자바 커넥션 풀 라이브러리 HikariCP 공식 위키에는 커넥션 수 = (코어 수 × 2) + effective_spindle_count라는 공식이 정리돼 있습니다. SSD를 쓰는 환경이면 effective_spindle_count는 보통 1로 둡니다.

서버 코어 수 effective_spindle_count(SSD) 계산된 pool_size
4 1 9
8 1 17
16 1 33

이 공식은 단순 조회·갱신 위주의 짧은 OLTP 쿼리를 전제로 만들어졌습니다. 집계 쿼리나 배치 작업처럼 한 쿼리가 수 초 이상 걸리는 환경에서는 같은 공식을 그대로 쓰면 숫자가 너무 작게 나옵니다. 그런 워크로드는 쿼리 수가 아니라 동시에 실행되길 원하는 작업 수를 기준으로 따로 잡아야 합니다.

워커 수를 곱하면 숫자가 커지는 이유

Gunicorn이나 uWSGI처럼 동기 워커 모델을 쓰는 서버는 워커 프로세스마다 자기 몫의 커넥션 풀을 따로 가집니다. 워커가 4개고 각 워커의 풀이 pool_size 5, max_overflow 5라면, DB에 실제로 걸리는 최대 연결 수는 4 × (5+5) = 40개입니다.

from sqlalchemy import create_engine

engine = create_engine(
    "postgresql+psycopg2://user:pass@db:5432/mydb",
    pool_size=5,
    max_overflow=5,
    pool_timeout=30,
    pool_recycle=1800,
)

여기서 워커 수를 곱해야 한다는 걸 놓치면, 코어 수 공식으로 pool_size만 9로 잡아놓고 워커 8개를 띄워서 실제로는 72개 연결이 걸리는 식의 계산 실수가 생깁니다. 실무에서는 (워커 수 × 풀 크기)의 합이 DB의 max_connections 한계치의 80%를 넘지 않게 여유를 두는 방식을 많이 씁니다. 나머지 20%는 배치 작업이나 관리자 접속, 모니터링 도구가 쓸 몫으로 비워두는 셈입니다.

비동기 프레임워크는 셈법이 다릅니다

FastAPI나 Node.js처럼 이벤트 루프 기반으로 동작하는 서버는 워커(프로세스) 하나가 요청 하나만 처리하는 구조가 아닙니다. 쿼리가 I/O 대기 중일 때 같은 프로세스가 다른 요청을 계속 처리할 수 있어서, 풀이 작아도 훨씬 많은 동시 요청을 받아낼 수 있습니다.

import asyncpg

pool = await asyncpg.create_pool(
    dsn="postgresql://user:pass@db:5432/mydb",
    min_size=5,
    max_size=20,
)

개발자가 코드 성능을 튜닝하는 모습

위 설정의 max_size 20은 “동시 접속자 20명까지만 받는다”는 뜻이 아닙니다. 요청이 몰리면 풀이 다 찬 요청들은 짧게 줄을 서서 기다리다가 빈 커넥션을 받습니다. 참고로 Node.js의 pg 라이브러리는 Pool의 기본 max 값이 10으로 잡혀 있어서, 별도 설정 없이 쓰면 생각보다 작은 값으로 운영되고 있는 경우가 많습니다.

max_connections, DB 쪽 한계도 같이 맞춰야 합니다

PostgreSQL의 max_connections 기본값은 100입니다. 여기서 superuser_reserved_connections 기본 3개를 관리자용으로 따로 빼두기 때문에, 일반 애플리케이션이 실제로 쓸 수 있는 몫은 그보다 조금 적습니다. 커넥션은 연결돼 있는 동안 메모리를 점유하기 때문에, 숫자를 무턱대고 늘리는 건 DB 서버 메모리를 깎아먹는 일이기도 합니다.

앱 서버 인스턴스가 여러 대로 늘어나서 (인스턴스 수 × 풀 크기) 합이 max_connections를 넘길 것 같다면, 애플리케이션 풀을 더 줄이기보다 pgbouncer 같은 프록시형 풀러를 중간에 두는 방법이 있습니다. transaction 풀링 모드로 두면 애플리케이션이 맺은 연결 수백 개를 DB 쪽 연결 수십 개로 줄여서 전달할 수 있습니다.

커넥션 풀을 늘렸는데 응답이 더 느려지는 경우

풀을 줄여야 할 때인데 거꾸로 늘리면 문제가 더 심해지는 경우가 있습니다. 트랜잭션이 커밋되지 않은 채로 커넥션이 풀에 반환되면, 그 연결은 락을 쥔 상태로 다음 요청에 재사용돼서 다른 쿼리가 줄줄이 대기하게 됩니다. 이런 상태에서는 풀 크기를 키울수록 대기 중인 잠긴 커넥션 수만 늘어날 뿐입니다.

이럴 때는 pool_recycle이나 idle timeout 값을 줄여서 오래된 연결을 주기적으로 끊어내고, DB의 pg_stat_activity를 확인해서 idle in transaction 상태로 오래 머무는 연결이 있는지부터 봐야 합니다. 코어 수 공식으로 나온 숫자는 어디까지나 시작점이고, 실제 서비스 트래픽으로 부하 테스트를 돌려본 뒤에 조정하는 쪽이 안전합니다.

지금 운영 중인 서버라면, 워커 수와 pool_size를 곱한 값이 DB의 max_connections 중 몇 퍼센트를 차지하는지부터 한 번 계산해보시기 바랍니다. 그 값이 80%를 넘는다면 풀을 줄이거나 pgbouncer 도입을 검토해볼 차례입니다.

운영 중 컬럼 추가가 테이블을 잠그는 이유와 피하는 마이그레이션 순서

Leave a Comment