RSS

Supabase에 붙었더니 ENOTFOUND: 직결, 풀러(Supavisor), 그리고 IPv6

온프레미스 Docker로 돌아가던 서비스를 Vercel + Supabase로 옮기는 중이었습니다.
Supabase 설정을 끝내고 로컬에서 스키마를 밀어넣으려는데 이런 에러가 났습니다.

✗ 직결 실패: getaddrinfo ENOTFOUND db.xxxxxxxxxxxx.supabase.co

비밀번호를 틀린 것도, 방화벽 문제도 아니었습니다. 호스트 이름 해석 자체가 실패했습니다.
getaddrinfo 단계에서 죽었다는 건 아직 접속을 시도조차 못 했다는 뜻입니다.

원인: 직결 주소는 IPv6 전용입니다

DNS를 직접 확인해봤습니다.

$ dig +short A    db.xxxxxxxxxxxx.supabase.co
              # (비어 있음)

$ dig +short AAAA db.xxxxxxxxxxxx.supabase.co
2406:da12:557:f802:....

A 레코드(IPv4)가 아예 없고 AAAA 레코드(IPv6)만 있습니다.
제 회선은 IPv4 전용이라 해석할 주소가 없어서 ENOTFOUND가 난 것이었습니다.
재시도하거나 비밀번호를 고쳐도 절대 풀리지 않는 종류의 문제입니다.

Supabase 대시보드에도 이 안내가 떠 있었습니다.

Direct connections use IPv6 by default.
Enable the dedicated IPv4 address add-on to connect from IPv4-only networks.

설정 실수가 아니라 구조가 그렇습니다. Supabase는 IPv4 주소 고갈 문제로
직결 주소를 IPv6 기본으로 전환했고, IPv4가 필요하면 별도 수단을 쓰도록 했습니다.

그래서 연결 방식이 세 가지입니다

Supabase 대시보드의 Connect 버튼을 누르면 세 가지가 나옵니다.
처음 보면 뭘 골라야 할지 헷갈리는데, 정리하면 이렇습니다.

방식 호스트 포트 IPv4 용도
Direct connection db.<ref>.supabase.co 5432 ✗ (애드온 필요) 상주 서버, 논리적 복제
Session pooler <region>.pooler.supabase.com 5432 직결의 IPv4 대체재
Transaction pooler <region>.pooler.supabase.com 6543 서버리스 런타임

핵심은 풀러가 모든 요금제에서 IPv4를 지원한다는 점입니다.
IPv4 전용 환경이시라면 애드온을 구매하지 않아도 풀러로 우회할 수 있습니다.

커넥션 풀러가 무엇인가요

Postgres는 클라이언트 하나가 붙을 때마다 서버 프로세스를 하나씩 띄웁니다.
스레드가 아니라 프로세스입니다. 그래서 동시 접속 수에 실질적인 상한이 있고,
무료나 소형 인스턴스는 이 상한이 수십 개 수준으로 낮습니다.

문제는 서버리스 환경입니다. Vercel 같은 곳은 요청이 몰리면 함수 인스턴스를
수십, 수백 개로 늘립니다. 각 인스턴스가 커넥션을 하나씩만 잡아도 금세 상한을 넘겨
sorry, too many clients already 를 만나게 됩니다.

커넥션 풀러는 그 사이에 끼어드는 중개자입니다.
클라이언트 쪽으로는 커넥션을 넉넉히 열어주고, 실제 Postgres 쪽으로는
적은 수의 커넥션을 돌려씁니다. Supabase가 쓰는 구현체는 Supavisor 입니다
(Elixir로 만든 자체 풀러이며, 널리 쓰이던 PgBouncer의 역할을 대신합니다).

세션 모드와 트랜잭션 모드

여기가 실무에서 제일 중요한 부분입니다. 같은 풀러인데 포트에 따라 동작이 완전히 다릅니다.

트랜잭션 모드 (6543)

  • 서버 커넥션을 트랜잭션 단위로 빌려주고 곧바로 반납합니다
  • 커넥션 하나를 여러 클라이언트가 번갈아 쓰므로 재사용률이 극대화됩니다. 서버리스에 적합합니다
  • 대신 세션 상태가 유지되지 않습니다. 세션 변수, 준비된 문(prepared statement),
    SET 으로 바꾼 설정, 어드바이저리 락 같은 것이 다음 쿼리에 남아 있지 않습니다

