dev.syw

로컬-스테이징-운영 환경 분리, 마이그레이션 자동화, 백업·모니터링·비용까지 프로덕션 운영 전반을 설계한다.

운영, CI/CD, 환경 관리

코드를 짜는 것과 서비스를 "운영"하는 것은 다른 차원의 문제입니다. 입문편에서 Supabase 프로젝트를 생성하고 Edge Function을 단발성으로 배포하는 방법은 다뤘지만, 실제 프로덕션 환경에서는 로컬 개발·스테이징·운영을 명확히 분리하고, 스키마 변경을 안전하게 롤링하며, 장애 상황에서 신속하게 복구할 수 있어야 합니다.

이 레슨은 Supabase CLI를 중심으로 다중 환경 분리, 마이그레이션 자동화, GitHub Actions CI/CD 파이프라인, 백업·PITR, 로깅·모니터링, 그리고 비용 관리까지 지속 운영에 필요한 모든 요소를 체계적으로 다룹니다.

학습 목표

  • Supabase CLIsupabase link를 이용해 로컬·스테이징·운영 환경을 분리하는 방법을 이해한다.
  • supabase db diff, supabase db push, seed.sql로 구성된 마이그레이션 워크플로를 설계한다.
  • GitHub Actions로 마이그레이션과 Edge Function 배포를 자동화하는 CI/CD 파이프라인을 구축한다.
  • **PITR(Point-in-Time Recovery)**과 수동 백업으로 장애 복구 전략을 수립한다.
  • 로그·메트릭·알림 체계를 갖춰 운영 모니터링과 사고 대응 능력을 확보한다.

1. 다중 환경 분리 — 로컬·스테이징·운영

왜 환경을 분리해야 하는가

운영 DB에 직접 마이그레이션을 실행하는 팀이 종종 있지만, 이는 되돌리기 어려운 장애의 원인이 됩니다. 환경 분리는 "변경을 검증할 공간"을 확보하는 것입니다.

로컬 (개발자 PC)
  └─ supabase start (Docker)
스테이징 (Supabase 프로젝트 — staging)
  └─ 실제 네트워크, 실제 Auth, 익명 데이터
운영 (Supabase 프로젝트 — production)
  └─ 실 트래픽, PITR 활성화, 알림 설정

로컬 환경 초기화

# 프로젝트 루트에서 한 번만 실행
supabase init

# Docker 기반 로컬 Supabase 스택 시작
supabase start

supabase start가 완료되면 로컬 Studio URL, API URL, anon key, service_role key가 출력됩니다. 이 값들을 .env.local에 저장합니다.

# .env.local — 절대로 git에 커밋하지 말 것
NEXT_PUBLIC_SUPABASE_URL=http://127.0.0.1:54321
NEXT_PUBLIC_SUPABASE_ANON_KEY=<local-anon-key>
SUPABASE_SERVICE_ROLE_KEY=<local-service-role-key>

스테이징과 운영 각각 별도 Supabase 프로젝트를 만들고, 환경마다 .env.staging, .env.production을 관리합니다.

# 스테이징 프로젝트에 연결
supabase link --project-ref <staging-project-ref>

# 운영 프로젝트로 전환할 때
supabase link --project-ref <production-project-ref>

⚠️ 주의 supabase link는 현재 디렉터리의 supabase/config.toml을 기준으로 동작합니다. 모노레포 구조라면 패키지별로 supabase/ 디렉터리를 분리하거나, 프로젝트 ref를 명시적으로 인수로 전달하세요.

config.toml로 환경별 설정 관리

supabase/config.toml은 로컬 스택의 포트, 확장(extension) 활성화 여부, Auth 설정 등을 선언합니다. 원격 환경 차이는 환경 변수로 오버라이드합니다.

# supabase/config.toml
[api]
port = 54321
schemas = ["public", "graphql_public"]
extra_search_path = ["public", "extensions"]

[db]
port = 54322
major_version = 15

[auth]
site_url = "http://localhost:3000"
additional_redirect_urls = ["https://staging.example.com", "https://example.com"]

[auth.email]
enable_signup = true
double_confirm_changes = true

2. 마이그레이션 워크플로 — db diff · db push · seed

마이그레이션 파일 생성 방식 두 가지

