Backend

Supabase 무료 플랜 생존기 — RLS, 임베딩, Storage 한계 극복

Supabase 무료 플랜에서 RLS 설정 삽질, pgvector 임베딩 구축, 1GB Storage 한계를 자체 파일 서버로 우회한 경험. PGRST116 에러, 타입 캐스팅 문제까지 실전 기록.

·9 min read
#Supabase#RLS#pgvector#임베딩#Storage#보안#PostgreSQL

A digital lock representing database security

Supabase 무료 플랜의 현실

이전 글에서 Supabase 무료 플랜으로 SaaS를 운영한다고 했다. 큰 틀에서는 잘 동작하지만, 세부적으로 들어가면 삽질의 연속이었다. 특히 RLS(Row Level Security), 임베딩(pgvector), Storage 1GB 한계 — 이 세 가지에서 제대로 고생했다.

RLS — 안 하면 데이터가 전부 노출된다

Supabase에서 테이블을 만들면 기본적으로 RLS가 꺼져 있다. 이 상태에서 anon 키로 API를 호출하면? 모든 데이터를 누구나 읽을 수 있다. 다른 사용자의 프로젝트, 분석 결과, 개인정보까지 전부.

처음에 이걸 몰랐다. 개발 중에는 service_role 키만 쓰다 보니 문제를 인지하지 못했고, 프론트에서 anon 키로 바꾼 순간 다른 유저의 데이터가 보이는 걸 발견하고 식겁했다.

PGRST116 — RLS의 세례

RLS를 켰다. 그런데 policy를 안 만들고 켜면?

{
  "code": "PGRST116",
  "details": "The result contains 0 rows",
  "hint": null,
  "message": "JSON object requested, multiple (or no) rows returned"
}

PGRST116 — RLS를 켰는데 허용 policy가 없어서 모든 쿼리가 빈 결과를 반환하는 에러. 테이블 10개에 RLS를 한꺼번에 켰더니 앱 전체가 동작을 멈췄다.

각 테이블마다 SELECT, INSERT, UPDATE, DELETE policy를 만들어야 했다:

-- projects 테이블 RLS 예시
ALTER TABLE projects ENABLE ROW LEVEL SECURITY;

-- 본인 프로젝트만 조회 가능
CREATE POLICY "Users can view own projects"
  ON projects FOR SELECT
  USING (auth.uid() = user_id);

-- 본인만 생성 가능
CREATE POLICY "Users can create own projects"
  ON projects FOR INSERT
  WITH CHECK (auth.uid() = user_id);

-- 본인만 수정 가능
CREATE POLICY "Users can update own projects"
  ON projects FOR UPDATE
  USING (auth.uid() = user_id)
  WITH CHECK (auth.uid() = user_id);

-- 본인만 삭제 가능
CREATE POLICY "Users can delete own projects"
  ON projects FOR DELETE
  USING (auth.uid() = user_id);

10개 테이블 × 4개 policy = 40개의 policy를 작성했다. 단순 반복이지만 테이블마다 컬럼명이 달라서 복붙만으로는 안 됐다.

project_biomarkers의 타입 캐스팅 삽질

가장 짜증났던 건 project_biomarkers 테이블이었다. 이 테이블의 project_id가 다른 테이블은 uuid 타입인데 여기만 text 타입으로 되어 있었다. (초기 설계 실수)

-- ❌ 이렇게 하면 타입 불일치로 policy가 작동 안 함
CREATE POLICY "Users can view project biomarkers"
  ON project_biomarkers FOR SELECT
  USING (
    project_id IN (
      SELECT id FROM projects WHERE user_id = auth.uid()
    )
  );
-- ERROR: operator does not exist: text = uuid

textuuid를 직접 비교할 수 없었다:

-- ✅ 캐스팅 추가
CREATE POLICY "Users can view project biomarkers"
  ON project_biomarkers FOR SELECT
  USING (
    project_id::uuid IN (
      SELECT id FROM projects WHERE user_id = auth.uid()
    )
  );

project_id::uuid — 이 캐스팅 하나 찾는 데 반나절이 걸렸다. 에러 메시지가 "The result contains 0 rows"라서 policy 문제인지 데이터 문제인지 처음에 구분이 안 됐기 때문이다.

임베딩 — Ollama + pgvector로 시맨틱 검색

BioAI Market에는 바이오마커/질병 검색 기능이 있다. 단순 키워드 검색이 아니라, "심장 관련 바이오마커"를 검색하면 cardiac troponin, BNP, CK-MB 등이 나와야 한다. 이를 위해 **시맨틱 검색(semantic search)**을 구축했다.

임베딩 모델 선택

로컬 Ollama에서 실행할 수 있는 임베딩 모델로 nomic-embed-text를 선택했다:

  • 768 dimensions
  • 8192 토큰 context
  • MTEB 벤치마크에서 상위권
  • Ollama에서 바로 실행 가능
ollama pull nomic-embed-text

Supabase에 pgvector 활성화

-- pgvector 확장 활성화
CREATE EXTENSION IF NOT EXISTS vector;

-- 임베딩 컬럼 추가
ALTER TABLE biomarkers ADD COLUMN embedding vector(768);

