---
title: "클릭하우스가 PgBouncer 처리량을 4배로 끌어올린 방법"
published: 2026-07-11T15:28:02.000Z
canonical: https://jeff.news/article/4997
---
# 클릭하우스가 PgBouncer 처리량을 4배로 끌어올린 방법

ClickHouse Managed Postgres 팀은 단일 스레드인 PgBouncer를 여러 프로세스로 띄우고 `so_reuseport`와 피어링(peering)을 조합해 16 vCPU 머신을 제대로 쓰게 만들었다. 동일한 AWS 환경에서 단일 프로세스는 약 8.7만 TPS 근처에서 막혔지만, 16개 프로세스 구성은 약 33.6만 TPS까지 올라갔다.

- PgBouncer의 기본 함정은 단순함. 단일 프로세스가 CPU 코어 하나만 쓴다는 것임
  - 16 vCPU 머신에 올려도 PgBouncer 프로세스 하나는 코어 1개만 바쁘게 씀
  - 나머지 15개 코어가 놀고 있는데도, Postgres가 아니라 풀러가 먼저 처리량 상한을 만들어버릴 수 있음

- ClickHouse Managed Postgres는 PgBouncer를 코어 수에 맞춰 여러 프로세스로 띄우는 방식을 썼음
  - 각 PgBouncer 프로세스가 같은 포트에 바인딩함
  - 이때 `so_reuseport`를 켜서 커널이 새 클라이언트 연결을 여러 프로세스에 분산하게 만듦
  - 클라이언트는 여전히 하나의 엔드포인트로 접속하니 구성 복잡도는 밖으로 새지 않음

- 그런데 `so_reuseport`만 켜면 Postgres 쿼리 취소가 깨질 수 있음
  - Postgres의 cancel request는 실행 중인 쿼리 연결을 타고 오는 게 아니라, cancel key를 들고 새 연결로 들어옴
  - 커널은 이 새 연결을 아무 PgBouncer 프로세스에나 보낼 수 있음
  - 그래서 실제 쿼리 세션을 가진 프로세스가 아닌 곳에 취소 요청이 도착하면, 그 프로세스는 “이 쿼리 모르는데?” 상태가 됨

```mermaid
sequenceDiagram
    participant 클라이언트
    participant 커널
    participant 풀러A
    participant 풀러B
    participant 포스트그레스
    클라이언트->>커널: 쿼리 실행 연결
    커널->>풀러A: 연결 분산
    풀러A->>포스트그레스: 쿼리 전달
    클라이언트->>커널: 취소 요청 새 연결
    커널->>풀러B: 다른 프로세스로 전달
    풀러B->>풀러A: 세션 소유자에게 취소 전달
    풀러A->>포스트그레스: 쿼리 취소
```

- 해결책은 PgBouncer 프로세스끼리 서로를 알게 하는 peering임
  - 취소 요청이 엉뚱한 프로세스에 도착해도, 그 프로세스가 실제 세션을 가진 프로세스로 전달함
  - 덕분에 여러 프로세스가 같은 포트를 나눠 쓰면서도 쿼리 취소 기능이 전체 플릿에서 정상 동작함

> [!IMPORTANT]
> 이 구성의 핵심은 “프로세스를 여러 개 띄운다”에서 끝나지 않음. Postgres cancel request처럼 새 연결로 들어오는 프로토콜 동작까지 처리해야 실제 운영에서 안 깨짐.

- 풀링 모드는 transaction mode를 사용함
  - 트랜잭션이 커밋되는 순간 서버 연결을 다시 풀에 돌려줌
  - `max_client_conn`과 `max_db_connections`는 프로세스 수로 나눠서, 전체 플릿이 Postgres 연결 예산을 넘지 않게 함
  - 여러 프로세스를 띄웠다고 Postgres까지 과도하게 물고 늘어지면 그건 또 다른 장애 포인트가 되기 때문임

- 벤치마크 환경은 꽤 깔끔하게 통제됐음
  - 풀러 머신은 AWS EC2 `c7i.4xlarge` 16 vCPU
  - Postgres는 별도 머신, 부하 생성기도 별도 머신
  - 워크로드는 `pgbench`의 select-only, transaction-pooled 모드
  - 비교 변수는 PgBouncer 1개 프로세스냐, 16개 프로세스 플릿이냐 하나였음

- 결과는 숫자로 바로 갈림. 단일 프로세스는 약 87k TPS 근처에서 막힘
  - 클라이언트 연결을 8개에서 256개까지 늘렸을 때, 단일 PgBouncer는 약 87k transactions/sec에서 정점 찍고 오히려 내려감
  - 256 클라이언트에서는 약 77k TPS까지 밀림
  - 이유는 단순함. 하나의 코어에 모든 경합이 몰리기 때문임

- 16개 프로세스 플릿은 약 336k TPS까지 올라감
  - 대략 4배 처리량임
  - PgBouncer가 더 많은 코어를 쓰기 시작했고, 풀러가 아니라 Postgres와 부하 생성기가 한계가 되는 지점까지 올라감
  - 단일 프로세스가 16 vCPU 머신을 사실상 1코어 장비처럼 쓰던 걸 바로잡은 셈임

