Supabase에서 프로덕션 수준의 다중 테넌트 RLS 스키마 설계

지난 2년 동안 Supabase에서 세 개의 다중 테넌트 SaaS 앱을 출시했습니다. 첫 번째는 재앙이었습니다. Supabase가 나를 실망시켜서가 아니라 행 레벨 보안(RLS)이 규모에서 쿼리 계획과 어떻게 상호작용하는지 근본적으로 오해했기 때문입니다. 두 번째는 더 나았습니다. 세 번째는 2,000개 이상의 테넌트를 처리하며 수백만 개 행이 있는 테이블에서 50ms 미만의 쿼리 시간을 달성합니다.

이 글은 첫 번째 프로젝트 전에 누군가가 나에게 말해줬으면 하는 모든 것입니다. 실제 다중 테넌트 스키마를 처음부터 구축하고, 실제로 성능을 내는 RLS 정책을 연결하며, 실제 트래픽이 데이터베이스를 타격할 때만 나타나는 엣지 케이스를 다룰 것입니다.

목차

Supabase RLS Multi-Tenant Production Schema Design Guide

Supabase의 다중 테넌시 접근 방식

한 줄의 SQL도 쓰기 전에, 세 가지 다중 테넌시 접근 방식을 명확히 하고 대부분의 Supabase 프로젝트에 어느 것이 최적인지 알아봅시다.

접근 방식 격리 수준 복잡도 테넌트당 비용 최적 용도
테넌트별 데이터베이스 최고 매우 높음 $25+/월 엔터프라이즈, 규정 준수 중심
테넌트별 스키마 높음 높음 $5-15/월 중견 SaaS
공유 스키마 + RLS 중간 중간 테넌트당 몇 센트 대부분의 SaaS 앱

테넌트별 데이터베이스는 은행이나 의료 회사에 팔 때 사용하는 것입니다. 말 그대로 별도의 인프라가 필요합니다. Supabase는 이를 쉽게 하지 않습니다 -- 여러 Supabase 프로젝트를 관리해야 합니다.

테넌트별 스키마(tenant_123.projects 같은 PostgreSQL 스키마 사용)는 매력적으로 보이지만 유지 관리의 악몽이 됩니다. 모든 마이그레이션은 모든 스키마에 대해 실행됩니다. 제가 한 번 시도했는데 400개 테넌트가 있을 때 간단한 ALTER TABLE 마이그레이션에 45분이 걸렸습니다.

RLS를 포함한 공유 스키마는 90%의 SaaS 애플리케이션에 최적입니다. 하나의 테이블 세트, 하나의 마이그레이션 세트, 그리고 PostgreSQL의 RLS 정책이 격리를 처리합니다. 이것이 우리가 구축하는 것입니다.

기초: 테넌트 및 사용자 스키마

핵심 테이블부터 시작하겠습니다. 튜토리얼 완구가 아니라 프로덕션에서 사용하는 스키마를 보여드리겠습니다.

-- UUID 생성 활성화
create extension if not exists "uuid-ossp";

-- 테넌트 (조직, 워크스페이스, 뭐라고 부르든)
create table public.tenants (
  id uuid primary key default uuid_generate_v4(),
  name text not null,
  slug text unique not null,
  plan text not null default 'free' check (plan in ('free', 'pro', 'enterprise')),
  settings jsonb not null default '{}',
  created_at timestamptz not null default now(),
  updated_at timestamptz not null default now()
);

-- 멤버십 연결: auth.users를 테넌트에 연결
create table public.tenant_memberships (
  id uuid primary key default uuid_generate_v4(),
  tenant_id uuid not null references public.tenants(id) on delete cascade,
  user_id uuid not null references auth.users(id) on delete cascade,
  role text not null default 'member' check (role in ('owner', 'admin', 'member', 'viewer')),
  created_at timestamptz not null default now(),
  
  unique(tenant_id, user_id)
);

-- 중요: RLS 정책이 활용할 인덱스
create index idx_tenant_memberships_user_id on public.tenant_memberships(user_id);
create index idx_tenant_memberships_tenant_id on public.tenant_memberships(tenant_id);
create index idx_tenant_memberships_user_tenant on public.tenant_memberships(user_id, tenant_id);

