Tech_News

PgBouncer를 4배 빠르게: so_reuseport 기반 멀티프로세스 풀러 아키텍처 뜯어보기

TeEm0 2026. 7. 13. 09:33
728x90

왜 지금 이 얘기가 나왔나

PgBouncer 돌려본 사람이면 다 알겠지만, 이 녀석은 PostgreSQL 앞단에 세우는 가장 흔한 커넥션 풀러다. 근데 여기엔 오래된 함정이 하나 있다. PgBouncer는 단일 스레드다. 프로세스 하나가 CPU 코어 하나만 쓴다. 16 vCPU짜리 인스턴스에 올려놔도 실제로는 코어 하나만 죽어라 일하고 나머지 15개는 놀고 있다.

실무에서 이걸 언제 만나느냐. 서비스 초반엔 문제없다. 근데 트래픽이 붙어서 초당 트랜잭션이 수만 건씩 들어오기 시작하면, Postgres는 아직 여유가 있는데 이상하게 처리량이 안 올라가는 구간이 온다. top 찍어보면 PgBouncer 프로세스 하나가 CPU 97~100%에 붙어있고, 인스턴스 전체 사용률은 10%도 안 되는 그림이 나온다. 이게 바로 Postgres가 아니라 풀러가 병목인 상황이다.

ClickHouse가 Managed Postgres를 만들면서 이 문제를 정면으로 풀었고, 그 결과를 공유한 게 이번 글이다. 핵심 숫자만 먼저 보면, 동일한 16 vCPU 인스턴스(c7i.4xlarge)에서 단일 프로세스는 최대 약 8.7만 TPS에서 막히고 오히려 부하가 늘수록 7.7만 TPS로 떨어지는데, 프로세스를 16개로 늘린 fleet 구성은 약 33.6만 TPS까지 올라갔다. 대략 4배다.

동작 원리: 코어 하나짜리 계산대를 여러 개로

비유하자면 이렇다. PgBouncer 단일 프로세스는 손님이 몰리는 대형마트에 계산대를 딱 하나만 열어놓은 거다. 마트 건물(16 vCPU)은 널찍한데 계산원 한 명이 모든 손님을 처리한다. 손님이 적을 땐 문제없지만, 몰리기 시작하면 그 계산대 앞에 줄이 무한정 길어진다.

해결책은 단순하다. 계산대를 코어 수만큼 여러 개 열면 된다. 문제는 "손님이 어느 계산대로 갈지 어떻게 나누느냐"인데, 여기서 리눅스 커널의 SO_REUSEPORT가 등장한다.

SO_REUSEPORT: 같은 포트를 여러 프로세스가 공유

원래는 하나의 포트를 하나의 프로세스만 bind할 수 있다. 근데 SO_REUSEPORT 옵션을 켜면 여러 프로세스가 같은 포트에 동시에 bind할 수 있고, 커널이 들어오는 연결을 프로세스들에게 알아서 분산해준다. 클라이언트 입장에선 여전히 엔드포인트 하나(예: 6432 포트)에 접속하는 거고, 뒤에 PgBouncer가 16개 떠 있다는 걸 전혀 모른다.

이게 PgBouncer 공식 문서가 멀티코어 활용법으로 안내하는 정석 방법이다. PgBouncer 설정 파일에서 다음 항목을 켜면 된다.

[pgbouncer]
listen_addr = 0.0.0.0
listen_port = 6432
so_reuseport = 1

; 트랜잭션 모드로 커넥션 반납을 빠르게
pool_mode = transaction

그리고 동일한 설정으로 프로세스를 코어 수만큼 띄운다. 확인은 이렇게 한다.

$ sudo ss -tlnp | grep 6432
LISTEN 0  128  0.0.0.0:6432  0.0.0.0:*  users:(("pgbouncer",pid=2841,fd=8))
LISTEN 0  128  0.0.0.0:6432  0.0.0.0:*  users:(("pgbouncer",pid=2842,fd=8))
LISTEN 0  128  0.0.0.0:6432  0.0.0.0:*  users:(("pgbouncer",pid=2843,fd=8))
...

