[Daily morning study] 데이터베이스 커넥션 풀링
#daily morning study
데이터베이스 커넥션 풀링
커넥션을 매번 새로 만들면 어떤 문제가 생기나
DB에 쿼리를 날리려면 먼저 TCP 연결을 수립하고, DB 인증을 거치고, 세션 초기화까지 해야 한다. 이 과정이 매 요청마다 반복되면 latency가 눈에 띄게 올라간다. MySQL 기준으로 커넥션 하나 맺는 데 보통 5~10ms 정도 걸리는데, 초당 수백 개의 요청이 들어오는 서비스라면 이것만으로도 병목이 된다.
커넥션 풀은 이 문제를 해결하기 위해 커넥션 여러 개를 미리 만들어 두고, 필요할 때 빌려줬다가 사용이 끝나면 반납받는 방식으로 동작한다.
동작 원리
[ 애플리케이션 ]
|
| 커넥션 요청
↓
[ 커넥션 풀 ]
┌────────────────────────────────┐
│ conn1 (idle) │
│ conn2 (in-use → 요청에 할당) │
│ conn3 (idle) │
│ ... │
└────────────────────────────────┘
|
| 실제 DB 통신
↓
[ Database ]
- 앱이 커넥션을 요청하면 풀에서 idle 상태인 커넥션을 꺼내 준다.
- 쿼리를 실행하고 나면 커넥션을 닫지 않고 풀에 반납한다.
- 풀에 idle 커넥션이 없으면 대기하거나, 풀 크기가 설정값 이하면 새 커넥션을 만든다.
- 최대 풀 크기에 도달하면 새 커넥션 생성이 차단되고 타임아웃까지 대기한다.
주요 설정 항목
| 설정 | 의미 |
|---|---|
minimumIdle / minPoolSize | 풀이 유지하는 최소 커넥션 수 |
maximumPoolSize / maxPoolSize | 풀이 허용하는 최대 커넥션 수 |
connectionTimeout | 커넥션 요청 후 대기할 최대 시간 |
idleTimeout | idle 커넥션이 풀에서 제거되기까지의 시간 |
maxLifetime | 커넥션 하나의 최대 수명 (DB 서버 wait_timeout보다 짧아야 함) |
keepaliveTime | idle 커넥션 유효성 검사 주기 |
HikariCP 예시 (Java/Spring Boot)
Spring Boot 2.x 이후 기본 풀이 HikariCP다.
# application.yml
spring:
datasource:
url: jdbc:mysql://localhost:3306/mydb
username: user
password: pass
hikari:
minimum-idle: 5
maximum-pool-size: 20
connection-timeout: 30000 # 30초
idle-timeout: 600000 # 10분
max-lifetime: 1800000 # 30분
// DataSource를 직접 구성할 때
HikariConfig config = new HikariConfig();
config.setJdbcUrl("jdbc:mysql://localhost:3306/mydb");
config.setUsername("user");
config.setPassword("pass");
config.setMaximumPoolSize(20);
HikariDataSource ds = new HikariDataSource(config);
Node.js 예시 (mysql2)
import mysql from 'mysql2/promise';
const pool = mysql.createPool({
host: 'localhost',
user: 'user',
password: 'pass',
database: 'mydb',
waitForConnections: true,
connectionLimit: 10, // maximumPoolSize에 해당
queueLimit: 0, // 대기열 무제한 (0 = 무제한)
});
// 커넥션을 명시적으로 빌리지 않아도 pool.query()로 바로 사용 가능
const [rows] = await pool.query('SELECT * FROM users WHERE id = ?', [userId]);
// 트랜잭션은 커넥션 하나를 유지해야 하므로 getConnection() 사용
const conn = await pool.getConnection();
try {
await conn.beginTransaction();
await conn.query('UPDATE accounts SET balance = balance - ? WHERE id = ?', [amount, fromId]);
await conn.query('UPDATE accounts SET balance = balance + ? WHERE id = ?', [amount, toId]);
await conn.commit();
} catch (err) {
await conn.rollback();
throw err;
} finally {
conn.release(); // 반드시 반납
}
Python 예시 (SQLAlchemy)
from sqlalchemy import create_engine
engine = create_engine(
"postgresql://user:pass@localhost/mydb",
pool_size=10, # 기본 풀 크기
max_overflow=5, # pool_size 초과 허용 임시 커넥션 수
pool_timeout=30, # 커넥션 대기 타임아웃 (초)
pool_recycle=1800, # max_lifetime에 해당
pool_pre_ping=True, # 커넥션 유효성 검사 활성화
)
pool_pre_ping=True를 설정하면 커넥션을 꺼낼 때 SELECT 1 같은 경량 쿼리로 살아있는지 먼저 확인한다. DB 서버가 커넥션을 끊었는데 풀이 모르고 있다가 에러를 뱉는 “stale connection” 문제를 막아 준다.
풀 크기를 어떻게 잡아야 할까
직관적으로는 크면 클수록 좋을 것 같지만 그렇지 않다. HikariCP 공식 문서에서 소개하는 공식이 있다.
pool size = Tn × (Cm - 1) + 1
Tn: 동시에 실행되는 스레드(워커) 수Cm: 하나의 트랜잭션에서 동시에 보유하는 최대 커넥션 수
예를 들어 워커 10개가 각각 커넥션 1개씩만 쓴다면 풀 크기 10이면 충분하다.
현실적인 접근은 DB 서버의 max_connections와 앱 인스턴스 수를 함께 고려하는 것이다.
인스턴스당 최대 풀 크기 = DB max_connections / 앱 인스턴스 수
DB가 max_connections = 200이고 앱 서버가 4대면, 인스턴스당 최대 50개 이하로 잡는 게 맞다. 여기서 모니터링·관리 툴이 쓰는 커넥션 여유분도 남겨 두어야 한다.
풀이 너무 크면 DB 서버의 메모리와 스케줄링 부담이 커지고, 너무 작으면 커넥션 대기로 latency가 튄다. 부하 테스트를 해보면서 조정하는 게 가장 확실하다.
커넥션 리크 (Connection Leak)
커넥션을 빌려갔다가 반납하지 않으면 풀이 고갈된다. 코드에서 예외 발생 시 release()나 커넥션 닫기를 빠트리는 게 주된 원인이다.
HikariCP는 leakDetectionThreshold 설정으로 지정한 시간 이상 반납되지 않은 커넥션을 로그로 경고해 준다.
hikari:
leak-detection-threshold: 5000 # 5초 이상 반납 안 되면 경고
Node.js에서는 try/finally로 conn.release()를 보장하고, Python SQLAlchemy에서는 with engine.connect() as conn: 컨텍스트 매니저를 쓰면 자동으로 반납된다.
PgBouncer — DB 앞단의 외부 커넥션 풀러
앱 레벨 풀과 별도로, DB 서버 앞에 PgBouncer 같은 외부 풀러를 두는 구성도 흔하다. 특히 PostgreSQL은 커넥션당 메모리 소비가 크기 때문에 실제 DB 커넥션 수를 극단적으로 줄이고 싶을 때 유용하다.
[ 앱 서버 × 10 ] → [ PgBouncer ] → [ PostgreSQL ]
각 앱 풀 50개 실제 커넥션 50개
= 총 500 요청 가능 (pool_mode = transaction)
pool_mode에 따라 동작이 다르다.
| 모드 | 커넥션 반환 시점 | 특징 |
|---|---|---|
session | 클라이언트 연결이 끊길 때 | 가장 호환성 높음, 절약 효과 낮음 |
transaction | 트랜잭션이 끝날 때 | 가장 많이 씀, prepared statement 제약 있음 |
statement | 쿼리 하나 끝날 때 | 트랜잭션 사용 불가, 거의 안 씀 |
정리
- 커넥션 풀은 커넥션 생성 비용을 줄이고 처리량을 높이기 위한 필수 컴포넌트다.
- 풀 크기는 크다고 좋은 게 아니라 DB
max_connections와 앱 인스턴스 수를 기준으로 잡아야 한다. - 커넥션 리크를 막으려면 반드시
try/finally또는 컨텍스트 매니저로 반납을 보장해야 한다. - 고트래픽 환경에서 PostgreSQL을 쓴다면 PgBouncer 같은 외부 풀러를 추가 고려할 만하다.