- CPU 사용률 지표도 같은 얘기를 함
  - 단일 프로세스는 PgBouncer 프로세스 하나가 약 97% CPU, 즉 코어 하나를 꽉 채움
  - 그런데 16 vCPU 머신 전체 사용률은 10% 미만에 머뭄
  - 플릿 구성은 약 8코어까지 바쁘게 쓰고, 256 클라이언트 고정 테스트에서 전체 CPU가 약 52%까지 올라감

- CloudWatch에서 봐도 차이는 그대로였음
  - 단일 프로세스 인스턴스는 평균 약 16% CPUUtilization
  - 플릿 인스턴스는 평균 약 60% CPUUtilization
  - 같은 돈 내고 16 vCPU를 샀는데, 단일 PgBouncer는 대부분을 바닥에 버리고 있었던 것임

> [!TIP]
> PgBouncer 앞단 병목이 의심되면 Postgres 튜닝부터 들어가기 전에 풀러 프로세스 CPU 사용률을 먼저 봐야 함. 전체 머신 CPU가 낮아도 단일 프로세스가 코어 하나를 꽉 채우고 있을 수 있음.

- 연결 제한도 플릿 기준으로 다시 생각해야 함
  - 단일 프로세스는 `max_client_conn`을 혼자 강제하므로 한계에 닿으면 새 클라이언트를 거절함
  - 플릿에서는 전체 연결 예산을 프로세스별로 나눠야 aggregate ceiling을 올리면서도 Postgres를 안전하게 보호할 수 있음

- 결론은 PgBouncer가 기본값으로는 충분하지만, 고동시성에서는 직접 병목이 될 수 있다는 것임
  - 연결 수가 적을 때는 단일 프로세스가 오히려 살짝 빠를 수도 있음
  - 하지만 실서비스처럼 동시성이 올라가는 순간, 코어 하나짜리 벽이 먼저 등장함
  - ClickHouse Managed Postgres는 이 구성을 기본으로 제공한다고 함

---

## 기술 맥락

- 여기서 선택한 구조는 PgBouncer를 스레드화하는 게 아니라, 단일 스레드 프로세스를 여러 개 띄우는 방식이에요. PgBouncer 자체가 프로세스당 한 코어만 쓰기 때문에, 16 vCPU 머신을 제대로 쓰려면 프로세스 수를 늘리는 쪽이 현실적인 선택이거든요.

- `so_reuseport`가 중요한 이유는 클라이언트 엔드포인트를 하나로 유지하면서도 내부 병렬성을 얻을 수 있기 때문이에요. 애플리케이션 설정을 여러 포트로 쪼개지 않아도 커널이 새 연결을 PgBouncer 프로세스들에 분산해줘요.

- 다만 Postgres의 cancel request는 평범한 요청이 아니에요. 실행 중인 연결이 아니라 새 연결로 들어오고 cancel key만 들고 오기 때문에, `so_reuseport` 환경에서는 원래 세션을 가진 프로세스와 다른 곳에 도착할 수 있어요. 그래서 peering이 없으면 취소가 조용히 실패할 수 있어요.

- 연결 예산을 프로세스 수로 나누는 것도 운영상 핵심이에요. PgBouncer 프로세스를 16개 띄웠다고 각자 같은 `max_db_connections`를 가지면 Postgres 입장에서는 전체 연결 수가 16배로 터질 수 있거든요. 처리량을 올리면서도 데이터베이스를 보호하려면 총량 기준으로 계산해야 해요.

- 벤치마크가 설득력 있는 이유는 병목 위치가 숫자로 보이기 때문이에요. 단일 프로세스는 프로세스 CPU가 약 97%인데 머신 전체는 10% 미만이고, 플릿은 약 336k TPS까지 올라가요. 이건 Postgres 튜닝 문제가 아니라 풀러가 코어를 못 쓰던 문제였다는 걸 꽤 직접적으로 보여줘요.

## 핵심 포인트

- PgBouncer는 프로세스당 CPU 코어 1개만 쓰기 때문에 큰 인스턴스에서도 병목이 될 수 있음
- `so_reuseport`로 여러 PgBouncer 프로세스가 같은 포트를 공유하고 커널이 연결을 분산함
- Postgres 쿼리 취소 요청은 새 연결로 들어오기 때문에, 잘못된 프로세스에 도착하면 peering으로 실제 세션 소유자에게 전달해야 함
- 16 vCPU c7i.4xlarge 실험에서 단일 프로세스는 약 87k TPS, 16개 프로세스 플릿은 약 336k TPS를 기록함

## 인사이트

Postgres 성능 문제라고 생각했는데 실제 병목이 PgBouncer 한 프로세스였다는 케이스라 실무 감각이 좋다. 커넥션 풀러는 ‘그냥 앞단에 두는 것’이 아니라, CPU 코어 수와 취소 요청 같은 프로토콜 동작까지 같이 봐야 함.
