PostgreSQL max_connections를 크게 올리기 전에 연결 상태부터 보게 된 이유

FastAPI 서비스를 운영하면서 PostgreSQL의 too many clients already 오류를 처음 봤을 때는 해결 방향이 꽤 명확해 보였다.

오류 메시지를 그대로 읽으면 PostgreSQL이 받아들일 수 있는 Connection 수를 모두 사용했다는 뜻으로 이해할 수 있었고, 그렇다면 허용 가능한 Connection 수를 늘리면 되는 것처럼 보였다.

당시에는 PostgreSQL의 max_connections를 상당히 크게 잡는 방법을 찾아보면서 3,000 정도까지 올리는 방안도 생각해봤다.

서버 메모리가 충분하고 PostgreSQL에서 연결을 더 많이 허용하면 적어도 too many clients already 때문에 새로운 요청이 실패하는 문제는 사라지지 않을까 하는 생각이었다.

처음에는 꽤 합리적인 해결 방법처럼 느껴졌다.

그런데 값을 실제로 크게 올리는 쪽으로 생각하다 보니 오히려 한 가지 질문이 생겼다.

지금 PostgreSQL이 연결을 적게 허용하고 있는 것이 정말 문제라면, 내 애플리케이션은 왜 그렇게 많은 Connection을 필요로 하고 있는 걸까?

이 질문에 답하지 못하고 있었다.

그때 FastAPI에서는 이미 asyncpg Connection Pool을 사용하고 있었고, 내가 사용하던 설정은 대략 다음과 같은 형태였다.

pool = await asyncpg.create_pool(
    dsn=DATABASE_URL,
    min_size=1,
    max_size=50
)

애플리케이션이 Connection을 무제한으로 생성하지 않도록 최대 크기까지 정해놓은 상태였다.

그렇다면 단순히 PostgreSQL의 상한만 올리기 전에 내가 생각하고 있는 Connection 구조와 실제 PostgreSQL 상태가 같은지부터 확인하는 것이 순서가 아닐까라는 생각이 들었다.

이 지점부터 max_connections를 바라보는 방식이 조금 달라졌다.


처음에는 “최대 몇 개까지 받을 수 있느냐”만 보고 있었다

PostgreSQL의 현재 설정값은 간단하게 확인할 수 있다.

SHOW max_connections;

PostgreSQL 공식 문서에서 max_connections는 서버가 동시에 허용할 수 있는 최대 Connection 수를 결정하는 설정이다. 그런데 문서를 조금 더 읽어보면 PostgreSQL은 이 값을 기준으로 일부 자원의 크기를 결정하고, 값을 높이면 shared memory를 포함한 관련 자원 할당도 증가할 수 있다고 설명하고 있다.

처음에는 앞부분만 보고 있었다.

Connection이 부족하다
→ max_connections가 작다
→ 값을 높인다

그런데 뒤의 이야기를 같이 생각하면 조금 달라진다.

max_connections는 단순히 출입문 앞에 붙어 있는 숫자표가 아니다.

값을 크게 만든다는 것은 PostgreSQL이 더 많은 동시 Connection을 받아들일 수 있는 구조로 운영하겠다는 결정에 가깝다.

그렇다면 100이 부족해서 300으로 늘리고, 300이 부족하면 1,000으로 늘리고, 또 부족하면 3,000으로 늘리는 방식이 정말 올바른 해결인지 먼저 확인해야 했다.

왜냐하면 애플리케이션 구조에서 Connection이 비정상적으로 계속 증가하고 있다면 상한을 올리는 것은 그 증가를 멈추는 것이 아니라 문제가 다시 나타나는 지점을 뒤로 옮기는 것일 수도 있기 때문이다.

예를 들어 물이 계속 새고 있는데 양동이 크기만 계속 키우는 것과 비슷하다.

양동이가 커졌기 때문에 넘치는 시점은 늦어진다.

하지만 물이 새는 원인은 그대로다.

나는 max_connections를 크게 올리는 문제도 이런 상황일 수 있다고 생각하기 시작했다.


