RSS듀오랩스
데이터베이스

PlanetScale + Vercel: DATABASE_URL과 DIRECT_URL을 나눠야 하는 이유

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

접속 문자열을 정리한다며 시크릿 이름 하나를 통일했습니다. 그날 밤 운영 사이트 로그인이 죽었고, 백업은 아무 소리 없이 깨졌습니다.

두 사고의 원인은 같았습니다. 같은 데이터베이스라도 누가 접속하느냐에 따라 접속 문자열이 달라야 한다는 사실을 제가 잊었습니다.

로그인이 죽었는데 파일을 못 찾는다고 합니다

Vercel 런타임 로그에 이렇게 찍혔습니다.

[auth][error] CallbackRouteError
[auth][cause]: PrismaClientKnownRequestError:
  Invalid `prisma.user.findUnique()` invocation:
  ENOENT: no such file or directory, open 'system'

open 'system'. 사용자 조회를 하다가 왜 system 이라는 파일을 열까요.

접속 문자열은 181자였고, 끝은 이렇게 생겼습니다.

?sslmode=verify-full&sslrootcert=system

sslrootcert=system 은 libpq만 아는 말입니다

TLS로 데이터베이스에 붙을 때 클라이언트는 서버 인증서를 검증해야 하고, 그러려면 믿을 CA 목록이 필요합니다. PostgreSQL 16부터 그 목록을 OS 신뢰 저장소로 지정하는 방법이 생겼습니다.

sslrootcert=/path/ca.pem   그 파일의 CA를 믿습니다
sslrootcert=system         OS 신뢰 저장소를 믿습니다

PlanetScale 같은 관리형 데이터베이스는 공인 CA 인증서를 씁니다. CA 파일을 따로 받아 둘 필요가 없어서 system 한 단어로 끝나고, 그래서 대시보드가 주는 접속 문자열에 이게 붙어 나옵니다.

문제는 이 말을 알아듣는 쪽이 libpq뿐이라는 것입니다.

드라이버 sslrootcert=system
libpq (psql, pg_dump) OS 저장소를 쓰라는 뜻으로 해석합니다
node-postgres (pg) system 이라는 이름의 파일을 엽니다

pg-connection-string 2.13.0 코드를 열어 봤는데 system 을 특별 취급하는 분기가 아예 없었습니다. 받은 문자열을 그대로 파일 경로로 씁니다. 없는 파일이니 ENOENT 로 죽습니다.

제가 오진한 지점이 여기였습니다. 같은 시간에 백업은 37MB로 멀쩡히 떨어지고 있었습니다. 저는 그걸 보고 "접속 문자열은 맞다"고 판단했습니다. 백업과 앱이 애초에 다른 드라이버를 쓰고 있다는 걸 생각하지 못했습니다.

sslmode를 require로 내리면 오히려 더 약해집니다

급하니까 verify-fullrequire 로 낮추고 싶어집니다. 저도 처음에 그렇게 하려고 했습니다. 확인해 보니 그게 더 위험했습니다.

// pg-connection-string
case 'require': {
  if (config.sslrootcert) { /* verify-ca 처럼 동작 */ }
  else { config.ssl.rejectUnauthorized = false }   // 검증을 끕니다
}
case 'verify-full': { break }                      // ssl={} 그대로 둡니다

verify-fullsslrootcert 가 없으면 ssl = {} 가 되고, Node는 내장 CA 저장소로 완전 검증을 합니다. 파라미터 하나만 빼면 검증 수준이 그대로 유지됩니다. 반대로 require 로 내리면 rejectUnauthorizedfalse 가 되어 중간자 공격에 열립니다.

그래서 고친 내용은 이게 전부였습니다.

sslmode=verify-full        남깁니다
&sslrootcert=system        지웁니다

백업은 아무 소리 없이 깨졌습니다