방식명령어적합한 상황
빈 파일 생성 후 직접 작성supabase migration new <이름>복잡한 DDL, RLS 정책 변경
로컬 변경 사항을 자동 캡처supabase db diff --use-migra -f <이름>Studio UI로 빠르게 프로토타이핑 후 캡처
# 1. 마이그레이션 파일을 직접 작성할 때
supabase migration new add_posts_table

# supabase/migrations/20240601120000_add_posts_table.sql 이 생성됨
-- supabase/migrations/20240601120000_add_posts_table.sql
create table if not exists public.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,
  body        text,
  published   boolean not null default false,
  created_at  timestamptz not null default now()
);

alter table public.posts enable row level security;

create policy "users can read their own posts"
  on public.posts for select
  using (auth.uid() = user_id);
# 2. Studio UI에서 테이블을 변경한 뒤 diff로 캡처
supabase db diff --use-migra -f add_comments_table

로컬 적용 및 검증

# 로컬 DB에 미적용 마이그레이션만 순차 실행
supabase migration up

# 또는 전체 리셋 후 재적용 (개발 중에만 사용)
supabase db reset

💡 TIP supabase db resetsupabase/seed.sql도 함께 실행합니다. 개발·테스트용 초기 데이터를 seed 파일로 관리하면, 팀원 모두가 동일한 초기 상태에서 작업할 수 있습니다.

seed.sql 작성 지침

-- supabase/seed.sql
-- 운영 환경에는 절대 적용하지 않는다
insert into public.posts (user_id, title, published) values
  ('00000000-0000-0000-0000-000000000001', '테스트 게시글 1', true),
  ('00000000-0000-0000-0000-000000000001', '테스트 게시글 2', false)
on conflict do nothing;

원격 환경으로 마이그레이션 배포

# 스테이징에 배포 (link된 프로젝트 기준)
supabase db push

# 특정 마이그레이션까지만 적용 (롤백 시나리오)
supabase migration repair --status reverted 20240601120000

⚠️ 주의 Supabase의 마이그레이션은 단방향입니다. down 마이그레이션 파일은 공식적으로 지원되지 않으므로, 컬럼 삭제 같은 파괴적 변경은 별도의 롤백 마이그레이션 파일을 미리 준비해 두세요.


3. GitHub Actions CI/CD 파이프라인

전체 파이프라인 설계

PR 오픈
  └─ [CI] 마이그레이션 lint + 로컬 db reset으로 검증
PR 머지 (main 브랜치)
  └─ [CD staging] 스테이징 환경에 마이그레이션·함수 배포
수동 승인 또는 태그(v*)
  └─ [CD production] 운영 환경에 배포

스테이징 배포 워크플로

# .github/workflows/deploy-staging.yml
name: Deploy to Staging

on:
  push:
    branches: [main]

jobs:
  deploy:
    runs-on: ubuntu-latest

    env:
      SUPABASE_ACCESS_TOKEN: ${{ secrets.SUPABASE_ACCESS_TOKEN }}
      STAGING_PROJECT_REF: ${{ secrets.STAGING_PROJECT_REF }}
      STAGING_DB_PASSWORD: ${{ secrets.STAGING_DB_PASSWORD }}

    steps:
      - uses: actions/checkout@v4

      - uses: supabase/setup-cli@v1
        with:
          version: latest

      - name: Link to staging project
        run: supabase link --project-ref "$STAGING_PROJECT_REF" --password "$STAGING_DB_PASSWORD"

      - name: Run DB migrations
        run: supabase db push --password "$STAGING_DB_PASSWORD"

      - name: Deploy Edge Functions
        run: supabase functions deploy --project-ref "$STAGING_PROJECT_REF"

운영 배포 워크플로 (수동 승인 포함)

# .github/workflows/deploy-production.yml
name: Deploy to Production

on:
  workflow_dispatch:       # 수동 트리거
  push:
    tags: ['v*']           # 버전 태그 푸시 시 자동 트리거

jobs:
  deploy:
    runs-on: ubuntu-latest
    environment: production  # GitHub Environment 보호 규칙 적용

    env:
      SUPABASE_ACCESS_TOKEN: ${{ secrets.SUPABASE_ACCESS_TOKEN }}
      PRODUCTION_PROJECT_REF: ${{ secrets.PRODUCTION_PROJECT_REF }}
      PRODUCTION_DB_PASSWORD: ${{ secrets.PRODUCTION_DB_PASSWORD }}

    steps:
      - uses: actions/checkout@v4

      - uses: supabase/setup-cli@v1
        with:
          version: latest

      - name: Link to production project
        run: supabase link --project-ref "$PRODUCTION_PROJECT_REF" --password "$PRODUCTION_DB_PASSWORD"

      - name: Run DB migrations
        run: supabase db push --password "$PRODUCTION_DB_PASSWORD"

      - name: Deploy Edge Functions
        run: supabase functions deploy --project-ref "$PRODUCTION_PROJECT_REF"