두 가지에 주목하세요. 첫째, 사용자 프로필에 tenant_id를 직접 넣는 대신 연결 테이블(tenant_memberships)을 사용합니다. 사용자는 여러 테넌트에 속할 수 있습니다 -- 거의 모든 SaaS 앱에 대한 실제 요구 사항입니다. 둘째, 이 인덱스는 선택이 아닙니다. 없으면 모든 RLS 체크가 멤버십 테이블에서 순차 스캔을 합니다. 몇 천 개의 멤버십이 있으면 이것이 쿼리에 200ms 이상을 추가하는 것을 봤습니다.

성능을 망치지 않는 RLS 정책 설계

대부분의 튜토리얼이 여기서 실패합니다. 이런 간단한 정책을 보여줍니다:

-- 프로덕션에서 이렇게 하지 마세요
create policy "사용자는 자신의 테넌트 데이터를 볼 수 있습니다"
  on public.projects for select
  using (
    tenant_id in (
      select tenant_id from public.tenant_memberships
      where user_id = auth.uid()
    )
  );

이것은 작동합니다. 테스트를 통과합니다. 그리고 배포하면 500명의 사용자를 얻고 대시보드가 4초가 걸리는 이유를 궁금해합니다.

문제는 PostgreSQL이 모든 단일 행에 대해 이 하위 쿼리를 평가한다는 것입니다. 쿼리 계획기는 때때로 이를 최적화할 수 있지만 종종 그렇지 않습니다 -- 특히 JOIN이 포함된 경우.

보안 정의자 함수 패턴

프로덕션에서 실제로 작동하는 것은 다음과 같습니다:

-- 사용자의 테넌트 ID를 반환하는 함수 생성
-- SECURITY DEFINER는 함수 생성자의 권한으로 실행됨을 의미합니다
-- 이것은 중요합니다: 멤버십 테이블 자체에서 RLS를 우회합니다
create or replace function public.get_user_tenant_ids()
returns setof uuid
language sql
security definer
stable
set search_path = public
as $$
  select tenant_id 
  from public.tenant_memberships 
  where user_id = auth.uid();
$$;

-- 이제 정책에서 사용합니다
create policy "tenant_isolation_select"
  on public.projects for select
  using (tenant_id in (select public.get_user_tenant_ids()));

create policy "tenant_isolation_insert"
  on public.projects for insert
  with check (tenant_id in (select public.get_user_tenant_ids()));

create policy "tenant_isolation_update"
  on public.projects for update
  using (tenant_id in (select public.get_user_tenant_ids()))
  with check (tenant_id in (select public.get_user_tenant_ids()));

create policy "tenant_isolation_delete"
  on public.projects for delete
  using (tenant_id in (select public.get_user_tenant_ids()));

왜 이것이 더 빠를까요? STABLE 키워드는 PostgreSQL에 함수가 단일 명령문 내에서 동일한 결과를 반환한다고 말합니다. 계획기는 한 번 호출하고 결과를 재사용할 수 있습니다. 제 벤치마크에서 이것은 여러 행을 터치하는 쿼리에서 RLS 오버헤드를 60-80% 줄입니다.

search_path 함정

함수에서 set search_path = public을 봤나요? 이것은 선택이 아닙니다. 없으면 악의적인 사용자가 auth.uid()를 차단하는 다른 스키마에 함수를 만들어 보안을 우회할 수 있습니다. Supabase 팀이 이것에 대해 썼지만 놓치기 쉽습니다.

Supabase RLS Multi-Tenant Production Schema Design Guide - architecture

tenant_id 패턴: 올바르게 하기

테넌트 특정 데이터를 보유하는 모든 테이블은 tenant_id 열이 필요합니다. 예외가 없습니다. 테이블이 다른 테넌트 범위 테이블에 대한 외래 키를 가지고 있어도. 이유는 다음과 같습니다:

-- 프로젝트 테이블
create table public.projects (
  id uuid primary key default uuid_generate_v4(),
  tenant_id uuid not null references public.tenants(id) on delete cascade,
  name text not null,
  created_at timestamptz not null default now()
);

-- 작업은 프로젝트에 속합니다. tenant_id가 여기서 중복인 것처럼 보일 수 있습니다.
create table public.tasks (
  id uuid primary key default uuid_generate_v4(),
  tenant_id uuid not null references public.tenants(id) on delete cascade,
  project_id uuid not null references public.projects(id) on delete cascade,
  title text not null,
  status text not null default 'todo',
  created_at timestamptz not null default now()
);

