0502

개발

"이 .env, 운영 DB 아니야?" — Supabase와 pnpm으로 dev/prod 나눈 후기

| 서론

안녕하세요, 팡일입니다.

사이드 프로젝트를 하다 보면 어느 순간 등골이 서늘해지는 순간이 옵니다. 저에게 그건 아주 평범한 저녁이었어요. 로컬에서 스키마를 바꾸려고 마이그레이션 명령어를 치기 직전, 무심코 열려 있던 .env 파일을 보다가 문득 이런 생각이 스쳤습니다.

made by ChatGPT

"잠깐, 지금 이 .env 운영 DB를 보고 있는 거 아니야?"

맞았습니다. 사이드 프로젝트 초반에는 대부분 데이터베이스가 하나뿐입니다. 로컬에서 개발할 때 붙는 DB도, 배포된 서비스가 실제 사용자에게 보여주는 DB도 전부 같은 데이터베이스인 거죠. 평소에는 아무 문제가 없습니다. 그러다가 prisma migrate reset 한 번, 혹은 실수로 커밋해 둔 wipe_all_data 같은 마이그레이션 한 번이면 운영 데이터가 통째로 사라질 수 있는 구조입니다. 비유하자면 안전벨트 없이 고속도로를 달리는 셈이에요. 사고가 안 나서 괜찮은 게 아니라, 아직 안 났을 뿐입니다.

그래서 큰맘 먹고 dev/prod DB를 나누기로 했습니다. 목표 자체는 아주 단순했어요. 로컬 개발은 dev DB를 바라보고, 배포된 서비스는 prod DB를 바라보게 하자. 딱 한 줄로 요약되는 목표였지만, 막상 이 단순한 목표를 이루는 길에는 생각보다 많은 함정이 숨어 있었습니다. 특히 pnpm 환경에서 두 DB를 자연스럽게 오가는 스위칭을 만드는 데는 나름의 재미있는 트릭이 필요했고요. 이 글에서는 제가 밟은 지뢰와 그걸 피해간 방법을 순서대로 정리해 보려고 합니다. 저처럼 "DB 하나로 로컬과 운영을 같이 쓰고 있는" 분들께 조금이라도 도움이 되면 좋겠습니다.

(참고로 스택은 Next.js 16 + Prisma 7 + Supabase이고, 패키지 매니저는 pnpm, 배포는 Vercel입니다.)

| 왜 굳이 나누는가 — "부술 수 있는 공간"의 가치

made by ChatGPT

본격적으로 방법을 고민하기 전에, 저는 먼저 "그래서 내가 원하는 dev 환경이 정확히 뭔데?"를 스스로에게 물어봤습니다. 이 질문이 중요한 이유는, 뒤에서 여러 선택지를 저울질할 때 이게 판단의 기준이 되기 때문이에요.

제가 내린 결론은 이렇습니다. dev 환경의 본질은 마음껏 부술 수 있는 샌드박스라는 것. 테스트 계정을 100개를 만들든, 데이터를 통째로 리셋하든, 말도 안 되는 스키마를 실험적으로 넣어보든 — 그 어떤 행동을 해도 운영 서비스에는 아무런 영향이 없어야 합니다. 개발이라는 건 결국 "안심하고 실수하는 과정"인데, 그 실수가 운영으로 새어 나가는 순간 그건 더 이상 dev가 아니니까요.

그래서 저는 이 문장을 기준으로 삼았습니다.

진짜 dev 환경이란, 부숴도 운영이 멀쩡한 '격리'가 보장되는 환경이다.

단순히 "DB를 하나 더 만든다"가 아니라 "격리 수준이 충분한가"를 봐야 한다는 뜻입니다. 이 기준을 손에 쥐고 나니, 그럴싸해 보이던 선택지 하나가 의외로 허무하게 탈락하게 됩니다. 바로 다음에 이야기할 '스키마 분리'예요.

| 후보 3가지를 저울에 올리다

made by ChatGPT

DB를 나누는 방법은 크게 세 가지가 있었습니다. 각각 비용, 오프라인 가능 여부, 그리고 방금 세운 기준인 격리 수준을 함께 놓고 비교해 봤어요.

방법

비용

로컬/오프라인

격리 수준

