이 문서는 자라는 중인 질문입니다. 아직 증거를 모으고 있으며, 내용이 바뀔 수 있습니다.

한 줄로 말하면

연결 풀러 프로세스 플릿은 단일 스레드 연결 풀러 하나가 한 CPU 코어에서 막힐 때, 같은 역할의 풀러 프로세스를 여러 개 띄우고 클라이언트에는 하나의 접속 지점처럼 보이게 만드는 구조야.1

비유로 이해하기

매표소 창구가 하나뿐이면 건물 안에 직원이 열여섯 명 있어도 줄은 한 창구 앞에서 막혀. 해결은 단순히 더 큰 건물을 빌리는 게 아니라, 같은 표를 처리하는 창구를 여러 개 열고 손님이 아무 창구로 가도 같은 서비스를 받게 만드는 일이야.

연결 풀러도 비슷해. PgBouncer 같은 풀러가 단일 스레드로 동작하면 서버에 코어가 여러 개 있어도 프로세스 하나는 코어 하나만 쓴다. ClickHouse Managed Postgres 사례에서는 16-vCPU 서버에서도 단일 PgBouncer 프로세스가 코어 하나를 거의 다 쓰는 동안, 나머지 코어는 풀러 작업에 충분히 쓰이지 못했다.1

여기까지가 비유야. 실제 시스템에서는 창구를 여러 개 여는 것만으로 끝나지 않아. 클라이언트 연결을 어느 프로세스에 보낼지, 쿼리 취소 요청이 엉뚱한 프로세스에 도착했을 때 어떻게 세션 주인을 찾을지, Postgres에 너무 많은 서버 연결을 열지 않게 연결 예산을 어떻게 나눌지도 함께 풀어야 해.

정확한 정의

연결 풀러 프로세스 플릿은 같은 데이터베이스 앞단에 여러 개의 연결 풀러 프로세스를 두고, 이들을 하나의 접속 계층처럼 운영하는 방식이야. ClickHouse Managed Postgres는 사용 가능한 코어 수에 비례해 PgBouncer 프로세스 수를 정하고, 모든 프로세스가 SO_REUSEPORT로 같은 포트에 바인딩되게 했다. 그러면 커널이 새 연결을 각 프로세스에 나눠 보내고, 클라이언트는 여전히 하나의 엔드포인트에 접속하는 것처럼 본다.1

flowchart TD
    A["클라이언트 연결"] --> B["같은 엔드포인트"]
    B --> C["커널 연결 분산"]
    C --> D["PgBouncer 프로세스 1"]
    C --> E["PgBouncer 프로세스 2"]
    C --> F["PgBouncer 프로세스 N"]
    D --> G["Postgres 연결 풀"]
    E --> G
    F --> G

이 구조의 핵심은 “여러 개처럼 운영하지만 하나처럼 보인다”는 점이야. 풀러를 여러 개 띄웠는데 클라이언트가 포트와 주소를 나눠 알아야 한다면 애플리케이션 쪽 복잡도가 커진다. 반대로 같은 포트를 공유하면 애플리케이션은 기존 접속 문자열을 크게 바꾸지 않고도 뒤쪽 풀러 계층의 병렬화를 얻을 수 있다.1

왜 중요한가

데이터베이스 병목은 항상 데이터베이스 본체에서만 생기지 않아. Postgres가 아직 더 처리할 여력이 있어도, 앞단 연결 풀러가 한 코어에서 꽉 차면 전체 처리량은 거기서 멈춘다. ClickHouse 사례도 이 지점을 보여 줘. 16-vCPU 환경에서 단일 PgBouncer 프로세스는 약 87,000 TPS에서 정점을 찍었고, 16개 프로세스 플릿은 약 336,000 TPS까지 올라 단일 프로세스 대비 약 4배의 처리량을 기록했다.1

이건 단일 스레드 성능 문제와도 이어져. 작업의 한 단계가 단일 스레드로 묶여 있으면 서버 전체의 코어 수가 늘어도 그 단계는 같이 빨라지지 않는다. 연결 풀러 프로세스 플릿은 풀러라는 좁은 단계를 여러 프로세스로 쪼개 코어를 더 쓰게 만드는 장치야.