이렇게 같은 0.0.0.0:6432를 여러 PID가 물고 있으면 so_reuseport가 제대로 걸린 거다. 만약 두 번째 프로세스 띄울 때 아래 에러가 뜨면 옵션이 안 켜진 거다.

FATAL: cannot bind socket: Address already in use

진짜 골치는 쿼리 취소(cancel)

여기가 이 글에서 제일 실무적인 포인트다. Postgres의 쿼리 취소 요청은 쿼리를 실행 중인 연결이 아니라, cancel key를 들고 완전히 새로운 연결로 들어온다. 그런데 so_reuseport는 이 새 연결을 커널이 아무 프로세스에나 던질 수 있다. 그러면 cancel 요청이 실제로 세션을 들고 있는 프로세스가 아니라 엉뚱한 프로세스로 도착하고, 그 프로세스는 "그런 쿼리 모르는데?" 하고 아무 일도 안 일어난다.

실무에서 이건 치명적이다. 사용자가 애플리케이션에서 무거운 쿼리를 취소했는데 실제 DB에선 계속 돌고 있는 상황이 벌어진다. ClickHouse는 이걸 peering으로 해결했다고 한다. 프로세스들이 서로의 존재를 알고 있어서, cancel이 엉뚱한 프로세스에 도착하면 실제로 세션을 소유한 프로세스로 전달(forward)해준다. fleet 전체에 걸쳐 취소가 정상 동작한다는 얘기다.

다만 이 peering 자체는 ClickHouse Managed Postgres의 구현으로 보이고, 오픈소스 PgBouncer를 직접 so_reuseport로 여러 개 띄우는 경우엔 이 cancel 문제를 별도로 신경 써야 한다. 직접 구성할 거라면 이 지점은 공식 문서 확인이 필요하다.

실무 관점: 도입 시 고려사항과 흔한 함정

1. 커넥션 예산(budget)을 반드시 나눠라

가장 흔하게 사고 나는 지점이다. 프로세스를 16개 띄웠는데 각 프로세스의 max_client_connmax_db_connections를 그대로 두면, fleet 전체로는 예산이 16배가 된다. 그러면 Postgres 쪽 max_connections를 순식간에 초과해서 오버서브스크립션(oversubscription)이 터진다.

원문에서도 명확히 짚는다. max_client_connmax_db_connections프로세스 개수로 나눠서 설정해야 fleet 전체가 Postgres를 안전한 범위 안에서만 쓴다. 예를 들어 전체 목표가 클라이언트 4096 연결, DB 커넥션 400개라면 프로세스당 이렇게 잡는다.

[pgbouncer]
; 프로세스 16개로 나눈 값
max_client_conn = 256      ; 4096 / 16
max_db_connections = 25    ; 400 / 16 (올림)
default_pool_size = 25
pool_mode = transaction
so_reuseport = 1

이 계산을 빼먹으면 부하 테스트 중에 Postgres 쪽에서 이런 에러를 만난다.

FATAL: sorry, too many clients already

2. 단일 프로세스의 커넥션 한계 에러

반대로 예산을 너무 짜게 나누거나, 애초에 단일 프로세스로 버티다가 한계에 부딪히면 PgBouncer가 클라이언트를 이렇게 쳐낸다. 이 메시지는 애플리케이션 로그에서 자주 보게 되니 기억해두면 좋다.

FATAL: no more connections allowed (max_client_conn)

이 에러가 보이면 두 가지 중 하나다. (1) max_client_conn이 너무 낮거나, (2) 커넥션을 제때 반납 안 하는 애플리케이션이 있거나. 무작정 값만 올리기 전에 SHOW POOLS;cl_activecl_waiting부터 확인하는 게 순서다.

3. pool_mode는 transaction이 기본이어야 멀티프로세스가 산다

