TCP 연결(TCP Connection)이란

두 컴퓨터가 데이터를 주고받기 위해 만드는 통신 회선이다.

[Railway 서버]  ←――― TCP 연결 ―――→  [Supabase DB]
   IP: 1.2.3.4                        IP: 5.6.7.8
   Port: 54321                         Port: 5432
  • TCP 연결이 성립되면 양쪽이 서로의 IP와 포트(Port, 프로세스별 통신 창구 번호)를 기억한다
  • 이 회선을 통해 SQL 쿼리를 보내고 결과를 받음
  • 웹 브라우저 접속이든 앱의 DB 접속이든 모두 TCP 연결이 기반

TCP 연결은 만드는 데 비용이 든다

  • 3-way handshake(세 번의 손짓, 연결 수립 절차): SYN → SYN-ACK → ACK 3단계 확인을 거친다
  • DB의 경우 TLS 협상과 인증까지 포함하면 수십 ~ 수백 ms가 걸림
  • 그래서 SQLAlchemy는 한 번 만든 TCP 연결을 **풀(Pool, 여러 개 모아둔 대기 주머니)**에 보관해 두고 재사용한다

일상 비유: 택배 기사와 매번 처음부터 인사하고 주소 확인하는 대신, 자주 오는 기사는 “단골 기사” 목록에 올려놓고 바로 맡기는 것과 같다.


idle 커넥션(유휴 커넥션)

TCP 연결은 살아 있지만 아무 데이터도 오가지 않는 상태다.

시간    상태                  TCP 연결    SQL 쿼리
00:00   검색 실행             살아있음     SELECT ... → 결과 반환
00:01   결과 받음             살아있음     (없음) ← idle 시작
00:02   다른 에이전트 작업 중  살아있음     (없음)
  :
13:00   13분째 쿼리 없음      살아있음     (없음) ← 13분간 idle
        pgBouncer: "이 커넥션 끊겠다"

pgBouncer가 idle 커넥션을 끊는 이유

  • DB 서버의 동시 접속 수에는 한계가 있다 (PostgreSQL 기본 max_connections=100 수준)
  • 놀고 있는 커넥션이 자리를 차지하면 다른 클라이언트가 새로 접속 못 함
  • 일정 시간(보통 10 ~ 15분) 쿼리가 없으면 “이 클라이언트는 더 이상 안 쓰나 보다”로 판단해 회수한다

각 역할 한 줄 정의

이름한 줄 정의층 위치비유
PostgreSQL데이터를 실제 저장하는 DB 서버가장 안쪽은행 금고
pgvectorPostgreSQL에 벡터 유사도 기능을 더한 확장PostgreSQL 내부금고에 설치한 특수 검색 장치
pgBouncerDB 앞에서 커넥션을 관리하는 중개자(Connection Pooler)DB 앞단금고실 앞 접수 창구
SQLAlchemyPython 코드에서 SQL을 만들어 보내는 ORM/엔진애플리케이션 내부요청서를 작성하는 도구
psycopg2SQLAlchemy가 PostgreSQL과 실제 TCP 통신할 때 쓰는 드라이버(Driver)SQLAlchemy 내부요청서를 창구까지 배달하는 배달원

포함 관계

Python 애플리케이션
├─ SQLAlchemy (ORM 엔진 + 커넥션 풀)
│   └─ psycopg2 (DB-API 드라이버, 실제 TCP 통신 담당)
│
└─ TCP 연결 ────→ pgBouncer (커넥션 중개) ────→ PostgreSQL + pgvector
  • SQLAlchemy는 psycopg2를 포함함: SQLAlchemy 자체는 SQL을 만들 뿐이고, 실제 네트워크 송신은 psycopg2가 맡는다
  • pgBouncer는 PostgreSQL 앞에 서 있음: 애플리케이션 입장에서는 pgBouncer가 PostgreSQL처럼 보인다

데이터가 흐르는 순서

[Python 코드]
  "대치동 관련 정책 문서 검색해줘"
     │
     ▼
[SQLAlchemy]  (1단계: ORM → SQL 변환)
  → "SELECT * FROM vectors WHERE similarity(...) > 0.8"
     │
     ▼
[psycopg2]  (2단계: SQL → TCP 패킷 직렬화)
  → TCP 소켓으로 네트워크 전송
     │
     ▼ (TCP 연결)
     │
[pgBouncer]  (3단계: 커넥션 검증/중개)
  → "이 클라이언트 세션이 유효한가?"
  → 유효하면 뒤쪽 PostgreSQL로 전달
     │
     ▼
[PostgreSQL + pgvector]  (4단계: 실제 실행)
  → SQL 실행 → 유사도 높은 문서 반환

각 단계가 “상위 층 → 하위 층”으로 내려가며 책임이 좁아지고, 결과는 반대 방향으로 올라간다.


대표적인 “죽은 커넥션” 에러 시나리오

상황 재연

이름이 에러에서 한 일
SQLAlchemy풀에 커넥션이 있으니 “유효함”으로 판단 → 실제로는 죽은 커넥션을 꺼냄 (원인 제공)
psycopg2TCP keepalive 패킷(연결 유지용 ping)을 30초마다 보냄 → pgBouncer에는 의미가 없었다 (방어 실패)
pgBouncer13분간 SQL 쿼리가 없음 → 세션 종료 (직접 원인)
pgvectorengine_args 없이 엔진 생성 → SQLAlchemy 기본값(pre_ping=False) 적용 (설정 누락)

왜 TCP keepalive로는 부족한가

  • TCP keepalive는 OS 레벨의 “이 소켓 살아 있나?” ping이다
  • pgBouncer는 애플리케이션 레벨에서 “실제 SQL이 13분간 없었다”로 판단함
  • 두 레이어가 달라서 keepalive가 통해도 pgBouncer는 세션을 종료할 수 있다

해결책: pool_pre_ping=True

SQLAlchemy 엔진 생성 시 옵션 한 줄이면 방어 가능함.

from sqlalchemy import create_engine
 
engine = create_engine(
    DATABASE_URL,
    pool_pre_ping=True,     # 커넥션 꺼낼 때마다 SELECT 1로 생존 확인
    pool_recycle=300,       # 300초마다 커넥션 강제 재생성
    pool_size=5,
    max_overflow=10,
)
  • pool_pre_ping=True: 풀에서 커넥션을 꺼내기 직전 가벼운 쿼리로 살아있는지 검사한다. 죽었으면 버리고 새로 생성
  • pool_recycle=300: pgBouncer의 idle 타임아웃(보통 600초)보다 짧게 설정해 만료 전에 재사용을 중단시킴

직접 확인

# 풀 상태 찍어보기
print(engine.pool.status())
# "Pool size: 5  Connections in pool: 3 ..." 형태로 출력된다

한마디 요약

TCP 연결은 만드는 비용이 크니 풀에 보관한다. 그런데 pgBouncer는 idle(쿼리 없음) 커넥션을 정리하기 때문에, SQLAlchemy가 이를 모르고 죽은 커넥션을 꺼내면 에러가 난다. pool_pre_pingpool_recycle로 방어하면 끝.

관련 노트