RSS듀오랩스
데이터베이스

Supabase에서 PlanetScale로: 이관이 드러낸 커넥션 풀 기본값

작성자
듀오랩스 대표·11분 읽기

운영 데이터베이스를 Supabase에서 PlanetScale Postgres로 옮겼습니다. 옮기고 나서 사이트가 조금 굼떠진 것 같았습니다. 클라우드를 건너뛰게 됐으니 그 대가겠거니 생각했는데, 재보니 아니었습니다. 원인은 이관과 상관없이 원래 있던 커넥션 풀 설정이었고, 그것도 제 첫 측정으로는 보이지 않았습니다.

옮긴 이유는 속도가 아니라 요금이었습니다

Supabase에 월 $35를 쓰고 있었습니다. Pro $25에 두 번째 프로젝트 $10입니다. 기능이 필요해서가 아니라 순전히 egress 한도 때문이었습니다. 무료 한도를 넘기니 Pro로 올려야 했고, 프로젝트가 둘이라 하나 더 붙었습니다.

실측해 보니 월 13.3GB를 내보내고 있었습니다. PlanetScale의 가장 작은 등급은 월 $5에 egress 10GB가 포함이고 초과분은 GB당 $0.12입니다. 13.3GB를 그대로 쓴다 해도 초과 요금이 몇백 원입니다. egress가 요금 항목에서 사실상 사라지는 셈이라 옮기기로 했습니다.

서울 리전이 있는지가 두 번째 조건이었습니다. Neon도 후보였지만 아시아에 싱가포르와 시드니뿐이라 여기서 탈락했습니다. PlanetScale은 GCP 서울이 있습니다.

옮기는 것보다 백업이 문제였습니다

덤프 뜨고 복원하면 되는 일이라고 생각했습니다. 그런데 기존 백업을 새 데이터베이스에 넣어보니 통째로 실패했습니다.

pg_dump--schema=public을 주지 않고 있었습니다. 그래서 덤프에 Supabase가 자기 용도로 쓰는 auth, storage, realtime, vault 스키마와 이벤트 트리거가 전부 섞여 들어가 있었습니다. 같은 Supabase 프로젝트에 되돌릴 때는 문제가 없지만, 다른 Postgres로는 들어가지 않습니다.

그때까지 쌓인 백업 33개가 전부 그 상태였습니다. 진짜 사고가 나서 복원을 시도했을 때 알았다면 백업이 없는 것과 같았습니다. 요금 때문에 시작한 작업에서 이걸 발견한 게 이번 이관의 가장 큰 수확입니다.

복원 스크립트도 성치 않았습니다. psql 16으로 실행하는데 덤프는 pg_dump 17이 만든 것이라 \restrict 구문을 읽지 못했고, 복원 대상 데이터베이스는 이미 쓰지 않는 곳으로 고정돼 있었습니다.

psql은 다 통과하는데 앱만 죽었습니다

데이터를 옮기고 psql로 검증을 마친 뒤 배포했더니 헬스체크가 db: down을 냈습니다. 검증은 전부 통과했는데 앱만 붙지 못하는 상황이었습니다.

원인은 연결 문자열에 붙어 있던 sslrootcert=system이었습니다. libpq는 이걸 "OS 신뢰 저장소를 쓰라"는 뜻으로 알아듣습니다. 그런데 애플리케이션이 쓰는 node-postgres는 system이라는 이름의 파일을 열려고 하다가 ENOENT로 죽습니다. sslrootcert만 빼고 sslmode=verify-full을 남기면 Node의 기본 CA 저장소로 검증이 그대로 되므로 보안이 낮아지지도 않습니다.

두 번째는 커넥션 풀 옵션의 statement_timeout이었습니다. node-postgres는 이 값을 접속 시작 패킷에 실어 보내는데, PgBouncer 트랜잭션 모드는 그런 시작 파라미터를 거부합니다. unsupported startup parameter: statement_timeout, FATAL 08P01입니다. 클라이언트 쪽에서 재는 query_timeout으로 바꾸면 타임아웃 의도는 그대로 지키면서 풀러와 부딪히지 않습니다.

둘 다 psql로는 재현되지 않는 종류라, 검증을 아무리 해도 배포 전에는 알 수 없었습니다.