세션 모드 (5432)

  • 클라이언트가 접속을 끊을 때까지 서버 커넥션을 붙잡고 있습니다
  • 동작이 직결과 사실상 같습니다. 세션 상태가 그대로 유지됩니다
  • 재사용률은 떨어지지만, 상주 서버나 마이그레이션 도구처럼 세션이 필요한 쪽에 맞습니다

한 줄로 요약하면 - 트랜잭션 모드는 효율, 세션 모드는 호환성입니다.

그래서 URL을 두 개 씁니다

이 차이 때문에 저는 접속 문자열을 두 개로 나눠서 관리하기로 했습니다.

환경변수 어느 것 이유
DATABASE_URL Transaction pooler (6543) 앱 런타임. 서버리스 커넥션 폭증을 감당해야 함
DIRECT_DATABASE_URL Session pooler (5432) 스키마 변경(DDL). 세션이 유지돼야 함

마이그레이션을 트랜잭션 모드로 돌리면 안 되는 이유가 이것입니다.
스키마 변경 도구는 대개 여러 문장을 한 세션에서 이어서 실행하고 어드바이저리 락으로
동시 실행을 막는데, 트랜잭션 모드에서는 그 전제가 깨집니다.

Prisma를 쓰신다면 설정에서 이렇게 갈라주시면 됩니다.

// prisma.config.ts
export default defineConfig({
  schema: "prisma/schema",
  // 스키마 변경(DDL)은 세션이 유지되는 쪽으로.
  // 없으면 그냥 DATABASE_URL 을 씁니다(온프레미스처럼 풀러가 없는 환경).
  datasource: {
    url: process.env["DIRECT_DATABASE_URL"] ?? process.env["DATABASE_URL"],
  },
});

런타임 쪽은 평소대로 DATABASE_URL 을 씁니다.

// src/lib/prisma.ts
const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL });
export const prisma = new PrismaClient({ adapter });

알아두시면 편한 점

두 풀러 URL은 호스트·계정·비밀번호가 전부 같고 포트만 다릅니다(6543 ↔ 5432).
하나만 갖고 계셔도 포트만 바꾸면 나머지가 됩니다. 대시보드를 다시 열 필요가 없습니다.

참고로 풀러의 사용자명은 postgres.<프로젝트ref> 형태입니다.
여러 프로젝트가 풀러 호스트를 공유하기 때문에 사용자명으로 프로젝트를 구분합니다.
직결(postgres)과 다르니 복사하실 때 주의하세요.

IPv4 애드온은 유료입니다

대시보드에 보이는 Enable IPv4 add-on 버튼은 전용 IPv4 주소를 붙여주는 유료 부가기능입니다.
공식 문서 기준 시간당 $0.0055, 월 약 $4 이고 프로젝트 단위로 과금됩니다
(시간 단위 과금이라 1시간 미만도 1시간으로 계산됩니다). 조직 구독에 추가되는 형태라
유료 플랜이 전제입니다.

대부분은 구매하실 필요가 없습니다. 풀러가 무료로 IPv4를 지원하니,
"IPv4 회선에서 붙고 싶다"가 목적이라면 세션 풀러로 해결됩니다.

애드온이 실제로 필요한 경우는 따로 있습니다.

  • 외부 도구가 직결 호스트만 받고 풀러 주소를 지원하지 않을 때
  • 논리적 복제(logical replication) 처럼 풀러를 통과할 수 없는 기능을 쓸 때
  • 고정 IP가 있어야 하는 방화벽 화이트리스트 요구가 있을 때

저는 셋 다 해당하지 않아 세션 풀러로 진행했습니다.

실제로 확인한 것

말로만 "된다"고 하면 미덥지 않으니 직접 돌려봤습니다.

스키마 반영은 세션 풀러로 문제없이 통과했습니다.

Datasource "db": PostgreSQL database "postgres" at "<region>.pooler.supabase.com:5432"
🚀  Your database is now in sync with your Prisma schema. Done in 1.30s