💡 TIP GitHub의 environment: production 설정에서 "Required reviewers"를 지정하면, 운영 배포 전에 팀원의 수동 승인이 필요합니다. 이 한 줄로 사람에 의한 게이트를 만들 수 있습니다.

PR 검증 워크플로

# .github/workflows/validate-migrations.yml
name: Validate Migrations

on:
  pull_request:
    paths:
      - 'supabase/migrations/**'
      - 'supabase/functions/**'

jobs:
  validate:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4

      - uses: supabase/setup-cli@v1
        with:
          version: latest

      - name: Start local Supabase
        run: supabase start

      - name: Apply all migrations
        run: supabase db reset

      - name: Check DB diff is empty (no unapplied changes)
        run: |
          DIFF=$(supabase db diff --use-migra 2>/dev/null)
          if [ -n "$DIFF" ]; then
            echo "Unapplied schema changes detected:"
            echo "$DIFF"
            exit 1
          fi

      - name: Stop local Supabase
        if: always()
        run: supabase stop

4. 백업·PITR과 장애 복구 전략

Supabase 백업 티어 비교

기능FreeProTeam/Enterprise
자동 일일 백업7일 보관30일 보관30일+ 보관
수동 백업 다운로드불가가능가능
PITR(Point-in-Time Recovery)불가애드온기본 포함
PITR 최소 단위1분1분

PITR 활성화 시점

트랜잭션이 발생하는 서비스라면 Pro 플랜에서 PITR 애드온을 활성화하는 것을 강력히 권장합니다. 일일 백업만으로는 최대 24시간치 데이터를 잃을 수 있습니다.

# Dashboard > Settings > Addons > Point in Time Recovery
# 또는 CLI로 확인
supabase projects list

수동 백업 스크립트

PITR과 별개로 주요 마이그레이션 직전에 수동 덤프를 받아 두는 것이 좋습니다.

#!/usr/bin/env bash
# scripts/backup.sh

set -euo pipefail

PROJECT_REF="${1:?프로젝트 ref를 첫 번째 인자로 전달하세요}"
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
OUTPUT_FILE="backups/backup_${PROJECT_REF}_${TIMESTAMP}.sql"

mkdir -p backups

supabase db dump \
  --project-ref "$PROJECT_REF" \
  --file "$OUTPUT_FILE"

echo "백업 완료: $OUTPUT_FILE"

복구 절차 체크리스트

장애 발생 시 패닉을 방지하려면 복구 절차를 문서화하고 정기적으로 연습해야 합니다.

1. 장애 범위 파악
   - Dashboard > Logs > Postgres Logs 에서 오류 확인
   - 영향받은 테이블/기간 특정

2. 의사결정
   - 마이그레이션 실수 → 롤백 마이그레이션 파일 즉시 배포
   - 데이터 손상 → PITR로 특정 시점 복구 요청
   - 전체 장애 → Supabase Status Page 확인 후 지원 티켓

3. PITR 복구 요청 (Dashboard)
   - Settings > Backups > Point in Time Recovery
   - 복구 목표 시각(recovery target time) 입력 후 복구 실행
   - (목표 RPO를 만족하도록 데이터 손실을 최소화하는 시점을 선택)

4. 사후 검토(Post-mortem)
   - 타임라인 문서화
   - 재발 방지 마이그레이션 또는 모니터링 추가

⚠️ 주의 PITR 복구는 현재 데이터베이스를 과거 시점으로 덮어씁니다. 복구 시작 전에 현재 상태의 덤프를 반드시 저장해 두세요.


5. 로깅·메트릭·알림으로 운영 모니터링

Supabase 내장 로그 탐색

Dashboard의 Logs 섹션은 실시간 로그 쿼리를 지원합니다. 내부적으로 Logflare를 사용하며, SQL과 유사한 문법으로 조회합니다.

-- Dashboard > Logs > API Logs
-- 최근 1시간 내 5xx 오류만 필터
select
  timestamp,
  request.method,
  request.path,
  response.status_code,
  response.origin_time