다만 “무조건 여러 프로세스가 낫다”는 뜻은 아니야. ClickHouse 사례에서 클라이언트 연결이 8개일 때는 단일 프로세스가 8,910 TPS, 플릿이 6,450 TPS였다. 연결이 적으면 병렬화할 작업도 적고, 여러 프로세스에 연결이 흩어지는 비용이 더 크게 보일 수 있다. 플릿이 필요한 순간은 풀러가 처리량을 제한하기 시작하고, 높은 동시성에서 한 코어가 벽이 되는 때야.1

실제 예시

ClickHouse Managed Postgres의 구성은 세 가지를 같이 묶어 쓴다. 첫째, PgBouncer 프로세스를 코어 수에 맞춰 여러 개 띄운다. 둘째, 모든 프로세스가 SO_REUSEPORT로 같은 포트에 붙고 커널이 새 연결을 분산한다. 셋째, PgBouncer 프로세스끼리 peer process로 연결해 쿼리 취소 요청을 실제 세션 소유 프로세스로 넘긴다.1

세 번째가 없으면 이상한 구멍이 생긴다. Postgres의 취소 요청은 실행 중인 쿼리 연결과 별개인 새 연결로 들어온다. 같은 포트를 여러 프로세스가 공유하면 이 새 취소 연결이 원래 쿼리를 가진 프로세스가 아니라 다른 프로세스에 도착할 수 있다. 그 프로세스가 해당 쿼리를 모르면 취소가 실행되지 않는다. 그래서 프로세스 피어링이 필요하다.1

연결 예산도 나눠야 해. ClickHouse 사례에서는 max_client_connmax_db_connections를 프로세스 수로 나눠 각 프로세스에 할당했다. 이렇게 해야 플릿 전체의 클라이언트 연결 상한을 키우면서도 Postgres 쪽 서버 연결 수는 안전한 한도 안에 둘 수 있다.1

헷갈리지 말아야 할 점

  • 데이터베이스를 더 빠르게 만든다는 뜻이 아니야. 이 구조가 푸는 병목은 Postgres 본체가 아니라 앞단 풀러가 코어 하나에 묶이는 문제다.
  • 프로세스를 늘리면 연결 예산도 같이 설계해야 해. 프로세스마다 독립적으로 최대 연결 수를 크게 주면, 플릿 전체가 Postgres에 과도한 연결을 열 수 있다.
  • 취소 요청 같은 예외 경로가 중요해. 정상 쿼리만 보면 포트 공유가 충분해 보일 수 있지만, 별도 연결로 들어오는 취소 요청은 세션 소유자를 다시 찾아야 한다.
  • 낮은 동시성에서는 단일 프로세스가 충분할 수 있어. ClickHouse 사례에서도 연결 수가 적은 구간에서는 단일 프로세스가 더 높은 TPS를 보였다.1
  • 운영 기본값과 보편 정답은 다르다. 모든 ClickHouse Managed Postgres 서버는 이 구성을 기본으로 제공한다고 하지만, PgBouncer보다 Postgres가 먼저 한계에 닿는 환경에서는 단일 프로세스도 적절한 기본값일 수 있다.1

관련 문서

남은 질문들

  • PgBouncer 공식 문서는 SO_REUSEPORT와 peer process를 어떤 조건과 한계로 설명할까?
  • 연결 풀러 플릿에서 프로세스별 max_client_connmax_db_connections를 나누는 안전한 기준은 무엇일까?
  • managed Postgres 환경 밖에서 같은 구조를 직접 운영할 때, 배포·관측·장애 복구는 어떤 복잡도를 추가할까?
  • 연결이 적은 서비스와 높은 동시성 서비스 사이에서 단일 프로세스와 플릿의 전환점을 어떻게 잡을 수 있을까?

각주

  1. GeekNews, 「PgBouncer 처리량을 4배로 확장한 방법」 원문. ↩︎ ↩︎2 ↩︎3 ↩︎4 ↩︎5 ↩︎6 ↩︎7 ↩︎8 ↩︎9 ↩︎10 ↩︎11