RSS듀오랩스
데이터베이스

Prisma DIRECT_URL: 운영이 아니라 풀러가 가르는 기준

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

Prisma 설정에서 이런 줄을 자주 봅니다.

datasource db {
  provider  = "postgresql"
  url       = env("DATABASE_URL")
  directUrl = env("DIRECT_URL")
}

같은 데이터베이스를 가리키는 URL 이 왜 둘일까요. 게다가 어떤 환경에서는 DIRECT_URL 이 없어도 아무 문제가 없고, 어떤 환경에서는 없으면 배포가 마이그레이션 단계에서 멈춥니다. 같은 코드, 같은 스키마인데 그렇습니다.

"운영용 변수"라는 오해

가장 자주 보는 설명은 "운영 환경에서는 DIRECT_URL 을 추가로 설정한다" 입니다. 개발에서는 없어도 되고 운영에서는 필요하다는 식입니다.

이 이해로는 설명되지 않는 경우가 바로 나옵니다. 도커로 Postgres 를 띄운 운영 서버에는 DIRECT_URL 이 필요 없습니다. 반대로 관리형 Postgres 를 붙인 개발 환경에는 필요합니다. 환경의 등급이 기준이 아니라는 뜻입니다.

기준은 데이터베이스 앞에 커넥션 풀러가 있느냐입니다. 풀러가 없으면 URL 하나로 끝나고, 있으면 둘이 됩니다.

요청마다 세션을 갈아 끼우는 풀러

Supabase 의 Supavisor, Neon 의 pooled 엔드포인트, 그리고 그 조상 격인 PgBouncer 는 모두 같은 문제를 풉니다. Postgres 는 연결 하나에 프로세스 하나를 쓰기 때문에 연결 수가 늘면 급격히 비싸집니다. 서버리스 함수처럼 짧게 살고 많이 뜨는 클라이언트가 각자 연결을 열면 상한에 금방 닿습니다.

풀러는 실제 Postgres 연결을 소수만 유지하고 클라이언트에게는 그것을 돌려 씁니다. 문제는 어떤 단위로 돌려 쓰느냐 입니다. 트랜잭션 모드는 트랜잭션 하나가 끝나면 그 연결을 다른 클라이언트에게 넘깁니다. 클라이언트 입장에서는 방금 쓰던 연결이 다음 요청에서는 남의 것이 됩니다.

그래서 세션에 걸리는 것은 전부 못 씁니다. SET 으로 바꾼 설정, LISTEN/NOTIFY, 세션 수준 어드바이저리 락, 그리고 구현에 따라 prepared statement 까지 그렇습니다. PgBouncer 문서가 트랜잭션 모드에서 쓸 수 없는 기능을 표로 정리해 두었는데, 목록이 짧지 않습니다.

정확히 그 목록을 쓰는 마이그레이션

Prisma 의 migrate deploy 는 시작할 때 어드바이저리 락을 잡습니다. 배포 두 개가 동시에 같은 데이터베이스에 마이그레이션을 적용하는 사고를 막기 위해서입니다. 락은 세션에 붙는 것이라, 트랜잭션이 끝나는 순간 연결이 남에게 넘어가는 환경에서는 의미를 잃습니다.

DDL 여러 개를 한 트랜잭션에 묶는 마이그레이션도 마찬가지입니다. 중간에 연결이 바뀌면 그 트랜잭션은 성립하지 않습니다.

그래서 갈래가 이렇게 정리됩니다. 런타임은 풀러를 지나야 하고, 마이그레이션은 풀러를 지나면 안 됩니다. 하나의 변수로는 두 요구를 동시에 만족시킬 수 없어서 URL 이 둘이 됩니다. DATABASE_URL 은 풀러를, DIRECT_URL 은 데이터베이스를 직접 가리킵니다. 비밀번호와 데이터베이스 이름은 같고 대개 포트만 다릅니다.

세션 모드가 있는데 왜 안 쓰나

풀러 중에는 세션 모드로 동작할 수 있는 것도 있습니다. 그 모드에서는 연결을 클라이언트가 끊을 때까지 붙들어 주므로 위의 제약이 사라집니다.

그런데 그러면 풀러를 둔 이유의 절반이 사라집니다. 세션 모드는 클라이언트 하나가 연결 하나를 계속 점유하므로, 동시 접속 수가 결국 Postgres 의 연결 수를 그대로 따라갑니다. 서버리스에서 연결 폭증을 막으려고 넣은 장치가 폭증을 다시 허용하게 됩니다.

그래서 실무에서는 런타임은 트랜잭션 모드, 마이그레이션은 직결 로 나누는 쪽이 일반적입니다. 관리형 서비스들이 포트를 둘 열어 두는 것도 그래서입니다.

그럼 풀러가 없는 환경은

자체 호스팅한 Postgres 컨테이너를 앱이 직접 보는 구성이라면 DIRECT_URL 은 필요 없습니다. 앞에 갈아 끼울 것이 없으니 DATABASE_URL 하나가 런타임과 마이그레이션을 모두 감당합니다.

Prisma 설정에서 폴백을 이렇게 두는 것이 그래서 편리합니다.

url: process.env.DIRECT_URL ?? process.env.DATABASE_URL

DIRECT_URL 이 있으면 그쪽을, 없으면 DATABASE_URL 을 씁니다. 풀러가 있는 환경과 없는 환경이 같은 코드로 돌아갑니다.

다만 저는 이 폴백이 조용한 함정이라고 봅니다. 없어도 동작하기 때문에 필수 변수 목록에서 빠지기 쉽습니다. 목록에서 빠지면 환경변수를 정리하거나 동기화하는 도구가 그 값을 "관리 대상이 아닌 것" 으로 취급합니다. 일치를 강제하는 옵션을 켜는 순간 배포처에서 조용히 지워질 수 있고, 그때 깨지는 것은 런타임이 아니라 다음 배포의 마이그레이션 단계입니다. 앱은 멀쩡히 돌고 있는데 배포만 안 되는 상태라 원인을 찾는 데 시간이 걸립니다.

설계할 때 달라지는 것

DIRECT_URL 의 유무는 환경의 등급이 아니라 인프라의 모양을 따릅니다. 그래서 환경변수 문서를 쓸 때 "운영에만 있는 값" 으로 적으면 나중에 틀립니다. 관리형 데이터베이스를 개발 환경에 붙이는 순간 그 서술이 뒤집히기 때문입니다.

적어야 할 것은 어느 환경에 풀러가 있는가 입니다. 그것만 적혀 있으면 어느 환경에 DIRECT_URL 이 필요한지는 따라 나옵니다.

여기까지가 확실한 부분

풀러의 트랜잭션 모드가 세션 상태를 보장하지 않는다는 것, 그리고 Prisma 마이그레이션이 어드바이저리 락을 쓴다는 것은 각 문서에 명시돼 있습니다.

반면 어떤 기능이 정확히 어디까지 되는지는 구현마다 다릅니다. prepared statement 는 PgBouncer 버전과 설정에 따라 갈리고, 관리형 서비스들은 각자 우회책을 넣기도 합니다. 그래서 "트랜잭션 모드에서는 X 가 안 된다" 를 일반화하기보다, 쓰고 있는 풀러의 문서에서 그 항목을 확인하는 편이 안전합니다.

참고: Prisma directUrl · PgBouncer feature matrix

마지막 수정:

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