A. Supabase 프로젝트 2개

무료

클라우드

완전 격리 (Auth·Storage 포함)

B. 로컬 Supabase (Docker)

무료

완전 오프라인

완전 격리

C. 한 프로젝트에 스키마 분리

무료

클라우드

⚠️ 테이블만 격리

처음에 가장 솔깃했던 건 사실 C안, 스키마 분리였습니다. 인터넷에서 "인디 개발자들이 비용을 아끼려고 가장 많이 쓰는 최신 트렌드"라고 소개되는 걸 자주 봤거든요. 하나의 Supabase 프로젝트 안에 app_one, app_two처럼 별도의 스키마를 만들어서 여러 서비스의 데이터를 격리해 담는 방식입니다. 프로젝트를 새로 만들 필요도 없고, 무료 프로젝트 슬롯도 한 개만 소모하니 얼마나 경제적인가요. 게다가 최근 정책 변경으로 데이터베이스 용량 제한도 프로젝트당 넉넉해졌다는 이야기까지 들으니 더욱 마음이 기울었습니다.

그런데 이 방식을 실제 제 프로젝트에 적용해보려고 뜯어보는 순간, 첫 번째 함정을 정통으로 밟게 됩니다.

| 함정 하나 — 스키마 분리로는 dev/prod가 안 갈립니다

결론부터 말씀드리면, 스키마 분리는 훌륭한 기법이 맞지만 제가 원하는 목적에는 전혀 맞지 않았습니다. 그리고 이걸 깨닫는 과정에서 "기법의 원래 용도를 먼저 확인하는 게 얼마나 중요한지"를 다시 배웠어요.

스키마 분리가 진짜 노리는 용도는 서로 다른 여러 개의 앱을 하나의 프로젝트에 저렴하게 담는 것입니다. app_one, app_two라는 예시 이름이 힌트예요. 각각이 독립적인 서비스라서 서로 간섭할 일이 없을 때 유효한 방식이죠. 그런데 제가 원한 건 서로 다른 앱이 아니라 같은 앱의 dev와 prod였습니다. 이 둘은 겉보기엔 비슷해 보여도 성격이 완전히 다릅니다.

결정적인 걸림돌은 인증(Auth)이었습니다. Supabase Auth는 내부적으로 auth 스키마의 auth.users 테이블을 사용하는데, 이 테이블은 프로젝트당 딱 하나만 존재하며 스키마로는 절대 쪼갤 수 없습니다. 제 앱의 회원가입 흐름을 보면 이게 왜 치명적인지 바로 드러납니다.

code
// 1) Supabase Auth에 계정을 만든다 → auth.users 테이블에 행이 하나 생긴다
const { data } = await supabase.auth.signUp({ email, password })

// 2) 방금 만든 auth 계정과 "똑같은 id"로 앱의 프로필 테이블에 행을 만든다
await prisma.user.upsert({
  where: { id: data.user.id },
  create: { id: data.user.id, email, nickname, /* ... */ },
})

보시다시피 앱의 public.users.idauth.users.id1:1로 강하게 묶여 있습니다. 로그인 판정도 JWT 안의 사용자 id(sub)로 public.users를 조회하는 방식이라, 이 연결 고리가 앱 전체의 신원 체계를 떠받치고 있는 셈이에요. 이 상황에서 앱 테이블만 dev/prod 스키마로 나눠봤자, 그 밑에 깔린 auth.users는 여전히 하나로 공유됩니다. 그러면 다음과 같은 문제가 줄줄이 따라옵니다.

  • dev에서 테스트로 가입한 계정이 곧 prod의 실제 계정이 되어버립니다. 테스트 유저와 진짜 유저가 같은 공간에 뒤섞이는 거죠.

  • dev 스키마만 리셋해도 auth.users는 그대로 남기 때문에, 짝을 잃은 유령 계정이 계속 쌓입니다. 두 세계의 정합성이 서서히 어긋나요.

  • 사용자가 올리는 파일을 담는 Storage 버킷 역시 프로젝트당 하나이기 때문에, dev에서 올린 테스트 이미지가 prod 스토리지에 그대로 쌓입니다.

  • 그리고 무엇보다 결정적인 건, 그 데이터베이스가 물리적으로 운영 DB 그 자체라는 점입니다. dev에서 무심코 돌린 무거운 쿼리나 잘못된 마이그레이션이 곧바로 운영 성능과 데이터를 직격합니다.