그래서 총 Connection 수를 먼저 보기 시작했다

그다음 생각은 자연스럽게 이어졌다.

PostgreSQL이 최대 몇 개를 허용하는지는 알았다.

그렇다면 지금 실제로 몇 개가 연결되어 있는지를 봐야 했다.

PostgreSQL에서는 pg_stat_activity를 통해 서버 프로세스별 현재 활동을 확인할 수 있다. 현재 공식 문서에서도 이 View는 서버 프로세스마다 한 행을 제공하고 현재 상태와 Query 등의 정보를 보여준다고 설명한다.

가장 단순하게는 현재 Connection 수부터 확인할 수 있다.

SELECT COUNT(*)
FROM pg_stat_activity;

그런데 여기서 숫자 하나만 확인하고 끝내면 다시 처음과 비슷한 문제가 생긴다.

예를 들어 결과가 90이라고 하자.

max_connections가 100이라면:

거의 다 썼네. 역시 100이 부족했구나.

라고 생각하기 쉽다.

하지만 90개의 Connection이 왜 존재하고 있는지는 아직 모른다.

90개가 모두 실제 사용자 요청을 처리하고 있을 수도 있다.

Connection Pool이 다음 요청을 위해 유지하고 있는 연결이 많이 포함되어 있을 수도 있다.

오랫동안 끝나지 않는 Query 때문에 Connection이 반환되지 않고 있을 수도 있고, Transaction이 정상적으로 종료되지 않은 상태가 있을 수도 있다.

다른 애플리케이션이 같은 PostgreSQL에 연결하고 있을 수도 있다.

COUNT(*) = 90은 현상을 보여주기는 하지만 원인을 설명하지는 못한다.

이 부분 때문에 다음에는 Connection의 상태를 같이 보게 됐다.


activeidle을 보고 나서야 숫자의 의미가 조금 달라졌다

다음처럼 상태별로 나누면 현재 Connection이 어떤 상태인지 조금 더 구체적으로 볼 수 있다.

SELECT
    state,
    COUNT(*)
FROM pg_stat_activity
GROUP BY state;

pg_stat_activitystate에는 active, idle, idle in transaction 등 현재 backend의 상태가 표시된다. PostgreSQL 공식 문서에서도 active는 Query를 실행하고 있는 상태, idle은 새로운 client command를 기다리는 상태, idle in transaction은 Transaction 안에 있지만 현재 Query를 실행하지 않는 상태로 구분한다.

처음 이 값을 보면 idle이 많다는 사실 자체가 문제처럼 보일 수 있다.

사용하지 않는 Connection이 왜 이렇게 많이 남아 있느냐는 생각이 들기 때문이다.

하지만 내가 asyncpg Connection Pool을 사용하고 있다는 사실을 다시 떠올리면 그렇게 단순하게 볼 수 없었다.

Connection Pool은 사용이 끝난 Connection을 매번 PostgreSQL에서 완전히 끊어버리는 대신 다음 요청에서 재사용할 수 있도록 유지한다.

따라서 Pool 안에서 당장 사용하지 않는 Connection이 idle로 존재하는 것은 자연스러운 상황일 수도 있다.

여기에서 질문을 다시 바꿔야 했다.

idle Connection이 있는가?

가 아니라,

현재 보이는 idle Connection의 개수가 내가 설정한 Pool 구조로 설명 가능한가?

가 더 중요한 질문이었다.

예를 들어 Pool이 하나이고 최대값이 50이라고 생각했는데 동일한 애플리케이션에서 만들어진 것으로 보이는 Connection이 그보다 훨씬 많다면 그때부터 이야기가 달라진다.

Pool 자체가 여러 번 만들어지고 있는지, FastAPI worker마다 별도의 Pool이 생성되는지, 동일한 컨테이너가 여러 개 실행 중인지 확인할 이유가 생긴다.

이렇게 보니 pg_stat_activity의 숫자는 단순히 많고 적음을 판단하기 위한 값이 아니라 내가 알고 있는 애플리케이션 구조가 실제 DB에서도 그렇게 보이는지를 검증하는 자료가 됐다.