fleet 구성의 성능이 나오는 전제 조건이 transaction 모드다. 트랜잭션이 커밋되는 순간 서버 커넥션을 풀로 반납하기 때문에 적은 DB 커넥션으로도 많은 클라이언트를 돌릴 수 있다. session 모드로 두면 클라이언트가 연결을 물고 있는 내내 서버 커넥션을 점유해서, 코어를 늘려도 커넥션이 금방 고갈된다.

단, transaction 모드에선 세션 레벨 기능(SET 세션 변수, prepared statement, advisory lock 등)이 깨질 수 있다. 애플리케이션이나 ORM이 세션 상태에 의존하는지 먼저 점검해야 한다. 이건 코어 늘리기와 별개로 transaction 모드로 넘어갈 때 항상 걸리는 함정이다.

4. 언제부터가 fleet 도입 타이밍인가

원문 벤치마크 표를 보면 판단 기준이 명확하게 나온다.

Clients  Single TPS   Single CPU   Fleet TPS   Fleet CPU
   8        8,910        0.8%        6,450       2.9%
  32       54,203        5.2%       64,244      12.3%
  64       86,570        8.3%      219,439      31.9%
 128       83,463        8.1%      320,547      45.9%
 256       76,893        7.7%      336,469      48.9%

주목할 점: 클라이언트 8개일 때는 오히려 단일 프로세스가 더 빠르다. 병렬화할 게 없는데 fleet은 연결이 여러 프로세스로 얇게 흩어지니 손해다. 격차가 벌어지는 건 클라이언트 64개부터, 즉 진짜 동시성이 붙어서 코어 하나가 벽이 되는 지점이다.

그러니까 트래픽 적은 서비스에 무작정 16개 띄우는 건 오버엔지니어링이다. pidstat로 PgBouncer 프로세스가 코어 하나에 붙어서 안 떨어지는 게 확인될 때 도입을 검토하는 게 맞다.

$ pidstat -p $(pgrep -f pgbouncer | head -1) 1
Linux 6.x  (pooler-01)   x86_64  (16 CPU)

  UID   PID    %usr  %system  %CPU   CPU  Command
 1001  2841   72.00   25.00   97.00    3  pgbouncer

이렇게 %CPU가 97~100%에 계속 붙어있고 인스턴스 전체 사용률은 10% 아래라면, 코어 한 개짜리 벽에 부딪힌 전형적인 신호다.

대안

PgBouncer 멀티프로세스 구성 말고도 선택지는 있다. PgCat(Rust 기반 멀티스레드 풀러)이나 Odyssey(Yandex, 멀티스레드) 같은 애들은 애초에 멀티스레드로 설계돼서 프로세스를 여러 개 띄우는 번거로움 없이 코어를 다 쓴다. cancel forwarding이나 예산 분배 같은 걸 직접 관리하기 싫다면 이쪽도 검토할 만하다. 다만 PgBouncer만큼 검증되고 자료가 많은 건 아니라, 운영 안정성과 커뮤니티 지원을 저울질해야 한다.

정리

한 줄 요약: PgBouncer는 단일 스레드라 코어 하나가 병목이 되며, so_reuseport로 코어 수만큼 프로세스를 띄우고 커넥션 예산을 나누면 처리량을 최대 4배까지 끌어올릴 수 있다.

  • 이걸 써야 하는 사람: 16 vCPU 이상 인스턴스에 PgBouncer 올렸는데, Postgres는 여유 있는데 풀러 프로세스가 코어 하나에 붙어 처리량이 막힌 팀.
  • 아직 안 써도 되는 사람: 동시 연결이 수십 개 수준인 소규모 서비스. 오히려 단일 프로세스가 간단하고 미세하게 빠르다.
  • 도입 전 체크리스트: (1) so_reuseport = 1 켜고 ss로 확인 → (2) max_client_conn·max_db_connections를 프로세스 수로 나눴는가 → (3) transaction 모드 전환 시 세션 상태 의존성 점검 → (4) 쿼리 cancel이 fleet 전체에서 동작하는지 검증(직접 구성 시 특히).

참고 자료

728x90