앞에서 제가 세운 기준을 다시 떠올려 볼까요. "부숴도 운영이 멀쩡한 격리." 스키마 분리는 이 기준을 정면으로 위반합니다. 애초에 제가 이 고민을 시작한 이유가 "로컬에서 실수로 운영 데이터를 날리는 위험"이었는데, 스키마 분리는 그 위험을 단 하나도 줄여주지 못했어요. 오히려 "격리된 것 같은 착각"만 주기 때문에 어떤 면에서는 더 위험할 수도 있었습니다. 그래서 아쉽지만 깔끔하게 탈락시켰습니다.

여기서 얻은 교훈은 하나입니다. "싸다"와 "내 문제에 맞다"는 완전히 다른 이야기라는 것. 트렌드로 소개되는 기법일수록, 그게 원래 어떤 문제를 풀기 위해 나온 건지부터 확인해야 한다는 걸 다시 새겼습니다.

| 함정 둘 — 무료 플랜은 "조직"이 아니라 "계정"당 2개입니다

그렇게 스키마 분리를 접고, 정공법인 A안(프로젝트 2개)으로 방향을 잡았습니다. 그런데 새 dev 프로젝트를 만들려고 버튼을 누르는 순간, 이런 경고가 저를 막아섰습니다.

1) 해당 조직에는 무료 프로젝트 한도(2개)를 초과한 회원이 있습니다.

처음에는 "아, 조직 단위 제한이구나. 그럼 조직을 새로 하나 파면 되겠네" 하고 가볍게 생각했습니다. 그런데 새 조직을 만들어서 그 안에서 프로젝트를 생성하려고 해도 똑같은 경고가 떴어요. 몇 번을 시도해도 마찬가지였습니다. 그러다 경고 문구를 자세히 읽고 나서야 핵심을 이해했습니다.

2) Supabase 무료 플랜의 "활성 프로젝트 2개" 제한은 조직이 아니라 계정(멤버) 단위로 걸린다.

즉 한 계정이 이미 다른 프로젝트 2개를 활성 상태로 쓰고 있으면, 조직을 아무리 새로 만들어도 3번째 프로젝트는 만들 수가 없습니다. 저는 이미 다른 용도로 두 개를 쓰고 있었기 때문에 완전히 막힌 상황이었죠. 로컬 Docker를 쓰는 B안도 있었지만, 상시 실행해야 하는 Docker의 부담과 초기 세팅 비용이 마음에 걸려서 선뜻 손이 가지 않았습니다.

그런데 바로 이 "계정 단위 제한"이라는 특성 안에, 오히려 해결의 실마리가 숨어 있었습니다. 제한이 계정 단위라면, 논리적으로 이렇게 뒤집을 수 있으니까요.

3) 별도의 이메일로 Supabase 계정을 하나 더 만들면, 그 계정은 자기만의 무료 슬롯 2개를 새로 갖게 된다.

그래서 저는 새 계정을 하나 더 만들고, 그 계정 안에 jogaks.prodjogaks.dev 두 개의 프로젝트를 나란히 몰아넣었습니다. 이 방식의 장점은 생각보다 큽니다. 클라우드 기반이라 Docker를 깔 필요가 없고, 프로젝트가 물리적으로 분리되어 있으니 Auth와 Storage까지 완전하게 격리되며, 비용은 여전히 0원입니다. 추가로 감수해야 하는 관리 포인트라고는 "계정 하나를 더 로그인해서 관리한다"는 것뿐이에요. 참고로 Gmail을 쓰신다면 본인+dev@gmail.com 같은 별칭 이메일로 가입해도 실제 받은편지함은 하나로 들어오기 때문에, 메일 관리 부담도 거의 없습니다.

기존에 쓰던 운영 DB는 새로 만든 jogaks.prod로 옮겨야 했는데, 다행히 아직 정식 런칭 전이라 지켜야 할 실제 데이터가 없었습니다. 덕분에 이건 "데이터 이사"가 아니라 사실상 "새로 세팅"에 가까웠어요.