느려진 것 같아서 쟀는데, 처음엔 잘못 쟀습니다

이관을 마치고 나니 응답이 미묘하게 느린 느낌이 들었습니다. 근거는 없고 감이었습니다.

측정할 방법은 마침 있었습니다. 이 서비스에는 헬스체크가 두 개 있습니다. 하나는 데이터베이스를 건드리지 않고 JSON만 돌려주고, 다른 하나는 SELECT 1을 한 번 칩니다. 두 응답 시간의 차이가 곧 데이터베이스 왕복 비용입니다. 서버 처리 시간만 보려고 TLS 핸드셰이크가 끝난 시점을 빼서 제 쪽 네트워크 구간도 걷어냈습니다.

각각 30번씩 때려봤습니다.

DB 없음 DB 있음 차이
최소 32.6ms 36.2ms +3.6ms
p50 37.8ms 46.9ms +9.1ms
p90 48.6ms 93.1ms +44.5ms

따뜻할 때 3.6ms면 나쁘지 않은 값입니다. 클라우드를 건너뛰는 손해가 생각보다 작구나 싶었습니다. p90의 44.5ms만 눈에 걸렸는데, 새 인스턴스가 뜰 때 첫 연결을 여는 비용이라고 결론 내렸습니다.

이 측정이 틀렸습니다. 30번을 연달아 던지면 연결이 계속 살아 있습니다. 그건 제 서비스에 실제로 들어오는 트래픽 모양이 아닙니다. 방문자는 띄엄띄엄 옵니다.

간격을 벌리니 보였습니다

같은 측정을 요청 사이 25초를 두고 다시 했습니다. 열 번 돌린 결과입니다.

40.8  81.4  21.3  29.7  39.8  29.0  31.1  23.0   (ms)

중간값 31.1ms. 아까 본 3.6ms와는 다른 세계입니다. 눈여겨볼 것은 가장 좋았던 회차조차 21.3ms라는 점입니다. 어쩌다 한 번 느린 게 아니라 거의 매번 무언가를 새로 하고 있었습니다.

원인은 이관이 아니라 pg 기본값이었습니다

node-postgres의 PoolidleTimeoutMillis의 기본값이 10초입니다. 10초 동안 쓰이지 않은 연결은 닫습니다.

트래픽이 꾸준한 서비스라면 합리적인 기본값입니다. 제 사이트는 그렇지 않습니다. 방문자 사이 간격이 보통 10초보다 깁니다. 그래서 손님이 올 때마다 연결이 이미 닫혀 있었고, 매번 TCP 연결과 TLS 핸드셰이크와 인증을 처음부터 다시 했습니다. 정작 질문 자체는 1ms 남짓인데 준비에 30ms를 쓰고 있었던 겁니다.

여기서 제 가설이 하나 더 깨졌습니다. 이건 이관 때문에 생긴 문제가 아니었습니다. git으로 이관 직전 커밋을 열어 보니 풀 옵션이 하나도 없었습니다. idleTimeoutMillis는 클라이언트 쪽 설정이라 뒤에 어떤 Postgres가 있든 똑같이 동작합니다. Supabase를 쓰던 내내 같은 일이 벌어지고 있었습니다.

다만 아프기는 덜 아팠을 겁니다. 이 서비스는 Vercel에서 도는데 Vercel의 함수는 AWS 위에서 실행됩니다. Supabase도 AWS 서울이었으니 재연결이 같은 클라우드 안에서 끝났습니다. PlanetScale은 GCP 서울이라 클라우드를 한 번 건너갑니다. 재연결 횟수는 같은데 한 번의 값이 비싸진 셈입니다.

그래서 이관 직후 제가 느꼈던 굼뜬 느낌은 착각이 아니었습니다. 다만 원인은 이관 자체가 아니라, 이관이 기존 문제의 가격표를 올린 것이었습니다.

고친 것은 세 줄, 붙여야 했던 것은 한 개 더

풀에 최소 유지 개수를 두면 됩니다. pg-pool은 유휴 연결을 회수할 때 _clients.length > min인 경우에만 닫으므로, min: 1이면 한 개는 남습니다.

const pool = new Pool({
  connectionString,
  max: 5,
  min: 1,
  idleTimeoutMillis: 60_000,
  keepAlive: true,
  keepAliveInitialDelayMillis: 10_000,
});