-- RLS + 일반적인 쿼리 패턴을 위한 인덱스
create index idx_projects_tenant on public.projects(tenant_id);
create index idx_tasks_tenant on public.tasks(tenant_id);
create index idx_tasks_project on public.tasks(project_id);
create index idx_tasks_tenant_project on public.tasks(tenant_id, project_id);

네, taskstenant_id는 비정규화입니다. 네, 올바른 선택입니다. 없으면 tasks의 RLS 정책이 테넌트를 확인하기 위해 projects에 JOIN해야 합니다 -- 그리고 그 JOIN은 모든 쿼리에서 발생합니다. 비정규화된 tenant_id로는 RLS 체크가 간단한 인덱스 조회입니다.

트리거로 일관성을 강제합니다:

create or replace function public.verify_tenant_consistency()
returns trigger
language plpgsql
as $$
begin
  if NEW.tenant_id != (
    select tenant_id from public.projects where id = NEW.project_id
  ) then
    raise exception 'tenant_id mismatch: task tenant does not match project tenant';
  end if;
  return NEW;
end;
$$;

create trigger check_task_tenant
  before insert or update on public.tasks
  for each row execute function public.verify_tenant_consistency();

JWT 클레임 vs 데이터베이스 조회

Supabase를 사용하면 JWT 토큰에 사용자 정의 클레임을 포함할 수 있습니다. 일부는 이를 사용하여 현재 테넌트 ID를 저장합니다:

-- JWT에서 읽기 (빠르지만 주의 사항이 있습니다)
auth.jwt() -> 'app_metadata' ->> 'current_tenant_id'

매번 데이터베이스에서 조회하는 것과 비교:

-- 데이터베이스 조회 (느리지만 항상 최신)
select tenant_id from tenant_memberships where user_id = auth.uid()
측면 JWT 클레임 데이터베이스 조회
속도 ~0.1ms ~1-5ms
최신성 토큰 갱신까지 오래됨 항상 최신
다중 테넌트 전환 토큰 갱신 필요 즉시
취소 시 보안 JWT 만료까지 지연 즉시
구현 복잡도 더 높음 (Edge Function 필요) 더 낮음

제 추천: 대부분의 앱에 대해 보안 정의자 함수 패턴으로 데이터베이스 조회를 사용합니다. 적절한 인덱스가 있을 때 성능 차이는 무시할 수 있으며, 오래된 토큰 주변의 전체 버그 클래스를 피합니다.

10,000명 이상의 동시 사용자를 서빙하고 밀리초를 깎는 것이 중요하면, 활성 테넌트 ID를 JWT에 넣으세요. 사용자가 테넌트를 전환할 때 클레임을 설정하기 위해 Supabase Edge Function (또는 훅)이 필요하며, 클라이언트의 토큰 갱신 흐름을 처리해야 합니다.

교차 테넌트 작업 처리

일부 작업은 합법적으로 테넌트 경계를 넘어야 합니다. 관리자 대시보드, 청구 시스템, 분석 집계. 이를 안전하게 처리하는 방법은 다음과 같습니다.

서비스 역할 키 (신중하게 사용)

Supabase 서비스 역할 키는 RLS를 완전히 우회합니다. 서버 측 코드에만 사용합니다 -- 절대 클라이언트에 노출하지 마세요.

// 서버 측만 (Next.js API 경로, Edge Function 등)
import { createClient } from '@supabase/supabase-js';

const supabaseAdmin = createClient(
  process.env.NEXT_PUBLIC_SUPABASE_URL!,
  process.env.SUPABASE_SERVICE_ROLE_KEY! // 절대 노출하지 마세요
);

// 이것은 RLS를 우회합니다 - 극도로 신중하게 사용합니다
const { data } = await supabaseAdmin
  .from('tenants')
  .select('id, name, plan')
  .eq('plan', 'enterprise');

Next.js에서 구축하는 경우 (우리는 이것을 많이 합니다), API 경로와 Server Components가 서비스 역할 작업을 위한 올바른 장소입니다.

제어된 교차 테넌트 액세스를 위한 데이터베이스 함수

더 세분화된 제어를 위해 특정 함수를 만듭니다:

create or replace function public.admin_get_tenant_stats(target_tenant_id uuid)
returns json
language plpgsql
security definer
set search_path = public
as $$
declare
  result json;
  caller_role text;