from edge_logs
where timestamp > now() - interval '1 hour'
  and response.status_code >= 500
order by timestamp desc
limit 100;
-- Postgres Logs: 슬로우 쿼리 탐지 (1초 이상)
select
  timestamp,
  event_message
from postgres_logs
where timestamp > now() - interval '1 hour'
  and event_message ilike '%duration%'
  and cast(
    regexp_replace(event_message, '.*duration: ([0-9.]+) ms.*', '\1')
    as float
  ) > 1000
order by timestamp desc;

Edge Function 로그 구조화

Edge Function 내에서 구조화된 로그를 출력하면 Logs 탭에서 필터링하기 쉬워집니다.

// supabase/functions/process-order/index.ts
import { serve } from "https://deno.land/std@0.168.0/http/server.ts";

serve(async (req) => {
  const requestId = crypto.randomUUID();

  // ✅ 구조화 로그 — JSON으로 출력하면 Dashboard에서 필드별 필터 가능
  console.log(JSON.stringify({
    level: "info",
    requestId,
    message: "주문 처리 시작",
    timestamp: new Date().toISOString(),
  }));

  try {
    // 주문 처리 로직...
    return new Response(JSON.stringify({ ok: true }), { status: 200 });
  } catch (err) {
    // ✅ 에러도 구조화 로그로
    console.error(JSON.stringify({
      level: "error",
      requestId,
      message: "주문 처리 실패",
      error: (err as Error).message,
      timestamp: new Date().toISOString(),
    }));
    return new Response(JSON.stringify({ error: "internal" }), { status: 500 });
  }
});

외부 모니터링 연동 — Datadog / Grafana

Supabase Pro 이상에서는 Metrics 엔드포인트(Prometheus 형식)를 제공합니다.

# Prometheus scrape 설정 (prometheus.yml)
# Dashboard > Settings > Metrics 에서 API 키 발급

scrape_configs:
  - job_name: 'supabase'
    metrics_path: '/customer/v1/privileged/metrics'
    params:
      apikey: ['<your-metrics-api-key>']
    static_configs:
      - targets: ['<project-ref>.supabase.co']
    scheme: https

주요 추적 메트릭:

메트릭임계값 기준알림 조건
pg_stat_activity_count커넥션 풀 한도의 80%> 80 (기본 풀 100 기준)
pg_database_size_bytesDB 용량 80%계획된 스케일업 전 알림
supabase_auth_errors_total급격한 증가5분간 100건 이상
supabase_storage_objects_count청구 한도 접근 시계획에 따라 설정

업타임 알림 — pg_cron + Slack

DB 내에서 주기적으로 핵심 지표를 점검하고 이상 시 알림을 보내는 패턴입니다.

-- pg_cron 확장 활성화 (Dashboard > Extensions)
-- 매 5분마다 활성 연결 수를 점검하고 임계값 초과 시 Edge Function 호출

select cron.schedule(
  'check-connections',
  '*/5 * * * *',
  $$
    select
      net.http_post(
        url := 'https://<project-ref>.supabase.co/functions/v1/alert-slack',
        headers := '{"Authorization": "Bearer <service-role-key>", "Content-Type": "application/json"}',
        body := json_build_object(
          'message', format('DB 연결 수 임계값 초과: %s', count),
          'count', count
        )::text
      )
    from (
      select count(*) as count
      from pg_stat_activity
      where state = 'active'
    ) stats
    where count > 80;
  $$
);

6. 비용·리소스 한도 관리와 스케일업 시점 판단

Free vs Pro 핵심 한도 비교

리소스FreePro
DB 용량500 MB8 GB (초과 $0.125/GB)
스토리지1 GB100 GB (초과 $0.021/GB)
대역폭5 GB/월250 GB/월
Edge Function 호출500,000/월2,000,000/월
MAU (Auth)50,000100,000
동시 DB 연결60200

비용 폭증 시나리오와 대응

스토리지 버킷 무한 업로드: RLS 없이 공개 업로드를 허용하면 봇에 의해 스토리지가 소진됩니다.

-- ❌ 위험: 누구나 업로드 가능
create policy "public upload"
  on storage.objects for insert
  to anon
  with check (bucket_id = 'avatars');

-- ✅ 안전: 인증된 사용자만, 자신의 폴더에만
create policy "auth upload own folder"
  on storage.objects for insert
  to authenticated
  with check (
    bucket_id = 'avatars'
    and (storage.foldername(name))[1] = auth.uid()::text
  );

