PostgreSQL 커넥션 관리와 asyncpg 함정
상위: Database MOC
개요
커넥션은 조용히 새는 자원이다. 애플리케이션이 잘 도는 것처럼 보여도 요청마다 새 TCP 연결과 인증을 반복하거나, 여러 인스턴스가 무심코 max_connections를 나눠 먹다가 어느 순간 too many connections로 전면 장애가 난다. Python 비동기 스택에서 SQLAlchemy(asyncpg 드라이버)와 raw asyncpg를 섞어 쓰면 DSN 스킴 불일치라는 함정까지 더해진다. 이 문서는 (1) postgresql+asyncpg:// DSN이 raw 드라이버에서 거부되는 문제, (2) 풀링 없이 매 요청 새 연결을 맺는 안티패턴, (3) max_connections 고갈, (4) DB 타임존을 네 가지로 나눠 정리한다.
원리
DSN 스킴: 드라이버가 다르면 스킴도 다르다
SQLAlchemy는 URL의 스킴으로 사용할 드라이버를 고른다. 그래서 비동기 엔진 URL은 postgresql+asyncpg://user:pw@host:5432/db 형태다. 그런데 이 문자열을 그대로 raw asyncpg.connect()나 psql에 넘기면 거부된다. asyncpg와 libpq는 +asyncpg 접미어를 모르기 때문이다.
asyncpg.exceptions._base.InterfaceError: invalid DSN: scheme is expected to be
either "postgresql" or "postgres", got 'postgresql+asyncpg'즉 같은 연결 문자열을 SQLAlchemy와 raw 드라이버가 공유할 수 없다. 한쪽은 postgresql+asyncpg://, 다른 쪽은 postgresql://가 필요하다.
풀링이 왜 필요한가
PostgreSQL은 연결마다 백엔드 프로세스를 fork하고, TCP 핸드셰이크 + 인증(SCRAM 등)을 거친다. 이 비용은 쿼리 자체보다 클 수 있다. 풀 없이 요청 핸들러 안에서 매번 asyncpg.connect()를 호출하면 한 요청이 여러 번 연결을 새로 맺고, 요청량이 늘수록 연결 생성 오버헤드가 지연으로 그대로 드러난다. 분산 트레이싱을 켜면 연결 수립 구간이 span으로 잡혀 문제가 더 두드러진다.
실전
DSN 정규화
raw 드라이버에 넘기기 전에 스킴을 벗긴다.
def to_raw_dsn(url: str) -> str:
# SQLAlchemy 스타일 -> libpq/asyncpg 스타일
return url.replace("postgresql+asyncpg://", "postgresql://")
raw_dsn = to_raw_dsn(settings.database_url)
conn = await asyncpg.connect(raw_dsn)반대로 SQLAlchemy 엔진에는 +asyncpg가 붙은 원본 URL을 쓴다. 설정에는 하나의 canonical URL을 두고 raw가 필요한 지점에서만 정규화하는 편이 헷갈리지 않는다.
풀 사용
호출마다 connect하지 말고 애플리케이션 수명 주기 동안 풀을 하나 만들어 재사용한다.
# 앱 시작 시 1회
pool = await asyncpg.create_pool(
raw_dsn, min_size=2, max_size=10, timeout=10, command_timeout=30,
)
# 요청마다 획득/반납 (연결은 재사용)
async with pool.acquire() as conn:
rows = await conn.fetch("SELECT id FROM documents WHERE tenant_id = $1", tenant_id)SQLAlchemy async 엔진도 마찬가지로 기본 QueuePool을 쓰되, NullPool은 명시적 이유가 없으면 피한다.
# 안티패턴: NullPool은 매번 새 물리 연결
engine = create_async_engine(url, poolclass=NullPool) # 지양
# 권장: 풀 크기를 예산에 맞춰 지정
engine = create_async_engine(url, pool_size=5, max_overflow=5, pool_pre_ping=True)커넥션 예산 계산
max_connections(기본 100)는 서버 전체가 공유하는 한도다. 인스턴스 수 × 워커 수 × 풀 크기가 이 한도를 넘으면 고갈된다.
필요 연결 = 인스턴스 수 × 프로세스(워커) 수 × 풀 max_size
예: 4 인스턴스 × 4 워커 × 10 = 160 > max_connections(100) → 고갈| 대응 | 방법 |
|---|---|
| 풀 크기 축소 | 인스턴스당 max_size를 예산 내로 |
| 앞단 풀러 도입 | PgBouncer(transaction pooling)로 수천 클라이언트를 수십 백엔드로 다중화 |
| 한도 상향 | max_connections↑ (단, 메모리 사용도 함께↑) |
# pgbouncer.ini (예시)
[pgbouncer]
pool_mode = transaction
max_client_conn = 2000
default_pool_size = 20DB 타임존 고정
세션 타임존이 서버 로케일에 따라 달라지면 now()/타임스탬프 해석이 흔들린다. DB 기본 타임존을 명시한다.
ALTER DATABASE app SET timezone = 'Asia/Seoul';
-- 확인
SHOW timezone;가능하면 컬럼은 timestamptz로 저장하고 애플리케이션 경계에서 UTC로 다루는 편이 안전하다.
죽은 연결과 유휴 트랜잭션 방어
풀에 담긴 물리 연결은 방화벽/로드밸런서의 idle timeout이나 DB 재시작으로 조용히 끊길 수 있다. 다음 획득 시점에 끊긴 연결을 잡으면 첫 쿼리가 실패한다. 이를 막는 두 가지 장치가 있다.
# SQLAlchemy: 획득 직전 가벼운 핑으로 죽은 연결을 걸러낸다
engine = create_async_engine(
url,
pool_size=5, max_overflow=5,
pool_pre_ping=True, # 획득 시 SELECT 1로 생존 확인
pool_recycle=1800, # 30분 지난 연결은 재생성 (idle timeout보다 짧게)
)반대로 애플리케이션이 트랜잭션을 열어 두고 방치하면(idle in transaction) vacuum을 막고(autovacuum와 bloat) 커넥션을 붙잡는다. 서버 측에서 강제로 끊는다.
-- 유휴 트랜잭션을 일정 시간 후 서버가 종료
ALTER DATABASE app SET idle_in_transaction_session_timeout = '60s';이 값은 애플리케이션의 정상 트랜잭션 길이보다 넉넉히 크게 잡아, 실수로 방치된 세션만 정리되게 한다.
함정·트러블슈팅
| 증상 | 원인 | 대응 |
|---|---|---|
invalid DSN ... got 'postgresql+asyncpg' | raw 드라이버에 SQLAlchemy 스킴 전달 | postgresql://로 정규화 |
| 요청마다 지연 튀고 연결 수 폭증 | NullPool + 매 호출 connect | create_pool/QueuePool 재사용 |
FATAL: sorry, too many clients already | 인스턴스×워커×풀 합이 max_connections 초과 | 풀 예산화 / PgBouncer / 한도 상향 |
| 시간이 몇 시간씩 어긋남 | 세션 타임존이 서버 로케일 따라감 | ALTER DATABASE ... SET timezone + timestamptz |
| PgBouncer transaction 모드에서 prepared statement 오류 | 서버 측 prepared stmt와 세션 재사용 충돌 | asyncpg statement_cache_size=0 또는 session pooling |
- PgBouncer transaction pooling + asyncpg 조합에서는 서버 측 prepared statement 캐시가 세션을 넘나들며 깨질 수 있다.
asyncpg.connect(..., statement_cache_size=0)로 캐시를 끄거나 pooling mode를 조정한다. - 연결 누수 탐지 —
SELECT count(*) FROM pg_stat_activity WHERE datname = 'app';로 상시 연결 수를 모니터링한다.idle in transaction이 쌓이면 커밋/롤백을 빼먹은 코드가 있다는 신호다.
정리
- SQLAlchemy의
postgresql+asyncpg://와 rawasyncpg/psql의postgresql://는 서로 다른 스킴이다. raw로 넘기기 전 정규화한다. NullPool+ 매 호출 connect는 매 요청 TCP+인증을 새로 맺는 안티패턴이다.create_pool/QueuePool을 재사용하고, 대규모면 앞단에 PgBouncer를 둔다.max_connections(기본 100)는 인스턴스×워커×풀 크기의 합으로 고갈된다. 연결을 예산화한다.- DB 타임존은
ALTER DATABASE ... SET timezone으로 못 박고timestamptz를 쓴다.
관련: 안전한 스키마 마이그레이션 · autovacuum와 bloat · pgvector - 대규모 임베딩 적재 튜닝