begin
  -- 호출자가 대상 테넌트의 관리자인지 확인합니다
  select role into caller_role
  from tenant_memberships
  where user_id = auth.uid() and tenant_id = target_tenant_id;
  
  if caller_role not in ('owner', 'admin') then
    raise exception 'Unauthorized: requires admin role';
  end if;
  
  select json_build_object(
    'project_count', (select count(*) from projects where tenant_id = target_tenant_id),
    'task_count', (select count(*) from tasks where tenant_id = target_tenant_id),
    'member_count', (select count(*) from tenant_memberships where tenant_id = target_tenant_id)
  ) into result;
  
  return result;
end;
$$;

마이그레이션 전략 및 스키마 진화

기존 테이블에 tenant_id를 추가하는 것은 모두가 두려워하는 마이그레이션입니다. 가동 중지 시간을 최소화하는 접근 방식은 다음과 같습니다:

-- 1단계: 널 허용 열 추가
alter table public.some_existing_table 
  add column tenant_id uuid references public.tenants(id);

-- 2단계: 역채우기 (큰 테이블의 경우 배치로 수행)
update public.some_existing_table set tenant_id = (
  select tenant_id from public.projects 
  where projects.id = some_existing_table.project_id
)
where tenant_id is null;

-- 3단계: NOT NULL 제약 조건 추가
alter table public.some_existing_table 
  alter column tenant_id set not null;

-- 4단계: 인덱스 추가
create index concurrently idx_some_table_tenant 
  on public.some_existing_table(tenant_id);

-- 5단계: RLS 활성화 및 정책 추가
alter table public.some_existing_table enable row level security;

create policy "tenant_isolation" on public.some_existing_table
  for all using (tenant_id in (select public.get_user_tenant_ids()));

인덱스 생성에서 concurrently 키워드는 중요합니다 -- 없으면 전체 인덱스 빌드 동안 테이블을 잠급니다. 백만 행이 있는 테이블에서는 몇 분의 가동 중지 시간이 될 수 있습니다.

성능 벤치마크 및 최적화

Supabase Pro 플랜($25/월, 2025년 초 기준)에서 tasks 테이블의 500,000행을 1,000개 테넌트에 걸쳐 벤치마크했습니다.

쿼리 패턴 RLS 없음 순진한 RLS 최적화된 RLS 오버헤드
50행 선택 (단일 테넌트) 2.1ms 18.4ms 3.8ms +81%
단일 행 삽입 1.2ms 4.1ms 1.9ms +58%
필터 포함 계산 3.4ms 22.1ms 5.2ms +53%
2개 테이블 JOIN 4.8ms 45.2ms 8.1ms +69%

"최적화된 RLS"는 다음을 의미합니다: 보안 정의자 함수, 적절한 복합 인덱스, tenant_id 자식 테이블에 비정규화, 그리고 STABLE 함수 변동성.

순진한 접근 (인라인 하위 쿼리, 누락된 인덱스)은 RLS를 느리게 만듭니다. 최적화하면 오버헤드는 프로덕션 워크로드에 완전히 수용 가능합니다.

추가 최적화 팁

  1. 복합 인덱스 사용: 선행 열로 tenant_id: create index on tasks(tenant_id, status, created_at)
  2. 대용량 테이블 분할: tenant_id 기준 분할 (단일 테넌트가 수백만 행을 가진 경우)
  3. pg_stat_statements 사용: 느린 쿼리 찾기 -- Supabase는 대시보드의 Database > Query Performance에 노출합니다
  4. 물리화된 뷰 고려: 실시간 비용이 많은 집계를 실행하는 대신 교차 테넌트 분석

프로덕션 보안 강화

RLS가 주요 방어선이지만 유일한 방어선이 아니어야 합니다.

1. 기본 거부

항상 기본 정책 없이 RLS를 활성화합니다 -- 새 테이블에 정책을 추가하는 것을 잊으면 완전히 열려 있는 대신 잠금 상태입니다.

alter table public.new_table enable row level security;
-- 액세스 패턴을 생각해본 후에만 허용 정책을 추가합니다

2. 감사 로깅

create table public.audit_log (
  id bigint generated always as identity primary key,
  tenant_id uuid not null,
  user_id uuid,
  action text not null,
  table_name text not null,
  record_id uuid,
  old_data jsonb,
  new_data jsonb,
  created_at timestamptz not null default now()
);