idle in transaction은 같은 idle이라고 보기 어려웠다

상태를 보다 보면 idle in transaction이라는 값도 만날 수 있다.

처음에는 이름에 idle이 들어 있으니 일반적인 idle Connection과 비슷하게 생각하기 쉽다.

하지만 의미를 보면 다르다.

Transaction이 시작된 상태인데 현재 Query는 실행하지 않고 client의 다음 명령을 기다리고 있다는 의미다. PostgreSQL 문서에서도 idle in transaction을 Transaction 안에 있으면서 현재 Query를 실행하지 않는 상태로 구분한다.

왜 이 차이가 중요할까 생각해봤다.

일반적인 idle Connection은 Connection Pool에서 다음 사용을 기다리고 있을 수 있다.

반면 Transaction이 열린 채로 오래 남아 있다면 단순히 Connection 하나를 유지하고 있다는 것에서 끝나지 않을 수 있다.

Transaction이 언제 시작됐고 얼마나 오래 유지되고 있는지를 확인할 필요가 생긴다.

그래서 상태만 보는 것보다 조금 더 정보를 붙여서 확인하는 편이 의미가 있었다.

SELECT
    pid,
    usename,
    application_name,
    client_addr,
    state,
    backend_start,
    xact_start,
    query_start,
    state_change,
    query
FROM pg_stat_activity
ORDER BY backend_start;

이 쿼리를 보면 질문이 더 구체적으로 바뀐다.

이 Connection은 언제 만들어졌는가.

Transaction은 언제 시작됐는가.

마지막 Query는 언제 시작됐는가.

어떤 애플리케이션에서 연결했는가.

어느 client 주소에서 들어왔는가.

현재 어떤 Query와 연결되어 있는가.

단순히 90개 연결되어 있다에서 끝났을 때보다 조사할 수 있는 방향이 훨씬 많아진다.


그러고 나니 “연결이 많다”와 “연결 누수다”도 구분해야 했다

Connection 숫자가 계속 많아 보이면 곧바로 누수를 의심하기 쉽다.

나도 처음에는 연결 수가 예상보다 많으면:

Connection이 반환되지 않고 있는 것 아닐까?

라는 생각부터 했다.

충분히 확인할 가치가 있는 가설이다.

하지만 그것 역시 바로 결론을 내릴 수 있는 문제는 아니었다.

Connection Pool을 사용하면 일정한 수의 Connection을 의도적으로 유지할 수 있다.

FastAPI worker가 여러 개라면 프로세스마다 별도의 Pool이 만들어질 수 있다.

같은 PostgreSQL을 여러 서비스가 사용한다면 다른 서비스 Connection도 pg_stat_activity에 같이 보인다.

따라서 단순히:

Connection 수가 많음
=
Connection leak

라고 연결할 수 없다.

누수라고 판단하려면 적어도 내가 예상하는 정상적인 Connection 수와 실제 Connection 수 사이에 설명할 수 없는 차이가 있는지를 봐야 했다.

이 지점에서 이전 글에서 생각했던 Pool의 크기 문제와 다시 연결됐다.

코드에는:

max_size=50

이라고 적혀 있어도 프로세스가 여러 개라면 PostgreSQL 서버 전체에서 보게 되는 Connection 수는 50보다 클 수 있다.

결국 PostgreSQL 문제라고 생각했던 것이 실제로는 배포 구조까지 포함해서 봐야 하는 문제가 된다.


여기서 application_name 같은 값도 왜 필요한지 이해하게 됐다

정상적으로 동작할 때는 DB Connection이 어느 프로그램에서 왔는지를 자세히 구분할 필요가 없어 보인다.

연결만 잘 되고 Query만 정상적으로 실행되면 충분하기 때문이다.

그런데 여러 서비스가 같은 PostgreSQL을 사용하기 시작하면 상황이 달라진다.

예를 들어 검색 API와 추천 API, 관리용 프로그램, 배치 프로그램이 모두 같은 DB를 이용한다고 생각해보자.

