시리즈 목차 1편 – Supabase란 무엇인가? Firebase와 제대로 비교해보기 … 9편 – Cron Jobs & Queues — 백그라운드 작업 자동화 10편 – MCP 서버 연동 — AI 에이전트와 Supabase 연결하기 11편 👉 로컬 개발 환경 & CLI — 프로 개발자의 워크플로우 (현재 글) 12편 – Analytics Buckets — 대용량 분석 워크로드 …
들어가며
많은 개발자가 Supabase 대시보드에서 직접 테이블을 만들고 SQL을 실행하는 방식으로 시작합니다. 빠르고 편리하지만, 팀 협업과 프로덕션 배포 관점에서는 큰 문제가 생깁니다.
- 동료는 어떤 테이블이 있는지 모른다
- 로컬 개발 환경이 프로덕션과 다르다
- 롤백이 필요할 때 무엇을 되돌려야 하는지 모른다
- 스테이징 환경을 만들 때 처음부터 다시 설정해야 한다
Supabase CLI는 이 모든 문제를 해결합니다. 로컬에서 완전한 Supabase 스택을 실행하고, 모든 DB 변경을 마이그레이션 파일로 추적하며, Git과 함께 버전 관리합니다.
이번 편에서 다룰 내용:
- Supabase CLI 설치 및 초기 설정
- 로컬 스택 실행 (
supabase start) - 마이그레이션 워크플로우 (생성 → 적용 → 원격 배포)
- 시드(Seed) 데이터 관리
- TypeScript 타입 자동 생성
- 프로젝트 연결 및 원격 배포
- GitHub Actions CI/CD 파이프라인
- DB 브랜치 (Preview Branch)
- 실전 Next.js 프로젝트 폴더 구조
Supabase CLI 설치
# macOS (Homebrew)
brew install supabase/tap/supabase
# Windows (Scoop)
scoop bucket add supabase https://github.com/supabase/scoop-bucket.git
scoop install supabase
# npm (크로스 플랫폼)
npm install -g supabase
# 버전 확인
supabase --version
# supabase 2.x.x
# 업그레이드
brew upgrade supabase
# 또는
npm update -g supabase
⚠️ Docker Desktop 필요: 로컬 스택은 Docker 컨테이너로 동작합니다. docker.com에서 설치 후 실행하세요.
프로젝트 초기화
신규 프로젝트
# Next.js 프로젝트 생성
npx create-next-app@latest my-app --typescript --tailwind --app
cd my-app
# Supabase 초기화
supabase init
# "Generate VS Code settings for Deno? [y/N]" → y (Edge Functions 개발 시 권장)
# 생성된 폴더 구조:
# supabase/
# config.toml ← 로컬 설정 파일
# .gitignore ← 시크릿 파일 제외
# migrations/ ← 마이그레이션 파일들
# seed.sql ← 시드 데이터
# functions/ ← Edge Functions
config.toml 주요 설정
# supabase/config.toml
[api]
enabled = true port = 54321 schemas = [“public”, “graphql_public”]
[db]
port = 54322 shadow_port = 54320
[studio]
enabled = true port = 54323
[inbucket]
# 로컬 이메일 테스트 서버 enabled = true port = 54324
[auth]
enabled = true # 로컬 환경에서 이메일 인증 없이 바로 로그인 site_url = “http://localhost:3000” additional_redirect_urls = [“http://localhost:3000/**”]
[auth.email]
enable_confirmations = false # 개발 중 이메일 인증 비활성화
[auth.external.google]
enabled = true client_id = “env(GOOGLE_CLIENT_ID)” secret = “env(GOOGLE_CLIENT_SECRET)”
로컬 스택 실행
# 로컬 Supabase 시작 (첫 실행은 Docker 이미지 다운로드로 수분 소요)
supabase start
# 출력 예시:
# Started supabase local development setup.
#
# API URL: http://localhost:54321
# GraphQL URL: http://localhost:54321/graphql/v1
# S3 Storage URL: http://localhost:54321/storage/v1/s3
# DB URL: postgresql://postgres:postgres@localhost:54322/postgres
# Studio URL: http://localhost:54323
# Inbucket URL: http://localhost:54324
# JWT secret: super-secret-jwt-token-with-at-least-32-characters-long
# anon key: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
# service_role key: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
로컬 환경 변수 설정
# .env.local
NEXT_PUBLIC_SUPABASE_URL=http://localhost:54321
NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
SUPABASE_SERVICE_ROLE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
💡
supabase start출력 값이 매번 같습니다. 로컬 개발 중 고정된 키를 사용해도 됩니다.
주요 명령어
supabase start # 로컬 스택 시작
supabase stop # 중지 (데이터 유지)
supabase stop --backup # 중지 + 데이터 백업
supabase status # 현재 상태 및 URL/키 출력
supabase db reset # DB 초기화 + 마이그레이션 재적용 + 시드 데이터
마이그레이션 워크플로우
마이그레이션이란?
마이그레이션은 DB 스키마 변경 이력을 SQL 파일로 관리하는 방식입니다. 각 파일은 타임스탬프가 붙어 순서대로 적용됩니다.
supabase/migrations/
20240101000000_init.sql
20240115120000_add_posts.sql
20240202090000_add_comments.sql
20240301140000_add_rls_policies.sql
방식 1: SQL 직접 작성 (권장)
# 새 마이그레이션 파일 생성
supabase migration new create_posts_table
# → supabase/migrations/20240301000000_create_posts_table.sql 생성
-- supabase/migrations/20240301000000_create_posts_table.sql
CREATE TABLE posts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
title TEXT NOT NULL,
content TEXT,
published BOOLEAN DEFAULT false,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- 자동 updated_at 트리거
CREATE OR REPLACE FUNCTION update_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER posts_updated_at
BEFORE UPDATE ON posts
FOR EACH ROW
EXECUTE FUNCTION update_updated_at();
-- 인덱스
CREATE INDEX idx_posts_user_id ON posts(user_id);
CREATE INDEX idx_posts_created_at ON posts(created_at DESC);
-- RLS
ALTER TABLE posts ENABLE ROW LEVEL SECURITY;
CREATE POLICY "공개 게시글 읽기"
ON posts FOR SELECT
TO public
USING (published = true);
CREATE POLICY "본인 게시글 전체 접근"
ON posts FOR ALL
TO authenticated
USING ((SELECT auth.uid()) = user_id)
WITH CHECK ((SELECT auth.uid()) = user_id);
# 마이그레이션 적용
supabase db reset
# 또는 특정 마이그레이션만 적용
supabase migration up
방식 2: 대시보드 → Diff 생성
대시보드(로컬 Studio)에서 테이블을 만든 후 CLI로 차이를 감지합니다.
# 로컬 Studio(http://localhost:54323)에서 테이블 생성 후:
supabase db diff -f add_categories_table
# → supabase/migrations/[timestamp]_add_categories_table.sql 자동 생성
마이그레이션 목록 확인
# 적용된 마이그레이션 목록
supabase migration list
# LOCAL REMOTE TIME (UTC)
# ──────────────────────────────────────────────────────────────
# 20240301000000 20240301000000 2024-03-01 00:00:00
# 20240315120000 20240315120000 2024-03-15 12:00:00
# 20240401090000 ← 아직 원격에 적용 안 됨
시드(Seed) 데이터
시드 파일은 supabase start 및 supabase db reset 시 자동으로 실행됩니다.
-- supabase/seed.sql
-- 테스트 사용자 (auth.users에 직접 삽입 — 로컬 전용)
INSERT INTO auth.users (id, email, created_at, updated_at, raw_app_meta_data, raw_user_meta_data, is_super_admin, role)
VALUES
('00000000-0000-0000-0000-000000000001', 'alice@example.com', NOW(), NOW(),
'{"provider":"email","providers":["email"]}',
'{"name":"Alice"}', false, 'authenticated'),
('00000000-0000-0000-0000-000000000002', 'bob@example.com', NOW(), NOW(),
'{"provider":"email","providers":["email"]}',
'{"name":"Bob"}', false, 'authenticated')
ON CONFLICT (id) DO NOTHING;
-- 프로필 데이터
INSERT INTO profiles (id, name, avatar_url)
VALUES
('00000000-0000-0000-0000-000000000001', 'Alice', null),
('00000000-0000-0000-0000-000000000002', 'Bob', null)
ON CONFLICT (id) DO NOTHING;
-- 테스트 게시글
INSERT INTO posts (id, user_id, title, content, published)
VALUES
(gen_random_uuid(), '00000000-0000-0000-0000-000000000001',
'Hello World', 'First post content', true),
(gen_random_uuid(), '00000000-0000-0000-0000-000000000001',
'Draft Post', 'Draft content', false),
(gen_random_uuid(), '00000000-0000-0000-0000-000000000002',
'Bob''s Post', 'Bob''s content', true);
여러 시드 파일 구성
# supabase/config.toml
[db.seed]
sql_paths = [ “./seed/01_users.sql”, “./seed/02_categories.sql”, “./seed/03_posts.sql”, “./seed/04_comments.sql”, ]
TypeScript 타입 자동 생성
스키마가 변경될 때마다 TypeScript 타입을 재생성해 타입 안전성을 유지합니다.
# 로컬 DB 기준으로 타입 생성
supabase gen types typescript --local > src/lib/database.types.ts
# 원격 프로젝트 기준으로 생성
supabase gen types typescript --project-id [project-ref] > src/lib/database.types.ts
package.json 스크립트 등록
{
"scripts": {
"dev": "next dev",
"types": "supabase gen types typescript --local > src/lib/database.types.ts",
"types:remote": "supabase gen types typescript --project-id $SUPABASE_PROJECT_REF > src/lib/database.types.ts",
"db:reset": "supabase db reset",
"db:diff": "supabase db diff"
}
}
생성된 타입 활용
// src/lib/database.types.ts (자동 생성 — 수정 금지)
export type Database = {
public: {
Tables: {
posts: {
Row: {
id: string
user_id: string
title: string
content: string | null
published: boolean
created_at: string
updated_at: string
}
Insert: {
id?: string
user_id: string
title: string
content?: string | null
published?: boolean
created_at?: string
updated_at?: string
}
Update: {
id?: string
user_id?: string
title?: string
// ...
}
}
// 다른 테이블들...
}
}
}
// 타입이 적용된 Supabase 클라이언트
// src/lib/supabase/client.ts
import { createBrowserClient } from '@supabase/ssr'
import type { Database } from '@/lib/database.types'
export function createClient() {
return createBrowserClient<Database>(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
)
}
// 사용 예시 — 완전한 타입 추론
const { data: posts } = await supabase
.from('posts')
.select('id, title, published')
// posts의 타입: { id: string; title: string; published: boolean }[] | null
원격 프로젝트 연결 및 배포
프로젝트 연결
# Supabase 로그인
supabase login
# 프로젝트 연결
supabase link --project-ref [project-ref]
# 대시보드 URL: https://app.supabase.com/project/[project-ref]
# project-ref = URL의 영문+숫자 코드
# 연결 상태 확인
supabase status
원격 DB에 마이그레이션 배포
# 원격에 적용되지 않은 마이그레이션만 실행
supabase db push
# 출력:
# Applying migration 20240401090000_add_tags.sql...
# Finished supabase db push.
# 마이그레이션 상태 재확인
supabase migration list
원격 스키마 → 로컬 마이그레이션으로 가져오기
기존 원격 프로젝트를 로컬 개발로 전환할 때 사용합니다.
# 원격 스키마를 마이그레이션 파일로 저장
supabase db pull
# → supabase/migrations/[timestamp]_remote_schema.sql 생성
# 시드 데이터 없이 로컬 초기화
supabase db reset --no-seed
GitHub Actions CI/CD 파이프라인
스테이징 자동 배포
# .github/workflows/staging.yml
name: Deploy to Staging
on:
push:
branches: [develop]
jobs:
deploy:
runs-on: ubuntu-latest
env:
SUPABASE_ACCESS_TOKEN: ${{ secrets.SUPABASE_ACCESS_TOKEN }}
SUPABASE_DB_PASSWORD: ${{ secrets.STAGING_DB_PASSWORD }}
PROJECT_ID: ${{ vars.STAGING_PROJECT_ID }}
steps:
- uses: actions/checkout@v4
- uses: supabase/setup-cli@v1
with:
version: latest
- name: Link Supabase project
run: supabase link --project-ref $PROJECT_ID
- name: Run migrations
run: supabase db push
- name: Deploy Edge Functions
run: supabase functions deploy --project-ref $PROJECT_ID
프로덕션 배포 (PR 머지 시)
# .github/workflows/production.yml
name: Deploy to Production
on:
push:
branches: [main]
jobs:
migrate:
runs-on: ubuntu-latest
env:
SUPABASE_ACCESS_TOKEN: ${{ secrets.SUPABASE_ACCESS_TOKEN }}
SUPABASE_DB_PASSWORD: ${{ secrets.PROD_DB_PASSWORD }}
PROJECT_ID: ${{ vars.PROD_PROJECT_ID }}
steps:
- uses: actions/checkout@v4
- uses: supabase/setup-cli@v1
with:
version: latest
- name: Link to production
run: supabase link --project-ref $PROJECT_ID
- name: Apply pending migrations
run: supabase db push --include-all
- name: Regenerate types (아티팩트로 저장)
run: |
supabase gen types typescript \
--project-id $PROJECT_ID \
> database.types.ts
- uses: actions/upload-artifact@v4
with:
name: database-types
path: database.types.ts
DB 브랜치 (Preview Branch)
Pro 플랜 이상에서 사용 가능한 DB 브랜치는 Git 브랜치처럼 DB 변경사항을 격리합니다. Pull Request마다 독립적인 DB 환경을 만들어 안전하게 테스트할 수 있습니다.
# 브랜치 생성
supabase branches create feature/add-tags
# 브랜치 목록
supabase branches list
# 현재 브랜치 DB URL 확인
supabase branches get feature/add-tags
# 브랜치에 마이그레이션 적용
supabase db push --branch feature/add-tags
# 브랜치를 메인으로 병합
supabase branches merge feature/add-tags
# 브랜치 삭제
supabase branches delete feature/add-tags
GitHub Actions with 브랜치
# PR 생성 시 Preview Branch 자동 생성
on:
pull_request:
types: [opened, synchronize]
jobs:
preview:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: supabase/setup-cli@v1
- name: Create preview branch
run: |
supabase branches create pr-${{ github.event.pull_request.number }} \
--project-ref ${{ vars.PROD_PROJECT_ID }}
- name: Apply migrations to branch
run: |
supabase db push \
--branch pr-${{ github.event.pull_request.number }}
권장 프로젝트 폴더 구조
my-next-app/
├── src/
│ ├── app/
│ │ ├── (auth)/
│ │ │ ├── login/page.tsx
│ │ │ └── signup/page.tsx
│ │ ├── (dashboard)/
│ │ │ └── posts/page.tsx
│ │ ├── auth/callback/route.ts ← OAuth 콜백
│ │ └── layout.tsx
│ ├── lib/
│ │ ├── supabase/
│ │ │ ├── client.ts ← 클라이언트 컴포넌트용
│ │ │ ├── server.ts ← 서버 컴포넌트/Action용
│ │ │ └── middleware.ts ← middleware.ts에서 사용
│ │ └── database.types.ts ← 자동 생성 (수정 금지)
│ └── middleware.ts
│
├── supabase/
│ ├── config.toml ← 로컬 설정
│ ├── .gitignore
│ ├── migrations/
│ │ ├── 20240101000000_init.sql
│ │ ├── 20240201000000_add_posts.sql
│ │ └── 20240301000000_add_rls.sql
│ ├── seed/
│ │ ├── 01_users.sql
│ │ ├── 02_posts.sql
│ │ └── 03_comments.sql
│ └── functions/
│ ├── _shared/
│ │ ├── cors.ts
│ │ └── supabase.ts
│ ├── send-email/
│ │ └── index.ts
│ └── stripe-webhook/
│ └── index.ts
│
├── .env.local ← 로컬 환경 변수 (gitignore)
├── .env.example ← 환경 변수 템플릿 (git 포함)
└── package.json
.env.example 관리
# .env.example (git에 포함 — 실제 값 없이 키 이름만)
NEXT_PUBLIC_SUPABASE_URL=
NEXT_PUBLIC_SUPABASE_ANON_KEY=
SUPABASE_SERVICE_ROLE_KEY=
OPENAI_API_KEY=
RESEND_API_KEY=
STRIPE_SECRET_KEY=
STRIPE_WEBHOOK_SECRET=
자주 쓰는 CLI 명령어 치트시트
# ─── 로컬 스택 ───────────────────────────────────────
supabase start # 시작
supabase stop # 중지
supabase status # 상태 및 키 출력
supabase db reset # DB 초기화 + 마이그레이션 + 시드 재실행
# ─── 마이그레이션 ────────────────────────────────────
supabase migration new [name] # 새 마이그레이션 파일 생성
supabase migration list # 마이그레이션 목록 (로컬/원격 적용 상태)
supabase migration up # 미적용 마이그레이션 적용
supabase db diff -f [name] # 현재 변경 내용을 마이그레이션 파일로 저장
supabase db push # 원격에 마이그레이션 배포
supabase db pull # 원격 스키마 가져오기
# ─── 타입 생성 ───────────────────────────────────────
supabase gen types typescript --local > src/lib/database.types.ts
# ─── Edge Functions ──────────────────────────────────
supabase functions new [name] # 새 함수 생성
supabase functions serve # 로컬 서빙
supabase functions deploy [name] # 배포
supabase functions logs [name] # 로그 확인
# ─── 프로젝트 관리 ───────────────────────────────────
supabase login # 로그인
supabase link --project-ref [ref] # 원격 프로젝트 연결
supabase projects list # 프로젝트 목록
# ─── 브랜치 (Pro 이상) ───────────────────────────────
supabase branches create [name]
supabase branches list
supabase branches merge [name]
supabase branches delete [name]
자주 하는 실수
1. 대시보드에서 직접 스키마 변경
❌ 원격 대시보드에서 직접 테이블 생성/변경
→ 로컬 마이그레이션 이력과 불일치 발생
✅ 항상 로컬에서 마이그레이션 파일 생성 → supabase db push
✅ 긴급 수정이 필요하면 supabase db pull로 원격 변경 내용을 로컬로 가져오기
2. 시드 파일에 스키마 변경 포함
-- ❌ seed.sql에 CREATE TABLE 포함 → 마이그레이션과 충돌
CREATE TABLE posts (...);
INSERT INTO posts ...;
-- ✅ seed.sql은 데이터 삽입만
INSERT INTO posts (id, title) VALUES (...);
3. .env.local을 git에 커밋
# .gitignore 확인
.env.local
.env.*.local
# ✅ .env.example만 git에 포함 (실제 키 없이 키 이름만)
4. 마이그레이션 파일 직접 수정
이미 원격에 적용된 마이그레이션 파일을 수정하면 이후 배포에서 오류가 발생합니다.
❌ 기존 마이그레이션 파일 내용 수정
✅ 새 마이그레이션 파일을 추가해 변경 내용 적용
마치며
Supabase CLI와 마이그레이션 워크플로우는 처음에는 다소 번거로워 보이지만, 팀 규모가 커지고 서비스가 성장할수록 그 가치가 빛납니다. 모든 스키마 변경이 코드로 추적되고, 어떤 환경에서도 동일한 DB 상태를 재현할 수 있으며, CI/CD와 통합해 자동 배포까지 가능합니다.
다음 편에서는 Analytics Buckets를 다룹니다. Supabase의 컬럼 지향 스토리지를 활용해 대용량 로그 데이터, 이벤트 데이터를 효율적으로 저장하고 쿼리하는 방법을 살펴봅니다.