로그인을 고치면서 앱 쪽을 PgBouncer 포트(6432)로 옮겼습니다. Vercel Functions는 요청마다 인스턴스가 뜨고 각자 커넥션을 잡기 때문에, 직결로 붙으면 max_connections 를 금방 소진합니다. PlanetScale 자신이 서버리스에는 풀러를 권합니다.

그런데 백업 워크플로가 같은 이름의 시크릿을 보고 있었습니다.

env:
  DATABASE_URL: ${{ secrets.DATABASE_URL }}   # 이제 6432 풀러입니다
run: |
  pg_dump ... "$DATABASE_URL" | gzip > "$DB_FILE"

더 민망한 건, 바로 그 파일 23번째 줄에 제가 이렇게 적어 뒀다는 점입니다.

# 반드시 직결(5432)을 쓴다. PgBouncer 풀러로는 pg_dump 가 돌지 않는다.

경고를 써 놓고 그 경고가 가리키는 값을 제 손으로 바꿨습니다.

pg_dump가 풀러로 못 도는 이유

pg_dump 는 트랜잭션을 열어 REPEATABLE READ 스냅샷을 고정하고, 그 스냅샷 위에서 수백 개 테이블을 차례로 COPY 합니다. 끝날 때까지 같은 세션이어야 합니다.

PgBouncer의 transaction pooling은 트랜잭션이 끝날 때마다 뒤쪽 실제 커넥션을 다른 클라이언트에게 넘깁니다. 스냅샷은 트랜잭션 밖으로 못 나가서 덤프 도중에 데이터가 바뀌고, SET 으로 걸어 둔 설정은 다음 문장에서 사라집니다. 병렬 덤프(-j)는 SET TRANSACTION SNAPSHOT 이 필수라 아예 불가능합니다.

세션 모드로 두면 되긴 합니다. 그러면 풀링 이득이 없고, 관리형 서비스에서는 모드를 고를 수도 없습니다. 덤프는 직결로 가는 게 맞습니다.

무서운 건 이게 조용히 깨진다는 점입니다. 배포는 성공하고 앱은 잘 돌고, 백업만 다음 새벽부터 안 됩니다. 크기 하한 검사를 넣어 둔 게 그나마 다행이었습니다.

SIZE_BYTES=$(wc -c < "$DB_FILE")
if [ "$SIZE_BYTES" -lt 10000 ]; then
  echo "::error::덤프가 너무 작다(${SIZE_BYTES}바이트) — 연결 실패 가능"; exit 1
fi

이름이 갈린 것은 혼란이 아니라 구분이었습니다

정리하면 이렇습니다.

변수 포트 SSL 파라미터 소비자
DATABASE_URL 6432 (PgBouncer) sslmode=verify-full 앱, 서버리스 함수
DIRECT_URL 5432 (Direct) sslmode=verify-full&sslrootcert=system pg_dump, psql, 마이그레이션

호스트도 사용자명도 비밀번호도 같습니다. 포트와 SSL 파라미터만 다릅니다.

Prisma가 DATABASE_URLDIRECT_URL 두 칸을 두는 이유가 이것이었습니다. 저는 그걸 Supabase 시절의 관례로만 알고 있었습니다. 풀러를 쓰는 관리형 Postgres라면 어디서나 같은 구분이 필요합니다.

한 가지 함정이 더 있습니다. Prisma 7과 @prisma/adapter-pg 조합처럼 스키마가 url 을 들지 않고 코드에서 커넥션을 만드는 구성이면, DIRECT_URL애플리케이션 코드가 읽지 않습니다. 저장소 전체를 grep해도 안 나옵니다. 그래서 죽은 변수처럼 보입니다. 저는 실제로 그렇게 판단해서 지웠다가 되돌렸습니다.

코드가 안 읽는 것과 아무도 안 쓰는 것은 다릅니다. 워크플로가 쓰고, 스크립트가 쓰고, 사람이 씁니다. 지우기 전에 "코드에 없다"가 아니라 "왜 있었나"를 물었어야 했습니다.