code
# 새 prod 프로젝트에 스키마만 올리고
pnpm exec prisma migrate deploy
# Vercel 환경변수를 새 prod 값으로 교체한 뒤 재배포하면 끝

만약 지켜야 할 운영 데이터나 사용자 계정이 이미 쌓여 있었다면 pg_dump로 데이터를 옮기고 auth.users까지 이전하는 훨씬 번거로운 작업이 필요했을 겁니다. 그런 의미에서 DB 분리는 데이터가 쌓이기 전, 최대한 이른 시점에 해두는 게 이득이라는 것도 이번에 체감했습니다.

| 핵심 — pnpm에서 dev/prod DB를 스크립트로 오가기

made by ChatGPT

이제 이 글의 진짜 본론입니다. 프로젝트를 나누는 것까지는 대시보드 작업이라 어렵지 않았는데, 정작 개발 경험을 좌우하는 건 "로컬에서 두 DB를 얼마나 편하게 오갈 수 있는가"였습니다. 제가 원한 건 딱 이 정도로 직관적인 사용감이었어요.

code
pnpm dev    # → dev DB 를 바라보고
pnpm prod   # → prod DB 를 바라본다

이걸 구현하기 위해 먼저 각 환경 값이 "어디에 사는지"부터 명확하게 갈랐습니다.

  • 로컬 .env → dev 값을 담습니다. 평소 pnpm dev가 이 파일을 사용합니다.

  • Vercel 대시보드 환경변수 → prod 값을 담습니다. 배포된 서비스가 실행될 때 이 값을 사용합니다.

여기서 가장 중요한 원칙은 로컬 .env에는 절대 prod 값을 두지 않는다는 것입니다. 로컬에 prod 커넥션 문자열이 상주하고 있으면, 어느 순간 db:reset 한 번에 운영 데이터가 날아갈 수 있으니까요. 그리고 다행히 Vercel은 배포·빌드 시 로컬 .env 파일이 아니라 자기 대시보드에 등록된 환경변수를 사용하기 때문에, 로컬에 prod 값이 없어도 배포에는 전혀 지장이 없습니다. 역할이 깔끔하게 분리되는 거죠.

그런데 현실적으로 "가끔은 로컬에서 prod DB의 데이터를 직접 들여다보고 싶을 때"가 생깁니다. 운영에서 이상한 데이터가 보인다거나, 실제 사용자 상태를 확인해야 할 때요. 바로 이 순간을 위한 명령이 pnpm prod입니다.

1) Next.js의 env 로딩 우선순위를 이용합니다

이 트릭의 핵심은 Next.js가 환경변수를 읽는 순서에 있습니다. Next.js 공식 문서의 Environment Variable Load Order를 보면, 값을 찾는 순서가 다음과 같이 정해져 있어요. (위에서부터 찾다가 값을 발견하면 거기서 멈춥니다.)

code
1. process.env              ← 셸에서 미리 넣은 값이 최우선
2. .env.$(NODE_ENV).local
3. .env.local
4. .env.$(NODE_ENV)
5. .env                     ← 우리의 dev 기본값이 여기 있음

여기서 1번이 핵심입니다. 셸 환경(process.env)에 값을 미리 주입해두면, 그 값이 .env 파일(dev)보다 우선한다는 뜻이니까요. 즉 .env는 그대로 dev용으로 두되, prod로 붙고 싶을 때만 셸에 prod 값을 밀어 넣으면 되는 겁니다.

한 가지 주의할 점도 있습니다. next dev 명령은 NODE_ENV를 항상 development로 강제 고정합니다. 그래서 .env.production 파일을 만들어두고 그걸 자동으로 읽게 하려는 시도는 통하지 않아요. next dev 상태에서는 그 파일을 아예 쳐다보지도 않습니다. 이 때문에 "파일 이름으로 환경을 구분하는" 접근 대신, "process.env에 직접 주입하는" 접근을 택해야 합니다. 저도 처음엔 파일 이름 트릭을 시도했다가 이 사실을 뒤늦게 확인하고 방향을 틀었어요.

2) Node 내장 기능만으로 (의존성 0개)