-- 일반 감사 트리거
create or replace function public.audit_trigger_func()
returns trigger
language plpgsql
security definer
set search_path = public
as $$
begin
  if TG_OP = 'DELETE' then
    insert into audit_log(tenant_id, user_id, action, table_name, record_id, old_data)
    values (OLD.tenant_id, auth.uid(), TG_OP, TG_TABLE_NAME, OLD.id, to_jsonb(OLD));
    return OLD;
  else
    insert into audit_log(tenant_id, user_id, action, table_name, record_id, new_data, old_data)
    values (
      NEW.tenant_id, auth.uid(), TG_OP, TG_TABLE_NAME, NEW.id, 
      to_jsonb(NEW),
      case when TG_OP = 'UPDATE' then to_jsonb(OLD) else null end
    );
    return NEW;
  end if;
end;
$$;

3. 에지에서 속도 제한

RLS는 데이터 유출을 방지하지만 악용을 방지하지는 않습니다. Supabase Edge Functions 또는 Next.js 미들웨어를 속도 제한에 사용합니다. Astro를 사용하여 구축하는 경우, 서버 엔드포인트에서 이를 처리할 수 있습니다.

4. 정책 테스트

실제 테스트를 작성합니다. Supabase의 로컬 개발 환경(supabase start)을 사용하고 다음을 테스트합니다:

  • 사용자 A는 사용자 B의 테넌트 데이터를 볼 수 없습니다
  • 멤버십 제거는 즉시 액세스를 취소합니다
  • 서비스 역할이 올바르게 RLS를 우회합니다
  • insert/update 정책이 tenant_id 조작을 방지합니다
// Vitest를 사용한 예제 테스트
import { describe, it, expect } from 'vitest';
import { createClient } from '@supabase/supabase-js';

describe('RLS 정책', () => {
  it('교차 테넌트 데이터 액세스를 방지해야 합니다', async () => {
    const userA = createClient(SUPABASE_URL, SUPABASE_ANON_KEY, {
      global: { headers: { Authorization: `Bearer ${tokenA}` } }
    });
    
    const { data, error } = await userA
      .from('projects')
      .select('*')
      .eq('tenant_id', TENANT_B_ID);
    
    expect(data).toHaveLength(0); // RLS는 이것들을 필터링해야 합니다
  });
});

실제 스키마 예제

프로젝트 관리 SaaS를 위한 축약하지만 완전한 스키마입니다. 이것은 프로덕션에 배포한 것과 가깝습니다.

-- 모든 테이블에서 RLS 활성화
alter table public.tenants enable row level security;
alter table public.tenant_memberships enable row level security;
alter table public.projects enable row level security;
alter table public.tasks enable row level security;

-- 테넌트: 사용자는 자신이 속한 테넌트를 볼 수 있습니다
create policy "view_own_tenants" on public.tenants for select
  using (id in (select public.get_user_tenant_ids()));

-- 소유자만 테넌트 설정을 업데이트할 수 있습니다
create policy "owners_update_tenants" on public.tenants for update
  using (
    id in (
      select tenant_id from public.tenant_memberships
      where user_id = auth.uid() and role = 'owner'
    )
  );

-- 멤버십: 멤버는 자신의 테넌트에서 다른 멤버를 볼 수 있습니다
create policy "view_tenant_members" on public.tenant_memberships for select
  using (tenant_id in (select public.get_user_tenant_ids()));

-- 관리자+만 멤버십을 관리할 수 있습니다
create policy "admins_manage_members" on public.tenant_memberships for insert
  with check (
    tenant_id in (
      select tenant_id from public.tenant_memberships
      where user_id = auth.uid() and role in ('owner', 'admin')
    )
  );

-- 프로젝트 & 작업: 표준 테넌트 격리
create policy "tenant_projects" on public.projects for all
  using (tenant_id in (select public.get_user_tenant_ids()))
  with check (tenant_id in (select public.get_user_tenant_ids()));

create policy "tenant_tasks" on public.tasks for all
  using (tenant_id in (select public.get_user_tenant_ids()))
  with check (tenant_id in (select public.get_user_tenant_ids()));

이와 같은 것을 구축하고 전에 이것을 한 팀을 원한다면, headless CMS 개발 작업 확인 -- 우리는 여러 클라이언트에 대해 Supabase 백엔드를 headless 프론트엔드와 쌍으로 지었습니다.

FAQ

