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 = 20

DB 타임존 고정

세션 타임존이 서버 로케일에 따라 달라지면 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 + 매 호출 connectcreate_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://와 raw asyncpg/psqlpostgresql://서로 다른 스킴이다. raw로 넘기기 전 정규화한다.
  • NullPool + 매 호출 connect는 매 요청 TCP+인증을 새로 맺는 안티패턴이다. create_pool/QueuePool을 재사용하고, 대규모면 앞단에 PgBouncer를 둔다.
  • max_connections(기본 100)는 인스턴스×워커×풀 크기의 합으로 고갈된다. 연결을 예산화한다.
  • DB 타임존은 ALTER DATABASE ... SET timezone으로 못 박고 timestamptz를 쓴다.

관련: 안전한 스키마 마이그레이션 · autovacuum와 bloat · pgvector - 대규모 임베딩 적재 튜닝