max를 5로 낮게 잡은 것은 의도입니다. Vercel은 인스턴스를 여러 개 띄우고 각 인스턴스가 자기 풀을 가지므로, 인스턴스 수에 max를 곱한 만큼이 풀러의 클라이언트 한도를 향합니다.

여기에 에러 핸들러가 반드시 따라와야 합니다. 유휴 연결이 끊기면 pg-pool은 그 클라이언트를 풀에서 빼면서 pool.emit("error")를 부릅니다. 리스너가 없으면 EventEmitter 규칙에 따라 예외가 그대로 던져져 프로세스가 죽습니다. 연결을 더 오래 붙잡기로 한 이상 이 경로를 밟을 확률도 같이 올라가므로, 이건 선택이 아니라 위 설정의 전제 조건입니다.

pool.on("error", (err) => {
  console.error("[pg] 유휴 연결 오류(풀에서 제거됨):", err.message);
});

배포 전에 로컬에서 동작만 따로 확인했습니다. 15초씩 쉬면서 쿼리를 세 번 던졌을 때, 기존 설정은 연결을 세 번 열었고 새 설정은 한 번 열었습니다.

배포 후

같은 방식으로 다시 쟀습니다.

이전 이후
p50 31.1ms 1.1ms
p90 81.4ms 40.2ms

회차별로 보면 성격이 더 분명합니다.

이전:  40.8  81.4  21.3  29.7  39.8  29.0  31.1  23.0
이후:  -1.5   0.3   1.8  -0.1  40.2   1.1   8.1   0.1

음수는 데이터베이스가 빨라져서가 아니라 비교 대상인 쪽이 그 회차에 튄 것입니다. 대부분의 요청에서 왕복 비용이 측정 오차 안으로 들어갔습니다.

중간에 한 번 크게 당황했습니다. 배포 직후 두 회차가 각각 395ms, 391ms로 나와서 수정이 오히려 상황을 악화시킨 줄 알았습니다. 인스턴스가 동결됐다 깨어날 때 죽은 소켓을 쥐고 있는 것 아닌가 의심했는데, 3회차부터 정상으로 돌아왔습니다. 배포 직후 인스턴스가 새로 뜨는 구간이었습니다. 나중에 런타임 로그를 확인해 보니 위에서 넣은 [pg] 오류는 한 건도 찍히지 않았습니다.

남은 것

p90의 40ms는 없앨 수 없는 몫입니다. 요청이 몰려 새 인스턴스가 뜨면 그 인스턴스는 자기 연결을 한 번은 열어야 합니다. min은 같은 인스턴스가 연결을 유지하게 할 뿐입니다.

아직 확인하지 못한 것도 있습니다. 25초 유휴는 확실히 버텼지만, 새벽처럼 몇 시간씩 완전히 비는 구간에서도 연결이 살아 있을지는 모릅니다. 인스턴스가 그동안 동결돼 있으면 TCP keepalive 패킷도 나가지 못하므로 죽은 소켓을 쥘 가능성이 남아 있습니다. 에러 핸들러를 넣어둔 것이 그 대비이고, 실제 동작은 시간을 두고 봐야 알 수 있습니다.

Supabase 시절의 실제 수치도 이제는 잴 수 없습니다. 운영은 새 코드로 돌고 있고 예전 프로젝트는 놀고 있습니다. "지금이 그때보다 빠르다"는 말은 측정이 아니라 구조에서 나온 추론이라는 점을 적어 둡니다.

다르게 했어야 했던 것

측정을 먼저 하고 옮겼어야 했습니다. 이관 전 수치가 있었다면 "이관 때문에 느려졌나"라는 질문에 몇 분이면 답할 수 있었을 겁니다. 지금은 그 비교가 영원히 불가능합니다.

그리고 처음 잰 30회 연속 측정은 방법이 틀렸다기보다 재현하려는 상황이 틀렸습니다. 부하 테스트의 습관대로 최대한 빨리 많이 던졌는데, 제가 알고 싶었던 것은 부하가 아니라 한산할 때의 동작이었습니다. 무엇을 재는지보다 어떤 상황을 재는지를 먼저 정했어야 했습니다.

마지막 수정:

공유하실 때는 출처(Duolabs)와 원문 주소를 표시해 주세요.