Connection이 많아졌을 때:

누가 많이 사용하고 있는가?

를 알아야 한다.

그런데 모든 Connection이 비슷하게 보이면 조사하기가 어렵다.

pg_stat_activity에는 application_name, usename, client_addr 같은 정보가 있기 때문에 서비스별 Connection을 구분할 수 있도록 애플리케이션을 구성해두면 장애 상황에서 도움이 된다. 해당 컬럼들은 현재 pg_stat_activity 공식 정의에도 포함되어 있다.

평소에는 이런 메타데이터가 기능 구현과 직접 관계없는 정보처럼 보였다.

하지만 문제가 생긴 뒤에는 오히려 이런 값이 원인을 좁히는 데 중요하다는 것을 알게 됐다.

운영 로그와 모니터링 정보는 문제가 없을 때보다 문제가 생겼을 때 가치가 드러나는 경우가 많았다.


그런데 Connection 상태만 보고 끝내도 부족했다

여기까지 확인하면 PostgreSQL Connection 문제를 꽤 많이 이해한 것처럼 느껴진다.

하지만 한 가지가 다시 걸렸다.

예를 들어 Pool에 Connection이 50개 있고 대부분 active라고 하자.

그렇다면 Connection이 부족한 것이고 max_connections를 늘려야 할까?

꼭 그렇지는 않다.

왜 50개의 Connection이 모두 오랫동안 active인지 봐야 하기 때문이다.

Query 하나가 매우 빠르게 끝난다면 Connection은 곧 Pool로 돌아간다.

반대로 Query가 오래 걸리면 Connection 하나가 오랫동안 점유된다.

이 상황에서 요청이 계속 들어오면 Pool은 자연스럽게 가득 찰 수 있다.

즉 겉으로 보이는 현상은:

Connection 부족

이지만 실제 시작점은:

느린 Query

일 수도 있다.

이 사실을 이해하고 나니 max_connections를 높이는 것보다 SQL 실행시간을 줄이는 것이 더 직접적인 해결인 상황도 있을 수 있다는 생각이 들었다.

Connection을 두 배로 늘리는 것은 느린 Query를 두 배 더 많이 동시에 실행하게 만드는 결과로 이어질 수도 있기 때문이다.


그래서 Query 실행 통계까지 연결해서 봐야 했다

현재 실행 중인 Query는 pg_stat_activity로 확인할 수 있다.

하지만 어떤 SQL이 반복적으로 많이 실행되고 있고 평균적으로 얼마나 오래 걸리는지를 보려면 더 장기적인 통계도 필요하다.

PostgreSQL에서는 pg_stat_statements 모듈을 이용해 서버에서 실행되는 SQL의 planning 및 execution 통계를 추적할 수 있다. 공식 문서에서도 이 모듈을 서버에서 실행한 SQL 문의 계획 및 실행 통계를 추적하는 기능으로 설명하고 있다.

이 부분에서 Connection 문제와 Query 성능 문제가 연결된다.

처음에는 둘을 별개의 문제처럼 봤다.

Connection 문제
→ Pool / max_connections

Query 문제
→ SQL 튜닝

그런데 실제로는 느린 Query가 Connection을 오래 점유하고, 그 때문에 Pool 대기가 생기고, 결국 새로운 Connection을 더 만들려고 하면서 PostgreSQL 전체 Connection 수까지 증가하는 흐름도 생각할 수 있다.

느린 Query
    ↓
Connection 오래 점유
    ↓
Pool 사용량 증가
    ↓
새 요청이 Connection 대기
    ↓
Pool이 추가 Connection 사용
    ↓
전체 DB Connection 증가

그렇다면 마지막에 나타난 too many clients already만 보고 문제의 시작점을 판단하면 잘못된 곳을 수정할 수도 있다.

이런 이유로 이제는 DB 장애 메시지를 볼 때 가장 마지막에 터진 오류가 항상 최초 원인이라고 생각하지 않게 됐다.