셸에 env 파일을 주입하는 도구로는 dotenv-cli 같은 패키지가 널리 쓰입니다. 하지만 굳이 새 의존성을 추가하고 싶지 않았어요. 알고 보니 Node 22 버전부터는 process.loadEnvFile()이라는 함수가 아예 런타임에 내장되어 있더라고요. 이걸 이용해 아주 작은 래퍼 스크립트 하나만 만들면 끝이었습니다.

TypeScript
// scripts/with-env.mjs
// 지정한 env 파일을 process.env로 먼저 주입한 뒤 명령을 실행한다.
import { spawn } from 'node:child_process'
import { delimiter, join } from 'node:path'

const [envFile, cmd, ...args] = process.argv.slice(2)
if (!envFile || !cmd) {
  console.error('usage: node scripts/with-env.mjs <envFile> <cmd> [args...]')
  process.exit(1)
}

process.loadEnvFile(envFile) // Node 22+ 내장 — 파일을 process.env로 로드

// pnpm 스크립트 밖에서 직접 실행해도 로컬 바이너리(next·prisma)가 잡히도록 PATH 보강
const binDir = join(process.cwd(), 'node_modules', '.bin')
process.env.PATH = `${binDir}${delimiter}${process.env.PATH ?? ''}`

// prod에 붙을 땐 눈에 띄게 경고
if (/prod/i.test(envFile)) {
  console.log('\x1b[41m\x1b[97m  ⚠️  PROD DB 연결 중 — 운영 데이터에 실제로 반영됩니다  \x1b[0m')
}

const child = spawn(cmd, args, { stdio: 'inherit' })
child.on('exit', (code) => process.exit(code ?? 0))

그리고 package.json의 스크립트는 이렇게 구성했습니다.

JSON
{
  "scripts": {
    "dev": "next dev",
    "prod": "node scripts/with-env.mjs .env.prod next dev",
    "db:studio": "prisma studio",
    "db:studio:prod": "node scripts/with-env.mjs .env.prod prisma studio"
  }
}

이렇게 해두면 pnpm dev는 평소처럼 .env(dev)를 사용하고, pnpm prod.env.prod의 값을 process.env에 먼저 주입한 뒤 next dev를 띄웁니다. 앞서 설명한 우선순위 덕분에 prod 값이 dev를 자연스럽게 덮어쓰는 거죠. 완전히 같은 원리로 Prisma Studio도 dev/prod를 나눠서 열 수 있어서, 운영 데이터를 GUI로 확인할 때는 pnpm db:studio:prod를 씁니다.

3) pnpm 환경에서 밟은 자잘한 지뢰들

이 과정이 한 번에 매끄럽게 된 건 아니고, pnpm 특유의 구조 때문에 몇 가지 지뢰를 밟았습니다. 비슷한 환경에서 시도하실 분들을 위해 정리해 둡니다.

  • node_modules/.bin 경로 문제: pnpm은 의존성을 가상 스토어에 심볼릭 링크로 두는 독특한 구조를 씁니다. 그래서 래퍼를 pnpm run이 아니라 node scripts/...로 직접 실행하면 next 바이너리를 못 찾고 command not found가 떠버립니다. pnpm run 내부에서는 pnpm이 알아서 PATH를 세팅해 주지만, 저는 어떤 방식으로 실행하든 안전하게 동작하도록 래퍼가 스스로 node_modules/.bin을 PATH 맨 앞에 붙이게 만들었습니다.

  • spawnshell: true 함정: 처음에는 자식 프로세스를 띄울 때 shell: true 옵션을 줬습니다. 그런데 명령 인자에 괄호 같은 특수문자가 섞이면 /bin/sh가 파싱하다 깨지고, 거기에 더해 DEP0190 보안 관련 경고까지 뜨더군요. 위에서 PATH를 직접 보강하고 나니 shell 옵션 자체가 필요 없어져서, 아예 제거하는 것으로 두 문제를 한 번에 해결했습니다.

  • DB 비밀번호 percent-encoding: 커넥션 문자열에 들어가는 DB 비밀번호에 !, @, # 같은 특수문자가 있으면 반드시 URL 인코딩을 해줘야 합니다(!%21, @%40, #%23). 이걸 안 하면 커넥션이 조용히 실패하는데, 에러 메시지가 친절하지 않아서 원인을 찾는 데 시간을 꽤 쓸 수 있습니다.