-- 코사인 유사도 인덱스
CREATE INDEX ON biomarkers
  USING ivfflat (embedding vector_cosine_ops)
  WITH (lists = 100);

1141개 데이터 임베딩

바이오마커(800+)와 질병(300+) 데이터를 모두 임베딩했다:

import requests
from supabase import create_client

supabase = create_client(SUPABASE_URL, SUPABASE_SERVICE_KEY)

def get_embedding(text: str) -> list[float]:
    response = requests.post(
        f"{OLLAMA_URL}/api/embeddings",
        headers={"X-API-Key": OLLAMA_API_KEY},
        json={"model": "nomic-embed-text", "prompt": text}
    )
    return response.json()["embedding"]

# 바이오마커 임베딩
biomarkers = supabase.table("biomarkers").select("*").execute()
for bm in biomarkers.data:
    text = f"{bm['name']} {bm['description']} {bm['category']}"
    embedding = get_embedding(text)
    supabase.table("biomarkers").update(
        {"embedding": embedding}
    ).eq("id", bm["id"]).execute()

print(f"Embedded {len(biomarkers.data)} biomarkers")
# Embedded 1141 biomarkers/diseases

전체 임베딩에 약 20분이 걸렸다. RTX 3090이 있어서 빠르게 끝났다.

시맨틱 검색 함수

CREATE OR REPLACE FUNCTION match_biomarkers(
  query_embedding vector(768),
  match_threshold float DEFAULT 0.7,
  match_count int DEFAULT 10
)
RETURNS TABLE (
  id uuid,
  name text,
  description text,
  similarity float
)
LANGUAGE plpgsql
AS $$
BEGIN
  RETURN QUERY
  SELECT
    biomarkers.id,
    biomarkers.name,
    biomarkers.description,
    1 - (biomarkers.embedding <=> query_embedding) AS similarity
  FROM biomarkers
  WHERE 1 - (biomarkers.embedding <=> query_embedding) > match_threshold
  ORDER BY biomarkers.embedding <=> query_embedding
  LIMIT match_count;
END;
$$;

Storage 1GB 한계 — 자체 파일 서버로 이관

Supabase Storage 무료 한계는 1GB. 프로테오믹스 데이터 CSV 파일, 분석 결과 JSON, 리포트 PDF 등을 저장하다 보면 빠르게 찬다. 실제로 3개월 만에 800MB를 넘겼다.

유료 플랜으로 올리는 대신, RTX 3090 서버(Python 백엔드가 돌아가는 곳)에 파일을 직접 저장하기로 했다.

파일 서버 엔드포인트 구현

# storage.py
import os
import uuid
from fastapi import FastAPI, UploadFile, File, HTTPException
from fastapi.responses import FileResponse

STORAGE_PATH = "/data/storage"
os.makedirs(STORAGE_PATH, exist_ok=True)

app = FastAPI()

@app.post("/storage/upload")
async def upload_file(
    file: UploadFile = File(...),
    project_id: str = None
):
    file_id = str(uuid.uuid4())
    ext = os.path.splitext(file.filename)[1]
    save_path = os.path.join(STORAGE_PATH, project_id or "general", f"{file_id}{ext}")
    os.makedirs(os.path.dirname(save_path), exist_ok=True)

    with open(save_path, "wb") as f:
        content = await file.read()
        f.write(content)

    return {
        "fileId": file_id,
        "filename": file.filename,
        "size": len(content),
        "path": save_path
    }

@app.get("/storage/download/{project_id}/{file_id}")
async def download_file(project_id: str, file_id: str):
    # 파일 찾기
    project_dir = os.path.join(STORAGE_PATH, project_id)
    if not os.path.exists(project_dir):
        raise HTTPException(status_code=404, detail="Project not found")

    for fname in os.listdir(project_dir):
        if fname.startswith(file_id):
            return FileResponse(
                os.path.join(project_dir, fname),
                filename=fname
            )

    raise HTTPException(status_code=404, detail="File not found")

Supabase Storage에 있던 기존 파일들도 마이그레이션 스크립트를 만들어서 이관했다. Storage 사용량이 800MB → 50MB(메타데이터만)로 줄었다.

트레이드오프

Supabase Storage자체 파일 서버
용량1GB (무료)무제한 (디스크 한계)
CDN있음없음
가용성99.9%+집 서버 (정전시 다운)
인증Supabase Auth 연동직접 구현 필요

CDN이 없고 가용성이 낮지만, 사이드 프로젝트 수준에서는 충분했다. 대용량 파일은 분석 결과 다운로드 정도라 CDN이 없어도 크게 문제되지 않았다.

무료 플랜 생존 팁 정리

6개월간 Supabase 무료 플랜을 쓰면서 얻은 교훈:

  1. RLS는 Day 1부터 켜라 — 나중에 하면 policy 40개 한꺼번에 만들어야 한다
  2. 타입을 통일하라 — uuid는 uuid로, text는 text로. 섞이면 캐스팅 지옥
  3. 500MB DB 한계는 임베딩 데이터에 주의 — 768dim × 1000row만 해도 수십 MB
  4. Storage는 무겁다면 외부로 — 1GB는 금방 찬다
  5. keep-alive cron은 필수 — 7일 pause 정책을 절대 잊지 마라

참고 링크:

관련 글