Supabase RLS가 쿼리에 상당한 지연 시간을 추가합니까?

최적화된 정책 (보안 정의자 함수, 적절한 인덱스, tenant_id 자식 테이블에 비정규화, STABLE 함수 변동성)으로, 오버헤드는 일반적으로 원시 쿼리 시간의 50-80%입니다. RLS 없이 3ms가 걸리는 쿼리의 경우 5-6ms를 예상합니다. 이것은 프로덕션 사용에 완전히 수용 가능합니다. 순진한 접근 (인라인 하위 쿼리, 누락된 인덱스)은 오버헤드를 10-20배 추가할 수 있습니다. 최적화가 중요한 이유입니다.

Supabase Realtime 구독에서 RLS를 사용할 수 있습니까?

네. Supabase Realtime은 RLS 정책을 존중합니다. 클라이언트가 테이블의 변경 사항을 구독할 때, 인증된 행에 대해서만 이벤트를 받습니다. 이것은 자동으로 작동합니다 -- 클라이언트에서 추가 필터링 로직을 추가할 필요가 없습니다. 효율적인 RLS 정책을 확인하세요. 모든 브로드캐스트에 대해 평가되기 때문입니다.

UI에서 테넌트 전환을 어떻게 처리합니까?

활성 테넌트 ID를 앱 상태 (React 컨텍스트, Zustand 스토어 등)에 저장하고 쿼리에서 필터로 전달합니다. RLS가 이미 사용자가 속한 테넌트로 결과를 제한하므로, 전환은 필터링하는 tenant_id를 변경하는 것의 문제입니다. 토큰 갱신이 필요하지 않으면 데이터베이스 조회 접근을 사용합니다.

Supabase의 기본 제공 인증을 사용해야 하나요, 아니면 Clerk 같은 외부 제공자를 사용해야 하나요?

Supabase Auth는 auth.uid()를 통해 RLS와 자연스럽게 통합됩니다. 외부 제공자를 사용하면 사용자를 Supabase에 동기화하고 사용자 정의 JWT 클레임 또는 매핑 테이블을 사용해야 합니다. 특정 이유가 없는 한 Supabase Auth를 사용하세요 -- 상당한 통합 작업을 절약합니다.

단일 Supabase 프로젝트가 몇 개의 테넌트를 처리할 수 있습니까?

공유 스키마 다중 테넌시로, 저는 Supabase Pro 플랜($25/월)에서 2,000개 이상의 테넌트를 개인적으로 실행했습니다. 실제 제한은 테넌트 개수가 아니라 총 데이터 볼륨과 쿼리 패턴에 따라 다릅니다. 인덱스가 올바르면 2개 vCPU와 1GB RAM을 가진 Supabase Pro 인스턴스는 수천만 행이 있는 테이블을 처리할 수 있습니다.

새 테이블에서 RLS를 활성화하는 것을 잊으면 어떻게 됩니까?

anon 키로 Supabase 클라이언트를 사용하는 경우, RLS 없는 모든 테이블은 그 키를 가진 누구나 접근할 수 있습니다. 이것은 Supabase의 가장 큰 실수입니다. public 스키마의 모든 테이블이 RLS를 활성화했는지 확인하는 CI 체크를 설정합니다. pg_tablespg_class와 조인하여 이 체크를 자동화할 수 있습니다.

Supabase Edge Functions에서 RLS를 사용할 수 있습니까?

네. Edge Functions는 anon 키 (RLS 적용) 또는 서비스 역할 키 (RLS 우회)를 사용하여 Supabase 클라이언트를 만들 수 있습니다. 사용자 면 작업에는 anon 키를 사용자 JWT로 사용합니다. 테넌트 액세스가 필요한 관리 작업에는 서비스 역할 키를 사용합니다.

단일 테넌트 앱에서 다중 테넌트로 마이그레이션하려면 어떻게 합니까?

tenant_id를 널 허용 열로 추가하고, 기존 데이터를 역채웁니다 (단일 테넌트였으므로 모든 행이 동일한 테넌트 ID를 얻습니다). 그런 다음 NOT NULL 제약 조건, 인덱스, RLS 정책을 추가합니다. RLS를 활성화하기 전에 기존 데이터로 철저히 테스트합니다 -- 잘못된 정책 하나는 모든 사용자를 잠글 수 있습니다. Supabase의 로컬 개발 환경을 사용하여 마이그레이션을 리허설합니다.