`tokio-postgres`로 커넥션 풀을 직접 짜보면 `deadpool`·`bb8` 내부가 보인다
저도 처음 Rust로 PostgreSQL 연동 코드를 짤 때 deadpool-postgres를 그냥 갖다 쓰면서 "잘 동작하니까 됐지" 하고 넘어갔습니다. 그런데 어느 날 프로덕션에서 연결이 조용히 끊겨 있다가 첫 요청에 에러가 터지는 상황을 만나고 나서야 "이 풀 안에서 정확히 무슨 일이 벌어지고 있지?"라는 의문이 생겼습니다. 이 글의 마지막에서 제가 그 장애를 어떻게 마무리했는지 다시 이야기하겠습니다.
그래서 tokio-postgres만 써서 미니 커넥션 풀을 직접 짜봤습니다. 결론을 먼저 말하면, 커넥션 풀의 핵심은 세 가지 — VecDeque 기반 저장소, Semaphore 기반 동시성 제어, Drop 트레이트를 이용한 자동 반환 — 로 압축됩니다. 이 세 가지를 손으로 구현해보고 나면 deadpool과 bb8이 각자 어떤 설계 선택을 했고, 왜 둘 사이에 특성 차이가 나는지가 명확하게 보입니다.
이 글은 Rust async/await에 이미 익숙하고, Rust로 PostgreSQL을 다루는 백엔드 서비스를 운영하거나 이전을 검토 중인 분을 대상으로 합니다. tokio-postgres API는 알고 있다는 전제로 진행합니다.
왜 커넥션마다 새로 연결하면 안 되는가
tokio-postgres의 Client는 내부적으로 백그라운드 I/O 태스크와 채널로 이어진 핸들입니다. Clone이 가능하긴 하지만, Clone한 클라이언트는 같은 하나의 TCP 연결을 공유할 뿐입니다. 즉 Clone만으로는 병렬 처리량이 늘지 않고, 트랜잭션처럼 세션 상태를 격리해야 하는 작업은 서로 다른 물리 연결이 필요합니다. 그래서 독립된 TCP 연결을 여러 개 준비해두는 풀 개념이 등장합니다.
또 한 가지 이유는 연결 비용입니다. TCP 핸드셰이크 + (TLS를 쓴다면) TLS 협상 + PostgreSQL 인증 프로토콜이 매 요청마다 반복되면 레이턴시가 무시할 수 없습니다.
// tokio-postgres 기본 연결 방식
let (client, connection) = tokio_postgres::connect(
"host=localhost user=postgres dbname=mydb",
NoTls,
).await?;
// connection은 백그라운드 I/O 태스크로 분리해서 구동
tokio::spawn(async move {
if let Err(e) = connection.await {
eprintln!("connection error: {}", e);
}
});connect()가 리턴하는 쌍 — Client(요청 핸들)와 Connection(실제 I/O 루프) — 을 매 요청마다 새로 만들면 위의 협상 과정이 매번 반복됩니다. 이 반복을 피하고 독립 연결을 여러 개 동시에 유지하려는 것이 풀이 필요한 두 축입니다.
손으로 짜보는 미니 커넥션 풀
1단계: 뼈대 구조
가장 단순한 형태부터 시작합니다. Arc<Mutex<VecDeque<Client>>>가 유휴 연결을 보관하는 저장소입니다.
use std::collections::VecDeque;
use std::sync::Arc;
use tokio::sync::{Mutex, OwnedSemaphorePermit, Semaphore};
use tokio_postgres::{Client, Config, NoTls};
pub struct Pool {
inner: Arc<Mutex<VecDeque<Client>>>,
sem: Arc<Semaphore>,
config: Config,
}
impl Pool {
pub fn new(config: Config, max_size: usize) -> Self {
Pool {
inner: Arc::new(Mutex::new(VecDeque::new())),
sem: Arc::new(Semaphore::new(max_size)),
config,
}
}
}여기서 Semaphore가 핵심입니다. VecDeque만 있으면 풀이 비었을 때 새 연결을 계속 만들어 max_size를 초과할 수 있습니다. Semaphore가 슬롯 수를 강제로 통제합니다.
2단계: Guard 패턴으로 자동 반환
get()이 반환하는 PooledConn이 Drop 시점에 연결을 자동으로 풀에 돌려줍니다.
pub struct PooledConn {
client: Option<Client>,
pool: Arc<Mutex<VecDeque<Client>>>,
_permit: OwnedSemaphorePermit, // Drop 시 자동으로 Semaphore 슬롯 반환
}
impl std::ops::Deref for PooledConn {
type Target = Client;
fn deref(&self) -> &Self::Target {
self.client.as_ref().unwrap()
}
}
impl std::ops::DerefMut for PooledConn {
fn deref_mut(&mut self) -> &mut Self::Target {
self.client.as_mut().unwrap()
}
}
impl Drop for PooledConn {
fn drop(&mut self) {
if let Some(client) = self.client.take() {
if !client.is_closed() {
// Drop은 sync 컨텍스트이므로 tokio::spawn으로 우회
let pool = self.pool.clone();
tokio::spawn(async move {
pool.lock().await.push_back(client);
});
}
// 끊어진 연결은 그냥 폐기 — _permit은 자동 반환
}
}
}Drop이 sync 컨텍스트에서 실행된다는 점이 처음에 헷갈립니다. tokio::sync::Mutex를 쓰면 .await가 필요해서 Drop 안에서 직접 락을 잡을 수 없습니다. 위처럼 tokio::spawn으로 우회하거나, std::sync::Mutex를 쓰면 이 문제를 피할 수 있습니다. OwnedSemaphorePermit은 _permit 필드에 담아두면 PooledConn이 사라질 때 자동으로 슬롯을 반환해줍니다.
3단계: get() 구현
impl Pool {
pub async fn get(&self) -> Result<PooledConn, Box<dyn std::error::Error + Send + Sync>> {
// 슬롯이 날 때까지 대기 (max_size 초과 방지)
let permit = Arc::clone(&self.sem).acquire_owned().await?;
let client = {
let mut inner = self.inner.lock().await;
inner.pop_front()
};
// _ 는 두 경우를 함께 처리:
// 1) None: 유휴 연결이 없음
// 2) Some(closed): 유휴 연결은 있지만 이미 끊어져 있음
// 두 경우 모두 신규 연결을 만들어 반환한다.
let client = match client {
Some(c) if !c.is_closed() => c,
_ => self.create_client().await?,
};
Ok(PooledConn {
client: Some(client),
pool: Arc::clone(&self.inner),
_permit: permit,
})
}
async fn create_client(&self) -> Result<Client, Box<dyn std::error::Error + Send + Sync>> {
let (client, connection) = self.config.connect(NoTls).await?;
// 프로덕션에서는 도입부 예시처럼 에러를 로깅하는 것을 권장.
// 여기서는 흐름을 최소화하려 무시했다.
tokio::spawn(async move { let _ = connection.await; });
Ok(client)
}
}이 흐름을 다이어그램으로 보면 더 명확합니다.
이게 deadpool과 bb8이 하는 일의 핵심 뼈대입니다. 두 라이브러리는 이 위에 Manager 추상화, recycling 전략, 모니터링 등을 얹은 것입니다.
deadpool과 bb8의 내부: 어디가 다른가
deadpool의 설계 선택
deadpool managed 모듈은 Pool<Manager>를 Arc로 감싸 공유하고, 내부에 유휴 객체 저장소와 대기 제어 구조를 둡니다. 사용자가 채워야 하는 인터페이스는 Manager trait입니다.
// deadpool Manager trait (개념적 예시 — 실제 시그니처는 버전마다 다름)
pub trait Manager {
type Type;
type Error;
async fn create(&self) -> Result<Self::Type, Self::Error>;
async fn recycle(
&self,
obj: &mut Self::Type,
metrics: &Metrics,
) -> RecycleResult<Self::Error>;
}recycle()의 전략이 deadpool의 가장 큰 차별점입니다. deadpool-postgres 공식 문서의 RecyclingMethod 항목에는 다음과 같은 전략이 정의되어 있습니다.
| 전략 | 동작 | 오버헤드 |
|---|---|---|
Fast |
client.is_closed() 확인만 |
최소 |
Verified |
검증 쿼리 실행 | RTT 1회 |
Clean |
세션 상태 초기화 쿼리까지 실행 | RTT 1회 + α |
Custom |
사용자 정의 쿼리 | 쿼리에 따라 다름 |
기본값이 무엇인지, 훅이 언제 추가됐는지 같은 세부는 시점에 따라 달라지므로, 실제로 코드를 얹기 전에 사용하는 버전의 CHANGELOG와 RecyclingMethod 문서를 한 번 확인하기를 권합니다. 개념적으로 중요한 건 이 세 전략이 "연결 재사용 이득"과 "무결성 확인 비용" 사이 어디에 무게를 둘지를 사용자에게 넘긴다는 점입니다. 검증 쿼리를 매번 날리면 풀의 이점이 상당 부분 희석되지만, 반대로 확인을 생략하면 네트워크 단절 같은 외부 요인으로 조용히 끊긴 연결을 즉시 감지하지 못한다는 트레이드오프가 있습니다.
bb8의 설계 선택
bb8은 ManageConnection trait으로 연결 수명주기를 세 메서드로 나눕니다.
// bb8 ManageConnection trait (개념적 예시)
pub trait ManageConnection: Send + Sync + 'static {
type Connection: Send + 'static;
type Error: std::error::Error + Send + Sync + 'static;
async fn connect(&self) -> Result<Self::Connection, Self::Error>;
async fn is_valid(&self, conn: &mut Self::Connection) -> Result<(), Self::Error>;
fn has_broken(&self, conn: &mut Self::Connection) -> bool;
}이 세 메서드가 deadpool의 recycling 전략과 대응됩니다. connect()가 신규 연결, is_valid()가 무결성 확인, has_broken()이 폐기 여부 판단을 담당합니다. 특히 is_valid()는 bb8-postgres 구현에서 검증 쿼리를 실제로 실행하는데, 이 점이 deadpool의 Fast 전략과의 결정적인 차이입니다. 무결성 보장이 강한 대신, 요청 빈도가 높으면 이 비용이 누적됩니다.
성능 차이에 대해서는 커뮤니티 axum Discussion #2493에서 관련 논의가 있었으니 실제 수치는 원문에서 확인하시길 권합니다. 어느 쪽이 더 빠르다고 단정하기보다는, 워크로드의 요청 빈도와 연결 안정성 요구에 따라 최적점이 다르다고 이해하는 편이 정확합니다.
bb8이 대기 요청을 어떻게 처리하는지에 대한 세부 구현은 버전마다 바뀔 수 있으므로, 관심이 있다면 bb8 소스 코드의 PoolInner 부분을 직접 열어보는 것을 권합니다. 여기서는 deadpool이 Semaphore 기반으로 슬롯 대기를 직렬화한다는 점과 대비된다는 정도만 짚고 넘어가겠습니다.
bb8-postgres에 prepared statement 캐시가 없다는 것의 의미
deadpool-postgres는 ClientWrapper가 문장 캐시를 내장해 prepare_cached()를 제공합니다. bb8-postgres는 이 계층 없이 tokio_postgres::Client를 그대로 노출합니다. 이건 단순한 미구현이라기보다 bb8이 "일반 연결 풀"에 초점을 맞춘 프레임워크라는 설계 방향에서 자연스럽게 나온 결과에 가깝습니다. bb8은 Redis, MongoDB 등 다양한 백엔드용 어댑터가 있는 반면, deadpool-postgres는 Postgres 전용 편의 계층을 두터이 쌓았습니다. 캐시가 필요하면 bb8-postgres 위에 직접 얹거나 deadpool-postgres를 고르라는 것이 사실상 답입니다.
axum과 함께 쓰는 실전 코드
Rust 비동기 백엔드에서 axum + tokio + deadpool-postgres 조합이 흔한 선택지 중 하나로 자리 잡았습니다. axum의 State<AppState> 패턴을 보겠습니다.
use axum::{extract::State, routing::get, Router};
use deadpool_postgres::{Config, Pool, Runtime};
use tokio_postgres::NoTls;
#[derive(Clone)]
struct AppState {
pool: Pool,
}
async fn get_user(State(state): State<AppState>) -> String {
// 함수가 끝나면 자동으로 풀에 반환됨
let client = state.pool.get().await.unwrap();
// prepare_cached로 매번 파싱·플래닝 비용 생략
let stmt = client
.prepare_cached("SELECT name FROM users WHERE id = $1")
.await
.unwrap();
let rows = client.query(&stmt, &[&1i32]).await.unwrap();
rows.first()
.map(|r| r.get::<_, String>(0))
.unwrap_or_default()
}
#[tokio::main]
async fn main() {
let mut cfg = Config::new();
cfg.host = Some("localhost".into());
cfg.dbname = Some("mydb".into());
cfg.user = Some("postgres".into());
let pool = cfg.create_pool(Some(Runtime::Tokio1), NoTls).unwrap();
let app = Router::new()
.route("/user", get(get_user))
.with_state(AppState { pool });
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
axum::serve(listener, app).await.unwrap();
}prepare_cached()는 deadpool-postgres의 ClientWrapper가 제공하는 기능으로, 동일 SQL을 반복 실행할 때 문장 캐시에서 꺼내 파싱·플래닝 비용을 생략합니다. 캐시 무효화가 필요한 상황이라면 사용 중인 버전에서 어떤 인터페이스를 제공하는지 deadpool-postgres 문서의 StatementCache 항목을 직접 확인하시길 권합니다.
트레이드오프와 흔한 실수
어떤 상황에 무엇을 고를까
| 항목 | 직접 구현 | deadpool-postgres | bb8-postgres | sqlx 내장 풀 |
|---|---|---|---|---|
| 학습 비용 | 높음 | 낮음 | 중간 | 낮음 |
| recycling 방식 | 직접 구현 | 전략 선택 가능 | is_valid 기반 검증 | 자체 방식 |
| prepared statement 캐시 | 직접 구현 | 내장 | 없음 (설계 방향상 미제공) | 내장 |
| 컴파일 타임 쿼리 검증 | 없음 | 없음 | 없음 | 지원 |
| tokio-postgres 직접 접근 | 가능 | 가능 | 가능 | 불가 |
| 커스터마이징 | 자유로움 | Manager 훅 확장 | Manager 훅 확장 | 상대적으로 제한 |
| 다른 백엔드 지원 | 직접 구현 | 별도 어댑터 필요 | Redis, MongoDB 등 어댑터 존재 | 불가 |
직접 구현 시 빠지기 쉬운 함정들
Semaphore를 빠뜨리는 경우: VecDeque만 쓰면 풀이 비었을 때 새 연결을 계속 만들어 max_size를 초과합니다. 부하 테스트에서만 드러나는 문제라 놓치기 쉽습니다.
tokio::sync::Mutex vs std::sync::Mutex: .await 포인트를 걸쳐 락을 보유해야 하면 tokio::sync::Mutex를 써야 합니다. 그런데 Drop 안에서는 .await를 못 쓰기 때문에, Drop에서 반환 로직을 구현할 때는 std::sync::Mutex를 쓰거나 tokio::spawn으로 우회해야 합니다. 솔직히 이 부분이 제일 헷갈렸습니다.
Mutex poisoning: std::sync::Mutex 내부에서 panic이 발생하면 Mutex가 poisoned 상태가 됩니다. .unwrap() 대신 .unwrap_or_else(|e| e.into_inner())나 명시적 에러 처리로 대비해두면 좋습니다.
연결 수명 관리: PostgreSQL 서버는 idle_in_transaction_session_timeout 같은 설정으로 장시간 유휴 연결을 끊습니다. max_lifetime 개념 없이 구현하면 조용히 끊어진 연결이 쌓입니다. deadpool-postgres는 이를 설정으로 지원합니다.
타임아웃 미처리: get()이 영원히 대기하면 핸들러가 멈춥니다. tokio::time::timeout으로 대기 한계를 설정해두는 게 프로덕션에서는 필수입니다.
그래서 그 장애는 어떻게 마무리했는가
처음에 이야기한 프로덕션 장애로 돌아오겠습니다. 증상은 트래픽이 뜸한 새벽 시간대에 유휴 연결이 쌓여 있다가, 아침 첫 요청이 그 연결을 뽑아 쓰는 순간 에러가 터지는 패턴이었습니다. 로그를 되짚어보니 방화벽/로드밸런서 쪽에서 유휴 TCP 연결을 조용히 끊고 있었고, 우리 쪽 풀은 is_closed()만으로는 그 상태를 감지하지 못했습니다. is_closed()는 TCP 소켓이 우리 프로세스 관점에서 여전히 열려 있으면 false를 반환하기 때문입니다.
제 경우 해결은 두 축이었습니다.
- recycling 전략을
Fast에서Verified계열로 바꾸는 것: 검증 쿼리 한 번의 RTT 비용을 감수하는 대신, 뽑아 쓰는 순간 죽은 연결을 신규로 교체하도록 만들었습니다. 우리 서비스는 요청 빈도가 극단적으로 높지 않아 이 비용이 감당 가능했습니다. max_lifetime을 방화벽 idle 타임아웃보다 짧게 설정: 애초에 오래 놀린 연결이 풀에 남지 않도록 상한을 걸었습니다.
교훈은 두 가지였습니다. 첫째, 라이브러리의 기본값은 "라이브러리 저자가 생각한 평균 워크로드"에 최적화되어 있지 내 워크로드에 맞춰져 있지 않다. 둘째, 커넥션 풀은 get() 한 줄 뒤에 서버 측 타임아웃, 중간 네트워크 장비, 재사용 정책이 얽혀 있는 시스템이라 어느 한 조각만 튜닝해서는 해결되지 않는다.
이 경험 이후로는 새 서비스에 커넥션 풀을 붙일 때 항상 세 가지를 먼저 확인합니다. recycling 정책의 실제 동작, max_lifetime과 서버 측 idle 타임아웃의 관계, get()에 걸리는 타임아웃. 이미 풀을 쓰고 있다면 pool.status()(deadpool 계열)로 유휴/대기 상태를 한 번 들여다보세요. 예상과 다르면 그게 튜닝의 출발점입니다.
참고 자료
- deadpool-postgres 공식 문서 (docs.rs)
- deadpool 코어 managed 모듈 문서
- deadpool CHANGELOG (GitHub)
- bb8 GitHub 리포지터리
- bb8-postgres 라이브러리 정보 (lib.rs)
- tokio-postgres Client API 문서
- axum Discussion #2493 (GitHub)
- Implement PostgreSQL Pool connection in Rust (craft.ai)
- Rust async pool benchmark (Astro36/rust-pool-benchmark)
- Mastering Tokio Semaphores in Rust (DEV Community)
- Rust 포럼: async connection pool 구현 논의