더 궁금했던 건 런타임 쪽이었습니다. 제 주문 생성 로직은 재고 확인과 차감을
하나의 트랜잭션으로 묶는데, 트랜잭션 모드 풀러에서 이게 깨지지 않을까 싶었습니다.

결론부터 말씀드리면 정상 동작합니다. 트랜잭션 모드는 트랜잭션이 진행되는 동안
서버 커넥션을 해당 클라이언트에 고정(pin)해두기 때문에, 트랜잭션 하나가 통째로
같은 커넥션에서 실행됩니다. 로컬 앱을 Supabase에 붙여 한 바퀴 돌린 결과입니다.

✓ GET  /api/health
✓ GET  /api/products     → DUO-STK-001 (재고 200)
✓ POST /api/orders       → S20260729-XXXXXX (합계 10000원)
✓ GET  /api/orders/...
✓ 재고 차감 확인 200 → 198

Vercel에 올리기 전에 로컬 앱을 클라우드 DB에 붙여서 검증한 것인데,
이 순서를 권해드립니다. 빌드를 2~3분 기다렸다가 런타임에서 터지면
원인을 좁히기가 훨씬 번거로워집니다.

DATABASE_URL="<트랜잭션 풀러 URL>" npx next dev -p 3999
./scripts/smoke.sh http://localhost:3999

처음 붙이실 때 걸리는 것들

한 번씩 다 밟아본 것들이라 적어둡니다.

1. [YOUR-PASSWORD] 를 안 바꾼 경우

대시보드가 주는 문자열에는 비밀번호 자리가 플레이스홀더로 들어 있습니다.
그대로 붙여넣으면 당연히 인증에 실패하는데, 에러 메시지만 봐서는 눈치채기 어렵습니다.

2. 비밀번호 특수문자를 URL 인코딩하지 않은 경우

@ : / ? # 가 들어 있으면 URL 파싱이 깨져서 엉뚱한 호스트로 붙으려 합니다.
@%40 처럼 인코딩해야 합니다. 인증 실패가 아니라 호스트 오류로 나타나서
더 헷갈립니다.

3. 두 포트를 바꿔 넣은 경우

5432와 6543을 뒤집어 넣어도 당장은 접속이 됩니다. 문제는 나중에
마이그레이션이 이상하게 실패하거나 커넥션이 새는 식으로 드러납니다.

이 세 가지는 사람이 매번 조심하는 것보다 스크립트가 잡아주는 편이 낫습니다.
저는 접속 문자열을 입력받는 단계에서 미리 검사하도록 만들었습니다.
DNS 해석부터 먼저 확인해서, IPv6 전용 호스트면 바로 안내가 뜹니다.

✗ 직결 db.xxxxxxxxxxxx.supabase.co 는 IPv6 전용입니다(A 레코드 없음).
   Supabase Connect 에서 Session pooler(5432, *.pooler.supabase.com)
   문자열로 바꿔 넣으세요. 세션 모드라 직결과 동일하게 DDL 이 됩니다.

에러 메시지가 원인과 해결책을 같이 알려주면, 같은 함정을 다음 사람이 다시 밟지 않습니다.
사실 이 글도 같은 목적입니다.

정리

  • Supabase 직결 주소는 IPv6 전용입니다. IPv4 회선에서는 ENOTFOUND 로 실패합니다
  • 풀러(Supavisor)는 모든 요금제에서 IPv4를 지원합니다. 이것이 무료 우회로입니다
  • 트랜잭션 모드(6543) 는 커넥션을 트랜잭션 단위로 돌려씁니다 → 서버리스 런타임용
  • 세션 모드(5432) 는 직결과 동일하게 동작합니다 → 마이그레이션·상주 서버용
  • 그래서 접속 문자열을 런타임용과 DDL용 두 개로 나눠 관리합니다
  • IPv4 애드온은 유료(월 약 $4)이며, 대부분의 경우 세션 풀러로 대체됩니다

참고


관련 글

온프레미스로 돌던 서비스를 Vercel + Supabase 로 옮기고 워커를 분리하기까지,
같은 작업에서 나온 기록입니다.