한때 3,000까지 생각했던 숫자를 다시 보게 된 이유도 여기에 있다

max_connections=3000이라는 숫자를 처음 생각했을 때는 단순했다.

연결 상한이 매우 크면 Connection 부족 때문에 장애가 발생할 가능성도 작아질 것이라고 생각했다.

그런데 지금처럼 전체 흐름을 다시 보면 질문이 달라진다.

정말 3,000개의 Connection이 필요한 서비스인가.

동시에 3,000개의 Connection이 필요할 정도의 요청이 들어오는가.

그 Connection들이 실제로 동시에 Query를 실행한다면 현재 PostgreSQL 서버가 그 부하를 어떻게 처리할 것인가.

아니면 대부분의 Connection은 실제 작업 없이 유지되고 있는가.

왜 Application Pool에서 그렇게 많은 Connection을 허용해야 하는가.

이 질문에 답하지 않고 3,000이라는 숫자만 설정하는 것은 너무 큰 결정을 근거 없이 하는 셈이라는 생각이 들었다.

PostgreSQL 공식 문서 역시 max_connections를 높이면 이 값에 직접 비례해 일부 서버 자원 할당이 늘어날 수 있다고 밝히고 있다.

그래서 지금 다시 같은 문제를 만난다면 3,000이면 충분할까?부터 묻지 않을 것 같다.

오히려:

현재 서비스가 정상적으로 동작할 때 필요한 Connection은 몇 개인가?

부터 묻고 싶다.

이 값이 있어야 현재 Connection 수가 정상적인 성장인지 비정상적인 증가인지 판단할 기준도 생긴다.


지금이라면 장애가 발생하기 전 평상시 값을 먼저 기록할 것 같다

이 문제를 지나고 나서 한 가지 아쉬운 점도 있었다.

장애가 발생했을 때의 상태만 보면 그 숫자가 평소보다 얼마나 이상한 것인지 판단하기 어렵다는 것이다.

예를 들어 장애 순간 Connection이 80개라고 하자.

평소에도 70~80개를 사용하는 서비스라면 80이라는 숫자가 특별하지 않을 수 있다.

반대로 평소 5개를 사용하던 서비스가 갑자기 80개가 됐다면 매우 중요한 변화다.

결국 장애 분석에는 정상 상태의 기준값이 필요하다.

그래서 지금 다시 운영한다면 일정 간격으로 다음 정도는 기록해두고 싶다.

현재 총 Connection 수.

상태별 Connection 수.

서비스별 Connection 수.

오래 실행되는 Query 수.

Transaction이 오래 열린 세션.

이 데이터를 시간에 따라 보면 한 순간의 스냅샷보다 훨씬 많은 것을 알 수 있다.

예를 들어:

평소 10~15
↓
어느 배포 이후 30
↓
며칠 뒤 50
↓
장애 직전 90

같은 패턴이 보인다면 단순히 장애 순간의 숫자만 보는 것보다 원인을 추적하기 쉬워진다.

위 숫자는 설명을 위한 예시이지만, 이런 변화 자체를 기록하는 구조가 필요하다는 점이 중요하다.


결국 Connection 수는 하나의 결과값이었다

처음에는 too many clients already라는 오류 때문에 Connection 숫자 자체에만 집중했다.

몇 개까지 받을 수 있느냐.

몇 개가 연결되어 있느냐.

Pool이 몇 개인가.

그런데 계속 생각해보니 Connection 수는 여러 동작의 결과로 만들어지는 값이었다.

사용자가 얼마나 들어오는지에 따라 달라진다.

Query가 얼마나 빨리 끝나는지에 따라 달라진다.

Pool의 크기에 따라 달라진다.

FastAPI worker 수에도 영향을 받을 수 있다.

컨테이너 개수에 따라서도 달라질 수 있다.

동일 DB를 사용하는 다른 서비스가 있으면 또 달라진다.

Transaction을 잘못 관리하면 Connection이 오래 점유될 수도 있다.

그래서 Connection 문제를 단순히 PostgreSQL의 숫자 하나로 해결하려고 하면 원인을 설명하기 어려워진다.

