PostgreSQL 연결 풀링: Go 백엔드에서 앱 풀과 PgBouncer를 비교하고 모니터링할 메트릭과 지연 스파이크를 일으키는 잘못된 설정을 설명합니다.

데이터베이스 연결은 앱과 Postgres 사이의 전화선과 같습니다. 연결을 여는 데는 양쪽에서 시간과 작업이 들죠: TCP/TLS 설정, 인증, 메모리, 그리고 Postgres 쪽의 백엔드 프로세스. 연결 풀은 이 "전화선"을 몇 개 열어두어 앱이 매 요청마다 새로 다이얼하지 않고 재사용하도록 합니다.
풀링이 꺼져 있거나 크기가 잘못 설정되면 깔끔한 오류가 먼저 나오지 않습니다. 대신 랜덤한 느려짐이 나타납니다. 보통 20–50 ms 걸리던 요청이 갑자기 500 ms나 5초가 되고 p95가 치솟습니다. 그러다 타임아웃이 나오고 "too many connections" 오류나, 앱 내부에서 빈 연결을 기다리며 큐가 생깁니다.
연결 제한은 작은 앱에서도 중요합니다. 트래픽은 버스트성이기 때문입니다. 마케팅 이메일, cron 작업, 몇몇 느린 엔드포인트가 동시에 데이터베이스를 여러 요청으로 몰아넣을 수 있습니다. 각 요청이 새 연결을 연다면 Postgres는 쿼리 실행 대신 연결 수락과 관리를 위해 많은 용량을 쓰게 됩니다. 반대로 이미 풀이 있어도 너무 크면 활성 백엔드가 많아져 컨텍션 스위칭과 메모리 압박을 유발할 수 있습니다.
초기 증상으로는 다음을 관찰하세요:
풀링은 연결 교체(churn)를 줄여 Postgres가 버스트를 더 잘 처리하게 도와줍니다. 하지만 느린 SQL을 고쳐주지는 않습니다. 쿼리가 전체 테이블 스캔을 하거나 락을 기다린다면 풀링은 시스템이 실패하는 방식을 바꾸는(더 빨리 큐잉하거나, 타임아웃을 늦게 발생시키는 등) 역할만 합니다.
연결 풀링은 동시에 존재하는 데이터베이스 연결 수를 제어하고 재사용 방식을 관리하는 것입니다. 앱 내부에서 할 수도 있고(PostgreSQL 드라이버 레벨), Postgres 앞에 별도 서비스로 두는 방식(PgBouncer)이 있습니다. 둘은 관련 있지만 서로 다른 문제를 해결합니다.
앱 레벨 풀링(Go에서는 보통 내장된 database/sql 풀)은 프로세스 단위로 연결을 관리합니다. 언제 새 연결을 열지, 언제 재사용할지, 유휴 연결을 언제 닫을지 결정하죠. 하지만 여러 앱 인스턴스 간에는 조정하지 못합니다. 예를 들어 레플리카를 10개 돌리면 사실상 풀은 10개가 됩니다.
PgBouncer는 앱과 Postgres 사이에 앉아 여러 클라이언트를 대신해 풀링합니다. 요청이 짧고 인스턴스가 많거나 트래픽이 버스트성인 경우에 특히 유용합니다. 수백 개의 클라이언트 연결이 동시에 들어와도 PgBouncer가 서버 측 연결을 제한해 줍니다.
역할의 단순 분류:
이 둘은 각각의 목적이 분명하면 "이중 풀링(double pooling)" 문제 없이 함께 작동할 수 있습니다: 각 Go 프로세스에 합리적인 database/sql 풀을 두고, PgBouncer로 전역 연결 예산을 강제하는 식입니다.
많은 사람이 "풀을 늘리면 용량이 늘어난다"고 생각하지만 보통 반대입니다. 서비스, 워커, 복제본마다 큰 풀을 두면 총 연결 수가 폭증해 큐잉, 컨텍스트 스위칭, 갑작스러운 지연 스파이크를 일으킵니다.
database/sql 풀링의 실제 동작 방식Go에서 sql.DB는 단일 연결이 아니라 연결 풀 관리자입니다. db.Query나 db.Exec를 호출하면 database/sql은 유휴 연결을 재사용하려 합니다. 재사용할 수 없으면(한도까지) 새 연결을 열거나 요청을 대기시킵니다.
바로 이 대기가 "수수께끼 같은 지연"의 출처인 경우가 많습니다. 풀이 포화되면 요청들은 앱 내부에서 큐에 걸립니다. 외부에서 보면 Postgres가 느려진 것처럼 보이지만 실제로는 빈 연결을 기다리느라 시간이 소비되는 것입니다.
대부분의 튜닝은 네 가지 설정으로 귀결됩니다:
MaxOpenConns: 열린 연결(유휴 + 사용 중)에 대한 하드 캡. 이 한도에 도달하면 호출자는 블록됩니다.MaxIdleConns: 재사용을 위해 대기할 수 있는 유휴 연결 수. 너무 작으면 잦은 재연결이 발생합니다.ConnMaxLifetime: 주기적으로 연결을 재생성하도록 강제합니다. 로드밸런서나 NAT 타임아웃에 유용하지만 너무 짧으면 교체가 잦아집니다.ConnMaxIdleTime: 너무 오래 유휴인 연결을 닫습니다.연결 재사용은 보통 지연과 DB CPU를 낮춰줍니다(매번 TCP/TLS, 인증, 세션 초기화를 피하므로). 하지만 풀을 과도하게 크게 하면 반대로 Postgres가 감당할 수 있는 것보다 많은 동시 쿼리를 허용해 경쟁과 오버헤드를 증가시킬 수 있습니다.
프로세스당이 아니라 전체 총량으로 생각하세요. 인스턴스당 50개의 열린 연결을 허용하고 인스턴스를 20개로 확장하면 사실상 1,000개의 연결을 허용한 것입니다. 이 수를 Postgres 서버가 실제로 원활히 처리할 수 있는 수와 비교하세요.
실무적인 시작점은 MaxOpenConns를 인스턴스당 예상 동시성에 맞추고, 풀 메트릭(사용 중, 유휴, 대기 시간)을 확인해 늘리기 전에 검증하는 것입니다.
PgBouncer는 앱과 PostgreSQL 사이의 작은 프록시입니다. 서비스는 PgBouncer에 연결하고 PgBouncer는 제한된 수의 실제 서버 연결을 Postgres에 유지합니다. 버스트 동안 PgBouncer는 클라이언트 작업을 큐에 넣고 즉시 더 많은 Postgres 백엔드를 만들지 않습니다. 이 큐가 제어된 지연과 데이터베이스 붕괴 사이의 차이가 됩니다.
PgBouncer에는 세 가지 풀링 모드가 있습니다:
세션 풀링은 Postgres에 직접 연결하는 것과 가장 비슷합니다. 가장 이해하기 쉽지만 버스트 로드에서 절약되는 서버 연결 수가 적습니다.
일반적인 Go HTTP API에는 트랜잭션 풀링이 강력한 기본값인 경우가 많습니다. 대부분의 요청이 작은 쿼리나 짧은 트랜잭션을 수행하기 때문에 트랜잭션 풀링으로 많은 클라이언트 연결이 더 적은 Postgres 연결을 공유할 수 있습니다.
단점은 세션 상태입니다. 트랜잭션 모드에서는 동일한 서버 연결에 계속 고정되어 있다고 가정하는 기능들이 깨질 수 있습니다. 예를 들면:
SET, SET ROLE, search_path)앱이 이러한 상태에 의존한다면 세션 풀링이 더 안전합니다. 스테이트먼트 풀링은 가장 제한적이며 웹 앱에는 거의 맞지 않습니다.
유용한 규칙: 각 요청이 하나의 트랜잭션 안에서 필요한 것을 설정할 수 있다면 트랜잭션 풀링이 버스트에서 지연을 안정적으로 유지하는 경향이 있습니다. 장기간의 세션 동작이 필요하면 세션 풀링을 사용하고 앱에서 더 엄격한 제한을 두세요.
database/sql을 쓰는 Go 서비스를 운영하면 이미 앱 측 풀링이 있습니다. 많은 팀에게는 몇 개의 인스턴스, 안정적인 트래픽, 급격하지 않은 쿼리라면 그걸로 충분합니다. 이 경우 가장 간단하고 안전한 선택은 Go 풀을 튜닝하고 데이터베이스 연결 한도를 현실적으로 유지하는 것입니다.
PgBouncer는 데이터베이스가 동시에 너무 많은 클라이언트 연결로 공격받을 때 도움됩니다. 이는 많은 앱 인스턴스(또는 서버리스 스타일 확장), 버스트 트래픽, 짧은 쿼리가 많은 경우에 나타납니다.
또한 잘못된 모드로 쓰면 PgBouncer가 해가 될 수 있습니다. 코드가 세션 상태에 의존한다면(임시 테이블, 재사용하는 prepared statement, 어드바이저리 락, 세션 수준 설정 등) 트랜잭션 풀링은 혼란스러운 실패를 일으킬 수 있습니다. 세션 동작이 진짜 필요하면 세션 풀링을 사용하거나 PgBouncer를 건너뛰고 앱 풀을 신중히 사이즈하세요.
다음 규칙을 따르세요:
연결 제한은 예산입니다. 예산을 한 번에 다 쓰면 새 요청이 모두 기다리고 꼬리 지연이 급격히 상승합니다. 목표는 동시성을 통제된 방식으로 제한하면서 처리량을 안정적으로 유지하는 것입니다.
현재의 피크와 꼬리 지연을 측정하세요. 평균 대신 피크 활성 연결 수, p50/p95/p99 요청·핵심 쿼리 지연을 기록하세요. 연결 오류나 타임아웃도 기록합니다.
앱을 위한 안전한 Postgres 연결 예산을 정하세요. max_connections에서 운영자 접근, 마이그레이션, 백그라운드 잡, 버스트 여유를 빼고 시작하세요. 여러 서비스가 DB를 공유하면 예산을 의도적으로 나누세요.
예산을 인스턴스당 Go 제한으로 매핑하세요. 앱 예산을 인스턴스 수로 나눠 MaxOpenConns를 그 값(또는 약간 낮게)으로 설정하세요. MaxIdleConns는 잦은 재연결을 피할 만큼 충분히 높게, 수명 관련 설정은 연결이 가끔 재생성되지만 폭주하지 않도록 설정하세요.
필요하면 PgBouncer를 추가하고 모드를 선택하세요. 세션 상태가 필요하면 세션 풀링, 가장 큰 서버 연결 절감이 필요하고 앱이 호환된다면 트랜잭션 풀링을 사용하세요.
점진적으로 롤아웃하고 전후를 비교하세요. 한 번에 하나씩 변경하고 카나리로 배포한 뒤 꼬리 지연, 풀 대기 시간, DB CPU를 비교하세요.
예: Postgres가 서비스에 안전하게 200개의 연결을 줄 수 있고 인스턴스를 10개 운영한다면 인스턴스당 MaxOpenConns=15-18로 시작하세요. 그러면 버스트 여유가 남고 모든 인스턴스가 동시에 한도에 도달할 확률이 줄어듭니다.
풀링 문제는 보통 먼저 "too many connections"로 나타나지 않습니다. 대신 대기 시간이 서서히 오르고 갑자기 p95/p99가 튀어 오릅니다.
먼저 앱에서 보고하는 지표를 보세요. database/sql이라면 open connections, in-use, idle, wait count, wait time을 모니터링하세요. 트래픽이 평탄한데 wait count가 늘면 풀이 부족하거나 연결이 너무 오래 점유되고 있다는 신호입니다.
데이터베이스 측에서는 활성 연결 대비 max, CPU, 락 활동을 추적하세요. CPU가 낮은데 지연이 높다면 대기나 락이 원인인 경우가 많습니다.
PgBouncer를 운영하면 클라이언트 연결, Postgres에 대한 서버 연결, 큐 깊이를 추가로 보세요. 서버 연결은 안정적인데 큐가 커지면 예산이 포화되었다는 명확한 신호입니다.
유효한 알림 신호:
풀링 문제는 종종 버스트 상황에서 나타납니다: 요청들이 연결을 기다리느라 쌓이고, 이후에는 다시 괜찮아집니다. 원인은 한 인스턴스에서는 합리적으로 보였던 설정이 여러 복제본을 돌릴 때 위험해지는 경우입니다.
일반적 원인:
MaxOpenConns를 글로벌 예산 없이 설정. 인스턴스당 100 연결을 20개 인스턴스에 두면 2,000개의 잠재적 연결.ConnMaxLifetime / ConnMaxIdleTime이 너무 짧음. 많은 연결이 동시에 재생성되면 재연결 폭주가 발생합니다.스파이크를 줄이는 간단한 방법은 풀링을 앱 로컬 기본값이 아닌 공유된 한도로 취급하는 것입니다: 전체 연결을 캡으로 설정하고 적당한 유휴 풀을 유지하며 동기화된 재연결을 피할 만큼 수명을 충분히 길게 하세요.
트래픽이 급증하면 보통 세 가지 결과가 나옵니다: 요청이 빈 연결을 기다리며 큐에 쌓이거나, 요청이 타임아웃되거나, 모든 것이 너무 느려져 재시도들이 쌓입니다.
큐잉이 가장 교활합니다. 핸들러는 여전히 실행 중이지만 DB 연결을 기다리며 대기 상태에 있습니다. 그 대기 시간이 응답 시간의 일부가 되므로 작은 풀이 50 ms 쿼리를 수초짜리 엔드포인트로 바꿔버릴 수 있습니다.
유용한 정신 모델: 풀이 30개의 사용 가능한 연결을 갖고 있고 갑자기 DB를 필요로 하는 동시 요청이 300개 생기면 270개는 기다려야 합니다. 각 요청이 연결을 100 ms 점유하면 꼬리 지연은 금세 초 단위로 올라갑니다.
명확한 타임아웃 예산을 세우고 지키세요. 앱 타임아웃은 데이터베이스 타임아웃보다 약간 짧게 해 빠르게 실패하고 압박을 줄이세요.
statement_timeout 설정그다음 백프레셔를 추가해 풀을 초과하지 않도록 하세요. 엔드포인트별 동시성 제한, 명확한 오류(예: 429)로 로드 셰딩, 또는 백그라운드 잡을 사용자 트래픽과 분리하는 등의 방법을 한두 가지 선택하세요.
마지막으로 느린 쿼리를 먼저 고치세요. 풀링 압박 하에서 느린 쿼리는 연결을 더 오래 점유해 대기를 늘리고 타임아웃을 증가시키며 재시도를 유발합니다. 그 피드백 루프가 "조금 느림"을 "모든 것이 느림"으로 바꿉니다.
부하 테스트는 연결 예산을 검증하는 수단으로 다루세요. 목표는 스테이징에서의 풀링 동작이 실제 압박 하에서도 동일하게 나타나는지 확인하는 것입니다.
실제 트래픽을 반영한 테스트를 하세요: 동일한 요청 믹스, 버스트 패턴, 운영하는 인스턴스 수를 재현합니다. 단일 엔드포인트 벤치마크는 종종 론치 시에 풀 문제를 숨깁니다.
워밍업 기간을 포함해 콜드 캐시와 램프업 효과를 제외하세요. 풀이 정상 크기에 도달하면 기록을 시작합니다.
전략을 비교할 때는 워크로드를 동일하게 유지하고 다음을 실행하세요:
database/sql, PgBouncer 없음)각 실행 후 재사용 가능한 스코어카드를 기록하세요:
시간이 지나면 이 과정은 추측이 아닌 반복 가능한 용량 계획으로 바뀝니다.
풀 크기를 건드리기 전에 하나의 숫자를 적어두세요: 연결 예산. 이 환경(dev, staging, prod)에서 안전한 최대 활성 Postgres 연결 수입니다(백그라운드 잡과 관리자 접근 포함). 이 숫자를 못 말하면 추측하는 것입니다.
빠른 체크리스트:
MaxOpenConns)가 예산(또는 PgBouncer 캡)에 맞는지 확인하세요.max_connections와 예약 연결 수가 계획과 일치하는지 확인하세요.롤아웃 계획(롤백이 쉬운 방식):
Koder.ai (koder.ai)에서 Go + PostgreSQL 앱을 빌드·호스팅하는 경우 Planning Mode는 변경과 측정 항목을 매핑하는 데 도움이 되고, 스냅샷과 롤백으로 꼬리 지연이 악화될 때 되돌리기 쉬워집니다.
다음 단계: 다음 트래픽 급증 전에 한 가지 측정치를 추가하세요. 앱에서 "연결을 기다리는 데 소비된 시간"은 풀링 압박을 사용자 체감 전에 보여주는 가장 유용한 지표인 경우가 많습니다.
작은 수의 PostgreSQL 연결을 열어두고 요청들 사이에서 재사용하는 방식입니다. TCP/TLS, 인증, 백엔드 프로세스 설정 등 연결을 매번 새로 여는 비용을 줄여 버스트 상황에서 꼬리 지연을 안정화합니다.
풀이 포화되면 요청이 앱 내부에서 빈 연결을 기다리느라 대기하고, 그 대기 시간이 느린 응답으로 나타납니다. 평균은 괜찮아도 트래픽 버스트 때 p95/p99가 급등하는 "랜덤한 느려짐"으로 보이는 경우가 많습니다.
아니요. 풀링은 재연결 오버헤드를 줄이고 동시성을 제어해 시스템의 실패 방식을 바꿀 뿐입니다. 전체 테이블 스캔, 락 대기, 인덱스 문제로 쿼리가 느리다면 풀링은 그 쿼리를 빠르게 만들지 못합니다.
앱 풀링은 프로세스별로 연결을 관리해 각 앱 인스턴스에 풀을 만듭니다. PgBouncer는 앱과 Postgres 사이에 앉아 여러 클라이언트를 대신해 서버 측 연결을 제한해 전체 연결 예산을 강제합니다. 많은 복제본이나 버스트 트래픽에서 특히 유용합니다.
인스턴스 수가 적고 총 열려 있는 연결이 데이터베이스 한도보다 여유가 있다면 Go의 database/sql 풀만 조정해도 충분한 경우가 많습니다. 여러 인스턴스, 오토스케일링, 버스트 트래픽으로 총 연결 수가 넘칠 가능성이 있다면 PgBouncer를 추가하세요.
서비스의 총 연결 예산을 정한 뒤 인스턴스 수로 나누어 인스턴스당 MaxOpenConns를 약간 낮게 설정하는 것이 좋은 출발점입니다. 작게 시작해 대기 시간과 p95/p99, 풀 대기 시간을 보며 필요하면 점진적으로 올리세요.
일반적인 HTTP API에는 트랜잭션 풀링이 강력한 기본값인 경우가 많습니다. 요청 대부분이 짧은 쿼리나 짧은 트랜잭션이라면 트랜잭션 풀링이 클라이언트 연결을 더 적은 서버 연결로 공유하게 해줍니다. 세션 상태가 필요하면 세션 풀링을 사용하세요.
준비된 문(statement), 임시 테이블, 어드바이저리 락, 세션 설정 등은 트랜잭션 모드에서 예기치 않게 동작할 수 있습니다. 같은 서버 연결이 항상 보장되지 않기 때문입니다. 이런 기능이 필요하면 세션 풀링을 사용하세요.
p95/p99가 오르면서 p50은 정상인 경우, 앱 풀 대기 시간(연결 대기)이 증가하는지를 보세요. Postgres 쪽에서는 활성 연결 수 대비 최대 연결, CPU, 락 활동을 보세요. PgBouncer를 쓴다면 클라이언트 연결, 서버 연결, 큐 깊이를 지켜보면 예산 포화 여부를 바로 알 수 있습니다.
먼저 무제한 대기를 멈추게 하세요: 요청 데드라인과 DB 쿼리 타임아웃을 설정해 한 쿼리가 연결을 영구히 점유하지 못하게 합니다. 그다음 DB를 많이 쓰는 엔드포인트의 동시성을 제한하거나 트래픽을 거부(429)하는 등 백프레셔를 추가하세요. 연결 수명이 지나치게 짧아 재연결 폭주가 생기지 않도록도 주의하세요.
풀을 테스트하는 목적은 처리량 뿐 아니라 연결 예산이 압박을 받을 때의 동작을 검증하는 것입니다. 실제 트래픽 패턴, 버스트, 운영하는 인스턴스 수를 시뮬레이션하고 워밍업을 넣어 풀 크기가 정상 상태에 도달한 뒤 측정하세요. 서로 다른 전략을 비교할 때는 작업 부하를 동일하게 유지하세요.