[Daily morning study] 데이터베이스 커넥션 풀링

#daily morning study

Image


데이터베이스 커넥션 풀링

커넥션을 매번 새로 만들면 어떤 문제가 생기나

DB에 쿼리를 날리려면 먼저 TCP 연결을 수립하고, DB 인증을 거치고, 세션 초기화까지 해야 한다. 이 과정이 매 요청마다 반복되면 latency가 눈에 띄게 올라간다. MySQL 기준으로 커넥션 하나 맺는 데 보통 5~10ms 정도 걸리는데, 초당 수백 개의 요청이 들어오는 서비스라면 이것만으로도 병목이 된다.

커넥션 풀은 이 문제를 해결하기 위해 커넥션 여러 개를 미리 만들어 두고, 필요할 때 빌려줬다가 사용이 끝나면 반납받는 방식으로 동작한다.

동작 원리

[ 애플리케이션 ]
       |
       | 커넥션 요청
       ↓
[ 커넥션 풀 ]
  ┌────────────────────────────────┐
  │  conn1 (idle)                  │
  │  conn2 (in-use → 요청에 할당) │
  │  conn3 (idle)                  │
  │  ...                           │
  └────────────────────────────────┘
       |
       | 실제 DB 통신
       ↓
[ Database ]
  1. 앱이 커넥션을 요청하면 풀에서 idle 상태인 커넥션을 꺼내 준다.
  2. 쿼리를 실행하고 나면 커넥션을 닫지 않고 풀에 반납한다.
  3. 풀에 idle 커넥션이 없으면 대기하거나, 풀 크기가 설정값 이하면 새 커넥션을 만든다.
  4. 최대 풀 크기에 도달하면 새 커넥션 생성이 차단되고 타임아웃까지 대기한다.

주요 설정 항목

설정의미
minimumIdle / minPoolSize풀이 유지하는 최소 커넥션 수
maximumPoolSize / maxPoolSize풀이 허용하는 최대 커넥션 수
connectionTimeout커넥션 요청 후 대기할 최대 시간
idleTimeoutidle 커넥션이 풀에서 제거되기까지의 시간
maxLifetime커넥션 하나의 최대 수명 (DB 서버 wait_timeout보다 짧아야 함)
keepaliveTimeidle 커넥션 유효성 검사 주기

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/finallyconn.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 같은 외부 풀러를 추가 고려할 만하다.