Preview는 브랜치 데이터베이스로 분리합니다

운영을 정리하고 나니 프리뷰가 남았습니다. 프리뷰가 운영 데이터베이스를 보게 두면 안 됩니다. PR마다 배포가 생기고 그 URL은 쉽게 공유됩니다.

PlanetScale은 데이터베이스 브랜치를 만들 수 있습니다. 스키마를 복제한 독립 데이터베이스가 생기고, 접속 자격증명도 브랜치별로 따로 발급됩니다.

Branches 에서 main 을 원본으로 새 브랜치를 만듭니다. 프리뷰용이면 preview 처럼 오래 쓸 이름 하나를 두는 편이 관리하기 쉽습니다. PR마다 브랜치를 만들면 정리가 일이 됩니다.

그다음 Connect 화면 오른쪽 위에서 Branch를 방금 만든 브랜치로 바꿉니다. 역할(role)을 고르면 그 브랜치용 호스트와 포트, 사용자명이 나옵니다. 여기서 PgBouncer / Direct 를 고르는 자리가 있는데, 앱이 쓸 값이니 PgBouncer를 고릅니다.

비밀번호는 역할을 만들 때 한 번만 보여줍니다. 놓치면 자격증명을 새로 발급해야 합니다. 받는 즉시 비밀 저장소에 넣어 두세요.

Vercel에는 이렇게 넣습니다.

vercel env add DATABASE_URL preview --sensitive

특정 git 브랜치에만 걸고 싶으면 대시보드에서 브랜치를 지정할 수 있습니다. develop 만 프리뷰 데이터베이스를 쓰게 하는 식입니다. sslrootcert=system 은 여기서도 빼야 합니다. 앱은 어느 환경에서든 node-postgres를 씁니다.

프리뷰에 운영 비밀을 복사하지 않습니다

이름은 같고 값은 반드시 달라야 하는 것들이 있습니다.

변수 프리뷰에서
DATABASE_URL 브랜치 데이터베이스. 운영은 금지입니다
AUTH_SECRET 달라야 합니다. 같으면 프리뷰에서 발급된 세션이 운영에서도 유효해집니다
CRON_SECRET 달라야 합니다
알림 채널 토큰 아예 넣지 않습니다. 프리뷰가 운영 알림 채널에 글을 씁니다
외부 API 키 공유해도 되지만 사용 한도를 같이 씁니다

제 프리뷰 환경에는 변수가 24개 있었습니다. 그중 16개는 예전에 쓰던 데이터베이스 제공자의 통합이 심어 둔 것이었고, 이미 안 쓰는 서비스를 가리키고 있었습니다. DATABASE_URL 은 두 벌이 겹쳐 있었습니다.

더 나쁜 건 그것들이 Non-sensitive로 저장돼 있었다는 점입니다. Vercel 대시보드에서 비밀번호와 전체 접속 문자열이 그대로 읽혔습니다. 통합을 붙였다 뗄 때는 남은 변수를 반드시 확인해야 합니다.

남은 것

프리뷰 정리는 아직 안 했습니다. 브랜치 데이터베이스를 만들고 비밀을 갈라 넣는 일이 남았고, 급하지 않아서 미뤘습니다. 어차피 이미 죽은 데이터베이스를 가리키고 있어서 하루 더 깨져 있어도 잃을 게 없습니다.

이번에 제일 오래 남을 교훈은 따로 있습니다. "동작한다"가 "맞다"는 아니라는 것입니다. 백업이 37MB로 떨어졌다는 사실은 접속 문자열이 옳다는 증거가 아니라, pg_dump 가 그 형식을 이해한다는 증거였을 뿐입니다. 같은 값을 여러 곳이 읽고 있다면, 그들이 같은 라이브러리를 쓰는지부터 확인해야 합니다.

마지막 수정:

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