이제는 전체 흐름을 다음처럼 보는 편이 훨씬 자연스럽다.

사용자 요청
    ↓
FastAPI
    ↓
DB Connection 필요
    ↓
asyncpg Pool
    ↓
PostgreSQL Connection
    ↓
SQL 실행
    ↓
Connection 반환

이 흐름 중 어느 부분에서 작업이 오래 머무르는지를 찾아야 한다.


지금 다시 too many clients already를 본다면

지금 같은 오류를 다시 만난다면 예전과 순서가 많이 다를 것 같다.

가장 먼저 max_connections를 높이지는 않을 것이다.

우선 현재 상한을 확인한다.

SHOW max_connections;

그다음 실제 Connection 수와 상태를 본다.

SELECT
    state,
    COUNT(*)
FROM pg_stat_activity
GROUP BY state;

그런데 여기에서도 결론을 내리지 않는다.

예상보다 많다면 어떤 서비스에서 만든 Connection인지 본다.

SELECT
    usename,
    application_name,
    client_addr,
    state,
    COUNT(*)
FROM pg_stat_activity
GROUP BY
    usename,
    application_name,
    client_addr,
    state
ORDER BY COUNT(*) DESC;

오래 유지되는 Connection이 있다면 생성 시점과 Transaction, Query 시작 시각을 같이 본다.

그 후 애플리케이션으로 돌아가 Pool 설정과 create_pool() 위치를 확인한다.

worker와 container 수도 다시 본다.

그리고 Query가 Connection을 오래 잡고 있는 상황이라면 SQL 실행시간과 통계를 확인한다.

이 과정을 거치고도 실제 서비스 규모 때문에 정상적인 Connection이 부족하다는 결론이 나오면 그때 max_connections 조정을 검토할 수 있다.

이 순서가 예전과 가장 크게 달라진 점이다.


마무리

처음 too many clients already 오류를 봤을 때 내 질문은 단순했다.

max_connections를 얼마까지 높이면 될까?

지금은 그 질문부터 하지 않는다.

먼저 묻는다.

왜 지금 이렇게 많은 Connection이 필요한가?

이 질문에 답하기 위해서는 PostgreSQL 설정만 봐서는 부족하다.

pg_stat_activity에서 실제 Connection 상태를 보고, Pool이 어떻게 만들어지고 있는지 확인하고, 느린 Query가 Connection을 오래 점유하고 있지는 않은지까지 연결해서 봐야 한다.

max_connections는 분명 필요한 PostgreSQL 설정이다.

서비스 규모가 커지면 실제로 값을 조정해야 하는 상황도 있을 수 있다.

하지만 상한을 크게 만드는 것과 Connection을 올바르게 관리하는 것은 서로 다른 문제다.

내가 이 문제를 겪으면서 가장 크게 바뀐 부분은 바로 이것이었다.

Connection 한계에 도달했다는 사실을 해결해야 할 원인으로 보지 않고, 그 앞에서 어떤 일이 일어났는지를 알려주는 결과로 보기 시작한 것.

그러고 나니 too many clients already라는 메시지를 보는 방식도 달라졌다.

예전에는:

Connection이 부족하다.
→ 더 많이 허용하자.

였다면,

지금은:

Connection이 부족하다.
→ 왜 이렇게 많이 사용하고 있지?
→ 정상적인 사용량인가?
→ 어떤 상태의 Connection이 많은가?
→ 어느 서비스에서 만들었는가?
→ Query가 오래 잡고 있는 것은 아닌가?
→ Pool과 배포 구조로 설명 가능한가?

로 질문이 이어진다.

그리고 그 질문에 답한 다음에야 max_connections를 늘리는 것이 정말 필요한 조치인지 판단할 수 있다고 생각한다.

결국 PostgreSQL 설정값 하나를 바꾸는 것보다 현재 시스템에서 Connection이 만들어지고 사용되고 반환되는 흐름을 설명할 수 있는 상태를 만드는 것이 먼저였다.