| 안전장치 — 편함과 위험은 붙어 다닙니다

pnpm prod라는 편리한 명령을 만들고 나니, 동시에 새로운 위험도 생겼다는 걸 깨달았습니다. 이 명령으로 로컬에서 운영 DB에 붙는 순간, 거기서 하는 회원가입이든 삭제든 전부 실제 운영 데이터에 그대로 반영되니까요. 편해진 딱 그만큼 위험해진 셈입니다. 그래서 브레이크 역할을 할 안전장치를 몇 개 달아뒀습니다.

  1. 빨간 경고 배너: pnpm prod를 실행하면 터미널에 빨간 바탕의 경고 문구가 큼직하게 출력됩니다. 익숙해질수록 "지금 내가 어느 환경에 붙어 있는지"를 놓치기 쉬운데, 시각적으로 강하게 각인시켜서 실수를 줄이려는 장치입니다.

  2. 파괴적인 마이그레이션 명령은 prod로 가지 못하게 격리: db:migratedb:reset 같은 명령은 일부러 래퍼를 거치지 않고 항상 .env(dev)만 바라보도록 뒀습니다. 덕분에 pnpm prod로 앱은 prod에 붙어 있더라도, 스키마를 갈아엎는 위험한 명령은 dev로만 향합니다. 만약 정말로 prod에 마이그레이션을 적용해야 한다면, 그때만 명시적으로 .env.prod를 붙여 db:deploy를 실행하도록 하는 거죠. "위험한 일은 반드시 한 번 더 손이 가게" 만드는 설계입니다.

  3. .env*는 git에서 제외: .env.env.prod 모두 .gitignore로 추적에서 제외했습니다. API 키나 DB 비밀번호가 실수로 저장소에 커밋되어 새어 나가는 사고를 원천적으로 막기 위해서입니다.

이 세 가지는 대단한 기술은 아니지만, "편의 기능을 만들었으면 그에 상응하는 브레이크도 같이 만든다"는 원칙을 지키기 위한 최소한의 장치라고 생각합니다.

| 결론

made by ChatGPT

작업을 마치고 돌아보니, 정작 어려웠던 건 "코드를 짜는 일"이 아니라 "올바른 선택을 하는 일"이었습니다. 코드는 마지막에 래퍼 스크립트 몇 줄로 끝났지만, 거기까지 가는 판단의 과정이 이번 작업의 진짜 알맹이였어요. 핵심만 다시 추리면 이렇습니다.

  • 무료 플랜의 프로젝트 제한은 조직이 아니라 계정 단위였고, 이를 역이용해 별도 계정으로 슬롯을 확보했습니다.

  • 스키마 분리는 저렴해 보였지만 Auth가 프로젝트당 하나로 묶이는 구조 때문에 dev/prod 분리에는 부적합했습니다.

  • Next.js는 process.env.env 파일보다 우선하므로, 파일 이름 트릭 대신 env를 직접 주입하는 방식을 택했습니다.

  • 그리고 dotenv-cli 같은 외부 패키지 없이도 Node에 내장된 process.loadEnvFile 만으로 충분히 깔끔하게 해결할 수 있었습니다.

이 모든 여정의 최종 결과물은 결국 명령어 두 개로 요약됩니다.

code
pnpm dev    # 마음껏 부숴도 되는 dev
pnpm prod   # 조심해야 하는 prod (빨간 경고와 함께)

이제 로컬에서 prisma migrate reset을 쳐도 더 이상 손이 떨리지 않습니다. 사실 그게 dev/prod를 나눈 진짜 이유였어요. 대단한 스케일링이나 성능 최적화를 위해서가 아니라, 그저 안심하고 실수할 수 있는 공간을 갖기 위해서였습니다. 사이드 프로젝트 하나에 반나절을 이 작업에 썼지만, 저는 그 시간이 전혀 아깝지 않았습니다. 오히려 진작 해둘걸 싶었어요.

혹시 지금 이 글을 읽는 분 중에, 로컬과 운영을 DB 하나로 함께 쓰고 계신 분이 있다면 — 부디 늦기 전에 나눠두시길 권합니다. 제가 겪었던 그 등골 서늘한 순간이, 여러분에게는 찾아오지 않도록요.

목차