Connection pooling — why the DB ran out of connections

DB perf

Understand it step by step (한국어로 이해 → 영어로 말하기)

월요일 아침 배포 직후, 모든 API가 500을 뱉기 시작한다. 로그에는 "sorry, too many clients already" 한 줄. Postgres 기본 연결 한도는 100개인데, 앱 서버 10대가 각자 20개씩 연결을 열려고 했다. 이 사다리는 그 사고를 숫자 그대로 따라간다: 증상 → 원인 → 눈으로 확인 → 당장 고치기 → 규모에서 고치기 → 그 대가.
  1. 1

    증상부터. 배포 직후 API가 전부 500을 뱉는다. 앱 로그에 찍힌 건 'connection refused'가 아니라 Postgres가 직접 보낸 'sorry, too many clients already'다. DB가 죽은 게 아니다 — 연결 자리 100개가 다 차서 새 손님을 거절하는 중이다.

    ✅ Fix: 에러 문구를 그대로 읽는다. 'too many clients'는 DB 장애가 아니라 연결 한도 초과라는 뜻이다. 앱을 재시작하면 잠깐 낫다가 트래픽이 돌아오면 다시 터진다.

    🔧 도구:psqlapp logs

    FATAL:  sorry, too many clients already
    FATAL:  remaining connection slots are reserved for
            non-replication superuser connections
    🗣 영어로 말해

    Our API started failing because Postgres ran out of connection slots, not because it crashed.

    checking microphone…

  2. 2

    왜 이런 일이 생기나. Postgres는 연결 1개마다 OS 프로세스를 하나씩 띄운다. 연결 하나가 메모리 5~10MB를 먹고, 새로 여는 데 TCP + 인증으로 수십 ms가 든다. 그래서 기본 한도가 100으로 낮다. 우리 상황: 서버 10대 × 풀 20개 = 200개 시도. 한도 100(superuser 예약 3개 빼면 97)을 두 배로 넘었다.

    ✅ Fix: 내 풀 설정을 전체 합산으로 계산한다. 공식은 하나다: 서버 수 × 풀 크기 ≤ max_connections − 예약분.

    🔧 도구:psql

    SHOW max_connections;                  -- 100
    SHOW superuser_reserved_connections;   -- 3  → 앱이 쓸 수 있는 건 97개
    -- 서버 10대 × 풀 20 = 200개 시도  >  97개 한도
    🗣 영어로 말해

    Each Postgres connection is a whole OS process, so connections are expensive and limited.

    checking microphone…

  3. 3

    감이 아니라 숫자로 본다. pg_stat_activity는 지금 붙어 있는 연결 전부를 보여준다. 결과: active 5개, idle 92개. 실제로 일하는 연결은 5개뿐인데 97자리 중 92자리를 노는 연결이 차지하고 있다. 가장 오래 논 연결은 41분째다. 이게 '연결은 꽉 찼는데 DB는 한가한' 상태다.

    ✅ Fix: active 대 idle 비율부터 본다. idle이 압도적이면 문제는 쿼리 속도가 아니라 연결 관리다. 여기서 EXPLAIN 들고 쿼리 튜닝부터 시작하면 헛다리다.

    🔧 도구:psqlpg_stat_activity

    SELECT state, count(*) FROM pg_stat_activity GROUP BY state;
    --  state  | count
    --  active |     5
    --  idle   |    92
    
    SELECT max(now() - state_change)
    FROM pg_stat_activity WHERE state = 'idle';
    -- 00:41:07   → 41분째 노는 연결
    🗣 영어로 말해

    I checked pg_stat_activity and most connections were idle, not actually doing work.

    checking microphone…

  4. 4

    지루하지만 맞는 첫 수: 각 앱의 풀 크기를 줄여서 합산이 한도 안에 들어오게 고정한다. 10대 × 8 = 80 < 97. 풀이 크다고 빨라지지 않는다 — DB 코어가 8개면 동시에 일할 수 있는 쿼리도 그 근처다. 풀 20은 자리만 차지하던 셈이다.

    ⚖️ Trade-off: 트래픽이 튀면 요청이 풀 앞에서 줄을 선다. 하지만 줄 서기는 복구되고, DB 연결 고갈은 전체 장애다. 기다림이 더 싼 비용이다.

    ✅ Fix: HikariCP면 maximumPoolSize=8, connectionTimeout=3000. 풀이 꽉 찼을 때 빨리 실패하게 해서 요청이 무한정 매달리지 않게 한다.

    🔧 도구:HikariCP

    # HikariCP (앱 서버 10대 기준)
    maximumPoolSize=8        # 10 × 8 = 80 ≤ 97
    minimumIdle=8
    connectionTimeout=3000   # 풀이 꽉 차면 3초 후 실패 (무한 대기 금지)
    🗣 영어로 말해

    The boring fix is capping each app's pool so the total math fits.

    checking microphone…

  5. 5

    규모가 커지면 앱 쪽 계산이 깨진다. 서버 50대면 풀을 2개씩만 줘도 100개다. 이제 PgBouncer를 DB 앞에 세운다. transaction 모드에서는 트랜잭션이 끝나는 순간 연결을 회수해 다음 손님에게 준다. 클라이언트 2,000개 → 실제 Postgres 연결 25개로 압축된다.

    ⚖️ Trade-off: 홉이 하나 늘어 왕복마다 약간의 지연이 붙고, PgBouncer 자체가 새 단일 장애점이 된다. 보통 2대 이상 띄우고 앞에 로드밸런서를 둔다.

    ✅ Fix: 앱은 PgBouncer(6432 포트)를 DB인 줄 알고 붙는다. 코드는 거의 그대로 두고 연결 문자열만 바꾸면 된다.

    🔧 도구:PgBouncer

    ; pgbouncer.ini
    [pgbouncer]
    pool_mode = transaction
    default_pool_size = 25    ; 실제 Postgres 연결
    max_client_conn = 2000    ; 앱 쪽에서 받는 연결
    🗣 영어로 말해

    At scale, I'd put PgBouncer in front and share twenty-five real connections.

    checking microphone…

  6. 6

    transaction 모드의 대가: '같은 세션이 계속 내 것'이라는 보장이 사라진다. SET으로 바꾼 세션 변수는 다음 트랜잭션에서 남의 연결에 가 있을 수 있고, LISTEN/NOTIFY나 advisory lock처럼 세션에 붙는 기능은 깨진다. 면접에서 이 한 줄이 '진짜 써 본 사람' 신호다.

    ⚖️ Trade-off: 우회 경로를 두면 연결 수 계산에 예외가 생긴다. 직결 연결도 한도에 포함되니 그 몫을 예약분으로 따로 잡아야 한다.

    ✅ Fix: 세션 기능이 꼭 필요한 연결(마이그레이션, LISTEN 용)은 풀러를 우회해 DB에 직결하고, 일반 트래픽만 PgBouncer로 보낸다.

    🔧 도구:PgBouncerpsql

    -- transaction pooling에서 깨지는 것들
    SET work_mem = '256MB';   -- 다음 트랜잭션은 다른 연결일 수 있음
    LISTEN order_events;      -- 세션 고정이 필요 → 동작 안 함
    SELECT pg_advisory_lock(42);  -- 락을 쥔 연결이 남에게 넘어감
    🗣 영어로 말해

    The trade-off is losing session state, so I avoid session-level features in transaction mode.

    checking microphone…

6단계 영어를 다 말하면 → 이 메커니즘 전체를 영어로 설명할 수 있게 된다.