Edge Function 루프 호출: 함수가 자기 자신을 재귀적으로 호출하는 버그로 호출 수가 폭증할 수 있습니다.

// ✅ 호출 상한을 명시적으로 처리
serve(async (req) => {
  const depth = parseInt(req.headers.get("x-call-depth") ?? "0");
  if (depth > 3) {
    return new Response("max depth exceeded", { status: 429 });
  }
  // ...
});

스케일업 시점 판단 기준

스케일업은 "느려졌을 때"가 아니라 "임계값에 도달하기 전"에 판단해야 합니다.

DB 용량 70% 도달
  → 오래된 로그 테이블 아카이빙 또는 Pro 업그레이드 검토

동시 연결 수 80% 상시 도달
  → 풀러(Supavisor) 트랜잭션 모드 활성화 또는 연결 풀러 튜닝

응답 시간 p99 > 500ms 지속
  → 5강(인덱싱·성능 튜닝) 기법 먼저 적용 후, 컴퓨트 업그레이드

MAU 80% 도달
  → 비활성 계정 정리 정책 수립 또는 플랜 업그레이드

💡 TIP Dashboard > Reports > Usage 페이지에서 지난 30일 리소스 사용 추이를 차트로 확인할 수 있습니다. 주 1회 확인하는 습관을 들이면 대부분의 비용 폭증을 사전에 차단할 수 있습니다.


요약

  • 환경 분리supabase link로 프로젝트 ref를 전환하는 것에서 시작하며, .env.local / .env.staging / .env.production을 깃에서 제외하고 별도 관리한다.
  • 마이그레이션 워크플로supabase migration new → 로컬 db reset 검증 → PR 머지 → CI/CD로 원격 db push 순서를 지키며, 파괴적 변경에는 롤백 마이그레이션을 준비한다.
  • GitHub Actions에서 supabase/setup-cli를 사용해 마이그레이션과 함수 배포를 자동화하고, environment: production으로 운영 배포에 수동 승인 게이트를 설정한다.
  • PITR은 Pro 플랜 애드온으로 최소 1분 단위 복구를 보장하며, 주요 변경 전에는 반드시 수동 supabase db dump를 백업으로 남긴다.
  • 로깅은 구조화된 JSON으로 출력하고, Prometheus 메트릭 엔드포인트와 외부 모니터링 도구(Grafana, Datadog)를 연동하면 임계값 기반 알림을 운영할 수 있다.
  • 비용 관리는 스토리지 RLS 정책, Edge Function 호출 상한, 주 1회 Usage 대시보드 확인으로 폭증 사고를 예방한다.

연습문제

  1. 현재 프로젝트에 comments 테이블을 추가하는 마이그레이션 파일을 작성하고, supabase db reset으로 로컬에 적용한 뒤 diff가 비어 있는지 확인하는 전체 흐름을 서술하시오.

    힌트 supabase migration new → SQL 작성 → supabase db resetsupabase db diff --use-migra 순서를 따르세요.

  2. GitHub Actions 워크플로에서 스테이징과 운영 배포를 동일한 파일로 처리하면 어떤 문제가 발생하는가? environment: 키워드를 활용해 두 환경을 분리하도록 워크플로를 재설계하시오.

    힌트 environment: production에 "Required reviewers"를 설정하면 수동 승인 게이트를 만들 수 있습니다.

  3. 아래 스토리지 정책이 왜 위험한지 설명하고, 인증된 사용자가 자신의 user_id 폴더에만 업로드할 수 있도록 수정하시오.

    create policy "open upload" on storage.objects
      for insert to anon with check (bucket_id = 'uploads');
    

    힌트 storage.foldername(name)[1]auth.uid()::text를 비교하세요.

  4. Supabase Pro 프로젝트에서 동시 DB 연결 수가 상시 80% 이상을 유지할 때, 컴퓨트를 업그레이드하기 전에 먼저 시도해야 할 두 가지 접근 방법을 설명하시오.

    힌트 풀러(Supavisor) 모드와 애플리케이션 레벨 커넥션 풀 설정을 함께 검토하세요.


💡 연습문제 풀이

불러오는 중…

함께 보면 좋은 자료

댓글 0

Supabase 심화” 강좌에 대한 댓글입니다.

댓글을 작성하려면 로그인이 필요합니다.