Supabase 완전 정복 시리즈 11편 — 로컬 개발 환경 & CLI: 프로 개발자의 워크플로우




시리즈 목차 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 startsupabase 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의 컬럼 지향 스토리지를 활용해 대용량 로그 데이터, 이벤트 데이터를 효율적으로 저장하고 쿼리하는 방법을 살펴봅니다.




댓글 남기기