Supabase RLS 설정 방법: 바이브 코딩 앱 출시 전 꼭 막아야 할 7가지 구멍 (정책 SQL 예제·pgTAP 테스트·FAQ)
Lovable이나 Bolt, Cursor로 앱을 하루 만에 띄우고 나면 꼭 한 번은 이런 생각이 든다. “로그인도 되고, 내 데이터만 보이는데, 이 정도면 된 거 아닌가?” 나도 그랬다. 그런데 화면에 내 데이터만 보인다는 건 프론트엔드 코드가 그렇게 요청했다는 뜻일 뿐이다. 브라우저 개발자 도구를 열고 같은 Supabase 주소에 조건 없이 요청을 보내면 무엇이 돌아오는지는 전혀 다른 문제다. 그걸 결정하는 게 Row Level Security, 줄여서 RLS다.
이 글은 “Supabase RLS 설정 방법”을 검색한 사람, 특히 AI 코딩 도구로 만든 앱을 실제 사용자에게 열기 직전인 1인 개발자를 위해 썼다. 개념 설명은 최소한으로 하고, 내가 출시 전에 실제로 확인하는 순서대로 정리했다. 정책 SQL 예제, 자주 뚫리는 일곱 군데, 그리고 “정책을 썼다”와 “정책이 동작한다”를 구분하는 테스트 방법까지 담았다. 내용은 2026년 10월 기준 Supabase 공식 문서(Row Level Security, API keys 가이드)를 다시 읽고 맞춰 두었다.
먼저 결론: 출시 전에 이 다섯 줄만은 확인하자
- 노출된 스키마(보통
public)의 모든 테이블에 RLS가 켜져 있는가. anon,authenticated역할의 테이블 권한(grant)을 필요한 만큼만 남겼는가. 정책을 추가해도 grant는 사라지지 않는다.- select, insert, update, delete 정책을 따로 썼고, insert와 update에는
with check가 있는가. - secret 키(구 service_role 키)가 브라우저 번들, 모바일 앱, 깃 저장소 어디에도 없는가.
- 다른 사용자 계정으로 내 행을 읽고, 고치고, 지우려 했을 때 실제로 막히는 것을 테스트로 확인했는가.
이 다섯 개 중 하나라도 “아마 그럴 걸?”이라는 답이 나오면 아래를 끝까지 읽는 게 낫다.
RLS가 왜 바이브 코딩 앱의 마지막 방어선인가
Supabase로 만든 웹앱은 구조상 브라우저가 데이터베이스 API에 직접 말을 건다. 이때 쓰는 키가 publishable 키(예전 이름은 anon 키)다. 공식 문서도 이 키는 웹 페이지 소스나 앱 패키지에 들어가는 순간 누구나 꺼내 볼 수 있다고 전제한다. 즉 키를 숨기는 것으로는 아무것도 지킬 수 없고, 그 키로 들어온 요청이 어디까지 닿을지는 전적으로 데이터베이스 안의 규칙, 곧 RLS가 정한다.
공식 문서의 비유가 꽤 정확하다. publishable 키는 현관문에 붙여 둔 열쇠라 로비까지만 열고, secret 키는 마스터키라서 주머니에서 꺼내면 안 된다. 문제는 AI 코딩 도구가 만든 첫 버전이 로비와 사무실 사이에 문을 제대로 달아 두었는지 우리가 확인하지 않는다는 데 있다. 기능이 돌아가는 걸 보면 마음이 놓이니까.
Postgres는 요청이 테이블에 닿기 전에 두 가지를 검사한다. 먼저 grant, 즉 이 역할이 이 테이블에 select나 insert를 할 자격이 있는지. 그다음이 정책, 즉 그 작업이 어떤 행에 적용되는지. 이 두 층을 섞어서 이해하면 디버깅이 꼬인다. 권한이 없으면 42501 에러가 나고, 정책에 걸리면 에러 없이 빈 결과가 돌아온다. 요청이 이유 없이 실패하면 정책을 고치기 전에 grant부터 보라는 게 공식 문서의 조언이고, 실제로 이 순서를 지키면 시간이 훨씬 덜 든다.
Supabase RLS 설정 순서: 테이블 하나를 제대로 잠그는 법
공식 문서가 권하는 흐름은 네 단계다. RLS를 켜고 grant를 맞춘다, 작업별로 정책을 쓴다, 테스트 파일을 만든다, supabase test db로 돌린다. 나는 이걸 마이그레이션 파일 하나에 같이 넣는다. 대시보드 SQL 에디터에서 한 번 실행하고 끝내면, 나중에 스테이징이나 새 프로젝트를 만들 때 똑같이 재현되지 않는다.
예시로 각 사용자가 자기 메모만 다루는 notes 테이블을 생각해 보자.
-- 1. RLS 켜기
alter table public.notes enable row level security;
-- 2. 기본으로 깔려 있을 수 있는 권한 회수 후 필요한 것만 다시 부여
revoke all on table public.notes from anon, authenticated;
grant select, insert, update, delete on table public.notes to authenticated;
-- 3. 작업별 정책
create policy "notes_select_own" on public.notes
for select to authenticated
using ( (select auth.uid()) = user_id );
create policy "notes_insert_own" on public.notes
for insert to authenticated
with check ( (select auth.uid()) = user_id );
create policy "notes_update_own" on public.notes
for update to authenticated
using ( (select auth.uid()) = user_id )
with check ( (select auth.uid()) = user_id );
create policy "notes_delete_own" on public.notes
for delete to authenticated
using ( (select auth.uid()) = user_id );
-- 4. 정책이 거르는 컬럼에 인덱스
create index notes_user_id_idx on public.notes using btree (user_id);
여기서 몇 가지는 습관으로 만들어 둘 만하다. 정책마다 to authenticated처럼 대상 역할을 꼭 적는다. 역할을 안 적으면 로그인하지 않은 요청에도 정책 식이 평가된다. auth.uid()를 (select auth.uid())로 감싸는 건 성능 때문이다. 공식 문서 설명으로는 이렇게 쓰면 옵티마이저가 값을 문장 단위로 한 번만 계산해 캐시하고, 행마다 함수를 다시 부르지 않는다. 테이블이 작을 땐 차이가 안 보이지만 행이 쌓이면 체감된다. 인덱스도 같은 이유다. 정책은 결국 모든 쿼리에 붙는 WHERE 절이라, 거르는 컬럼에 인덱스가 없으면 읽기가 전부 순차 스캔이 된다.
한 가지 더. 프로젝트에 따라 새 테이블을 만들면 anon과 authenticated에 select, insert, update, delete가 자동으로 붙어 있기도 하다. 정책을 아무리 잘 써도 그 grant는 그대로 남는다. 그래서 위 예제처럼 일단 다 회수하고 필요한 것만 돌려주는 패턴을 쓴다. 로그아웃 사용자가 볼 게 하나도 없는 테이블이라면 anon에는 아무 권한도 주지 않는 게 맞다.
AI가 만든 앱에서 자주 뚫리는 일곱 군데
여기부터가 본론이다. 아래 일곱 개는 내가 AI 코딩 도구로 만든 프로토타입을 출시 전에 훑을 때 보는 목록이다. 대부분 공식 문서에 경고로 적혀 있는 내용인데, 생성된 코드가 그 경고를 자주 비켜 간다.
1. RLS를 켜지 않은 테이블이 하나 섞여 있다
처음 만든 테이블은 꼼꼼히 챙기는데, 기능을 붙이며 AI가 중간에 만든 테이블은 놓치기 쉽다. 로그용 테이블, 결제 웹훅 기록, 임시로 만든 설정 테이블 같은 것들이다. 공식 문서는 노출된 스키마에 RLS 없는 테이블이 있으면 grant를 가진 어떤 역할이든 읽고 쓸 수 있다고 분명히 경고한다. 대시보드의 Security Advisor가 이런 걸 잡아 주니 출시 전에 한 번은 꼭 열어 본다. 경고를 무시하고 넘기기 전에 이게 정말 공개되어도 되는 데이터인지 스스로 답해 보는 게 좋다.
2. 정책은 있는데 grant가 그대로 열려 있다
앞에서 말한 그 함정이다. select 정책만 꼼꼼히 써 두고 insert 정책은 없으니 쓰기는 막혔겠지, 하고 넘기기 쉽다. RLS가 켜져 있으면 정책이 없는 작업은 기본적으로 거부되므로 대개는 맞는 추측이다. 하지만 RLS를 잠시 껐다 켜는 과정에서 순서가 꼬이거나, 누군가 대시보드에서 “임시로” RLS를 해제하면 열린 grant가 바로 구멍이 된다. grant를 최소로 맞춰 두면 이중으로 막힌다. 나는 이걸 안전벨트와 에어백 관계로 생각한다.
3. update 정책에 with check가 없다
이게 생각보다 흔하다. using만 있는 update 정책은 “내 행만 고칠 수 있다”까지는 지키지만, 고친 결과가 어떤 모습이어야 하는지는 따로 묻지 않는다. 공식 문서 설명대로 with check가 없으면 using 식이 새 행에도 적용되긴 하지만, 의도를 명확히 하려면 둘 다 적는 게 안전하다. 특히 정책이 user_id가 아니라 팀 ID나 상태값으로 판단할 때, with check가 빠지면 사용자가 자기 행의 team_id를 남의 팀으로 바꿔 버리는 일이 가능해질 수 있다. 하나 더, update가 동작하려면 해당 행을 읽을 select 정책도 있어야 한다. update만 써 두고 “왜 수정이 안 되지” 하며 헤매는 경우가 여기서 나온다.
4. user_metadata로 권한을 판단한다
AI에게 “관리자만 수정할 수 있게 해 줘”라고 하면 종종 auth.jwt() -> 'user_metadata' ->> 'role' 같은 코드가 나온다. 공식 문서는 이걸 정면으로 경고한다. raw_user_meta_data는 로그인한 사용자가 supabase.auth.update()로 직접 바꿀 수 있어서 권한 정보를 두기에 부적절하다. 권한은 사용자가 바꿀 수 없는 raw_app_meta_data, 즉 app_metadata에 두거나 별도의 역할 테이블에서 확인해야 한다. 그리고 JWT 안의 값은 토큰이 갱신되기 전까지 옛 값이 남는다는 것도 기억해 두자. 팀에서 뺀 사람이 토큰이 새로 발급될 때까지는 여전히 팀원으로 보일 수 있다.
5. 뷰(View)가 RLS를 우회한다
대시보드용 통계를 보여 주려고 AI가 뷰를 하나 만들어 주는 경우가 있다. 그런데 Postgres 뷰는 기본적으로 만든 사람(보통 postgres)의 권한으로 실행돼서, 아래 테이블의 RLS를 그대로 건너뛴다. 테이블은 꽁꽁 잠갔는데 뷰를 통해 전 사용자 데이터가 새 나가는 셈이다. Postgres 15 이상이면 with (security_invoker = true)로 뷰를 만들어 호출한 사람의 권한으로 실행되게 하고, 그보다 낮은 버전이면 뷰를 노출되지 않는 스키마로 옮기거나 anon, authenticated의 접근을 회수한다.
create view public.my_note_stats
with (security_invoker = true)
as select user_id, count(*) as note_count
from public.notes group by user_id;
6. security definer 함수를 노출된 스키마에 만든다
팀 공유 기능을 만들다 보면 정책끼리 서로를 참조하다가 42P17 infinite recursion detected in policy 에러를 만나게 된다. 이걸 풀려고 security definer 함수를 쓰는 건 공식 문서도 권하는 방법이다. 다만 조건이 있다. 함수는 private 같은 노출되지 않는 스키마에 만들고, set search_path = ''를 걸고, 안의 이름은 전부 스키마까지 붙여 쓴다. 노출된 스키마에 만든 security definer 함수는 Data API로 바로 호출되고, 만든 사람의 권한으로 돈다. AI가 생성한 코드는 이 함수를 public에 만들어 두는 일이 잦아서 출시 전에 꼭 확인하는 항목이다.
create schema if not exists private;
create function private.user_team_ids()
returns setof uuid
language sql
security definer
set search_path = ''
stable
as $$
select team_id from public.team_members
where user_id = (select auth.uid())
$$;
revoke execute on function private.user_team_ids() from public;
grant usage on schema private to authenticated;
grant execute on function private.user_team_ids() to authenticated;
7. secret 키가 클라이언트로 새어 나간다
가장 치명적이고, 의외로 가장 단순한 실수다. 프론트엔드에서 뭔가 안 될 때 AI가 “service_role 키를 쓰면 해결됩니다”라고 제안하는 걸 본 적이 있을 거다. 그 키는 BYPASSRLS 속성을 가진 service_role로 들어가므로, 정책이 하나도 적용되지 않는다. 지금까지 쓴 RLS가 전부 무의미해진다.
요즘 Supabase는 새 키 체계로 넘어가는 중이다. 공식 문서에 따르면 기존 anon, service_role 키는 2026년 말까지 폐기될 예정이고, 대신 sb_publishable_로 시작하는 publishable 키와 sb_secret_로 시작하는 secret 키를 쓰라고 한다. 새 secret 키는 브라우저에서 쓰면 User-Agent를 보고 401을 돌려주는 안전장치가 있다. 그렇다고 안심할 건 아니다. 그건 브라우저에서 실수로 쓰는 걸 막는 장치일 뿐, 키가 새면 공격자는 다른 도구로 얼마든지 쓸 수 있다. 문서에도 “eyJ로 시작하는 긴 키를 복사하라는 튜토리얼이나 AI 도우미는 옛 키 기준으로 쓰인 것”이라는 문장이 있는데, AI 코딩 도구에 Supabase 연동을 시킬 때 딱 들어맞는 조언이다.
환경 변수 이름도 규칙을 정해 두면 좋다. Next.js라면 NEXT_PUBLIC_ 접두사가 붙은 변수는 번들에 들어간다. secret 키에는 절대 이 접두사를 붙이지 않는다. 혹시 키가 깃에 올라갔다면 원인부터 막고, 새 secret 키를 만들어 모든 곳을 교체한 다음 옛 키를 지운다. 교체가 끝나기 전에 지우면 서비스가 멈추니 순서가 중요하다.
보너스 함정: 로그인 안 한 사용자와 auth.uid()의 null
정책을 using (auth.uid() = user_id)로만 써 두면, 로그인하지 않은 요청에서 auth.uid()가 null이 되고 null = user_id는 항상 거짓이라 결과적으로 막히긴 한다. 문제는 “조용히” 막힌다는 점이다. 에러가 안 나니 프론트에서 빈 목록을 보고 버그인지 정상인지 헷갈린다. 정책에 to authenticated를 붙여 아예 로그인 사용자에게만 평가되게 하거나, 문서 권장처럼 auth.uid() is not null and ...를 명시해 의도를 드러내는 편이 낫다. 또 하나, Supabase Auth의 익명 로그인 사용자는 anon 역할이 아니라 authenticated 역할로 들어온다. 익명 사용자를 허용한 앱이라면 JWT의 is_anonymous 클레임으로 정식 회원과 구분해야 한다.
정책을 ‘썼다’와 ‘동작한다’를 구분하는 테스트
내가 이 주제로 글을 써야겠다고 마음먹은 이유가 사실 여기 있다. 정책을 써 놓고 내 계정으로 앱을 열어 보면 다 잘 된다. 내 데이터는 보이고, 남의 데이터는 안 보인다. 하지만 그건 프론트 코드가 .eq('user_id', user.id)를 붙여 요청했기 때문일 수도 있다. 정책이 막은 건지, 프론트가 알아서 걸러 준 건지 화면만 봐서는 구분이 안 된다.
그래서 최소한 두 가지 방식으로 확인한다.
방법 1: 프론트 필터 없이 직접 찔러 보기
계정을 두 개 만든다. A로 로그인해서 메모를 몇 개 만들고, B로 로그인한 상태에서 브라우저 콘솔이나 간단한 스크립트로 필터 없이 supabase.from('notes').select('*')를 호출한다. A의 메모가 하나라도 돌아오면 정책이 없거나 틀린 거다. 같은 방식으로 A의 메모 id를 알아낸 다음 B 계정으로 update와 delete를 시도한다. 에러 없이 “0행 영향”이 돌아오면 정상, 실제로 바뀌면 사고다. 로그아웃 상태에서도 한 번씩 반복한다.
방법 2: pgTAP으로 테스트를 코드에 남기기
수동 확인은 한 번은 좋지만 기능이 바뀔 때마다 다시 하지 않게 된다. Supabase CLI는 pgTAP 기반 데이터베이스 테스트를 지원한다. supabase test new notes_rls.test로 supabase/tests/ 아래에 파일을 만들고, supabase test db로 돌린다. 공식 문서 예제를 줄여서 옮기면 대략 이런 모양이다.
begin;
select plan(4);
insert into auth.users (id, email) values
('11111111-1111-1111-1111-111111111111', 'a@example.com'),
('22222222-2222-2222-2222-222222222222', 'b@example.com');
set local role authenticated;
set local request.jwt.claim.sub = '11111111-1111-1111-1111-111111111111';
select results_eq(
$$insert into notes (user_id, body)
values ('11111111-1111-1111-1111-111111111111', 'mine')
returning body$$,
array['mine'], 'A는 자기 메모를 만든다');
set local request.jwt.claim.sub = '22222222-2222-2222-2222-222222222222';
select is_empty($$select * from notes$$, 'B에게는 A의 메모가 안 보인다');
select is_empty(
$$update notes set body = 'hacked' returning body$$,
'B는 A의 메모를 고치지 못한다');
set local request.jwt.claim.sub = '11111111-1111-1111-1111-111111111111';
select results_eq($$select body from notes$$, array['mine'],
'거부된 수정 뒤에도 A의 메모는 그대로다');
select * from finish();
rollback;
공식 문서에서 특히 마음에 들었던 대목이 있다. 허용된 쓰기를 lives_ok로 검증하지 말라는 것이다. lives_ok는 쓰기가 0행에 적용돼도 통과하기 때문이다. 반드시 returning을 붙여 실제로 바뀐 값을 확인하라고 한다. 거부 방식에 따라 검증 방법도 다르다. grant가 없거나 with check에 걸리면 42501 에러가 나니 throws_ok로, using에 걸려 행이 걸러지면 에러 없이 0행이니 is_empty로 확인한 뒤 원래 행이 그대로인지 한 번 더 읽는다. 이 구분을 알고 나면 “테스트는 통과했는데 사실 아무것도 검증하지 않은” 상태를 피할 수 있다.
나는 이 테스트 파일 작성을 AI 코딩 도구에게 맡기는 편이다. 다만 “정책이 맞는지 테스트해 줘”라고 하면 정책에 맞춰 통과하는 테스트를 만들어 오기 쉬우니, “B 사용자가 A의 행을 읽기·수정·삭제하려는 시도가 실패하는지, anon이 아무것도 못 하는지 확인하는 pgTAP 테스트”처럼 공격 시나리오를 문장으로 못 박아서 시킨다. 그리고 일부러 정책 하나를 지워 보고 테스트가 실패하는지 확인한다. 실패해야 할 때 실패하지 않는 테스트는 장식이다.
Storage 버킷도 같은 원리로 본다
프로필 사진이나 첨부 파일을 Supabase Storage에 올린다면 그쪽도 잠겨 있는지 봐야 한다. Storage의 접근 제어도 결국 storage.objects 테이블에 거는 RLS 정책으로 동작한다. 흔한 실수는 테스트하다가 버킷을 public으로 만들어 두고 잊는 것이다. 공개 버킷의 파일은 URL만 알면 누구나 받는다. 사용자별 파일이라면 비공개 버킷으로 두고, 경로 첫 폴더를 사용자 ID로 정한 뒤 그 폴더에만 접근하게 정책을 쓰는 방식이 일반적이다. 정확한 함수 이름과 예제는 Supabase Storage 접근 제어 문서를 확인하고 쓰는 게 좋다. 버전에 따라 바뀌는 부분이라 기억에 의존하지 않는 편이 안전하다.
Lovable·Bolt·Cursor에게 RLS를 시킬 때 쓰는 프롬프트 습관
AI 도구를 탓하려는 건 아니다. 최근 도구들은 RLS를 기본으로 켜 주려고 꽤 노력한다. 다만 기능 요청 중심으로 대화를 이어 가다 보면 보안 조건이 대화 뒤로 밀린다. 그래서 나는 테이블을 새로 만들 때마다 아래 조건을 같이 붙인다.
- “새 테이블은 RLS를 켜고, anon과 authenticated의 grant를 회수한 뒤 필요한 것만 다시 부여하는 마이그레이션으로 만들어 줘.”
- “정책은 select·insert·update·delete를 따로 쓰고, 대상 역할을 to 절로 명시하고, update에는 using과 with check를 둘 다 넣어 줘.”
- “권한 판단에 user_metadata를 쓰지 말고 app_metadata나 별도 역할 테이블을 써.”
- “클라이언트 코드에는 publishable 키만 쓰고, secret 키가 필요한 작업은 서버나 Edge Function으로 분리해 줘.”
- “마지막으로 다른 사용자와 비로그인 사용자의 접근이 실패하는지 확인하는 pgTAP 테스트를 supabase/tests에 추가해 줘.”
이걸 매번 타이핑하기 귀찮으면 프로젝트 규칙 파일(Cursor의 rules, Claude Code의 CLAUDE.md 같은 곳)에 넣어 두면 된다. 그 순간부터 AI가 만드는 모든 테이블에 이 기준이 적용된다.
출시 전 체크리스트
- Security Advisor를 열고 RLS 관련 경고를 하나씩 확인했다.
public스키마의 모든 테이블에 RLS가 켜져 있다.- 각 테이블의
anon,authenticatedgrant가 앱에 필요한 만큼만 남아 있다. - 정책은 작업별로 나뉘어 있고 모두
to절이 있다. - insert에는
with check, update에는using과with check가 모두 있다. - 권한 판단에 user_metadata를 쓰지 않는다.
- 뷰는
security_invoker = true로 만들었거나 노출되지 않는 스키마에 있다. - security definer 함수는 private 스키마에 있고
search_path가 고정돼 있다. - secret 키가 클라이언트 번들, 앱 패키지, 깃 기록에 없다.
- Storage 버킷 중 public이 정말 공개해도 되는 것만 남아 있다.
- 두 계정과 비로그인 상태로 읽기·수정·삭제를 직접 시도해 봤다.
- pgTAP 테스트가 있고, 정책을 하나 지우면 테스트가 실패한다.
자주 묻는 질문
RLS를 켜자마자 앱에서 데이터가 하나도 안 보여요. 고장 난 건가요?
정상이다. 공식 문서에도 RLS를 켜면 정책을 만들기 전까지 publishable 키로는 어떤 데이터도 접근할 수 없다고 나와 있다. 필요한 작업별로 정책을 추가하면 된다. 정책을 넣었는데도 42501 에러가 난다면 grant를 먼저 확인하자.
anon 키(publishable 키)가 프론트 코드에 보이는데 괜찮은가요?
그 키는 원래 공개되는 걸 전제로 설계됐다. 대신 그 키로 닿을 수 있는 범위를 RLS가 정하므로, RLS가 제대로 걸려 있어야만 괜찮다. 공개되면 안 되는 건 secret 키, 그리고 예전 체계의 service_role 키다.
서버에서 secret 키로 조회하는데 RLS가 적용되는 것 같아요.
공식 문서에 따르면 secret 키는 요청에 사용자 액세스 토큰이 없을 때만 RLS를 우회한다. 클라이언트를 secret 키로 초기화했더라도 요청에 로그인 사용자 토큰이 실려 가면 그 사용자의 정책이 적용된다. 서버 코드에서 사용자 세션을 그대로 전달하고 있지 않은지 보자.
정책을 for all 하나로 쓰면 안 되나요?
동작은 할 수 있지만 공식 문서는 작업별로 나눠 쓰길 권한다. for all 하나로 묶으면 어떤 규칙이 어떤 작업을 위한 건지 흐려지고, update에 필요한 with check 같은 걸 빠뜨려도 눈에 잘 안 띈다. 나중에 읽는 사람이 나 자신이라도 나눠 두는 게 편하다.
팀 기능을 만들었더니 infinite recursion 에러가 납니다.
두 테이블의 정책이 서로를 읽고 있어서 생기는 전형적인 문제다. 멤버십 조회를 private 스키마의 security definer 함수로 빼서, 정책이 그 함수를 호출하게 바꾸면 순환이 끊긴다. 함수에는 set search_path = ''를 꼭 걸자.
RLS 때문에 느려지지 않나요?
정책은 모든 쿼리에 붙는 WHERE 절이라, 잘못 쓰면 느려질 수 있다. 정책이 거르는 컬럼에 btree 인덱스를 만들고, auth.uid() 같은 함수는 (select auth.uid())로 감싸는 두 가지만 지켜도 대부분의 1인 서비스 규모에서는 문제가 되지 않는다. 복합 기본키는 첫 컬럼에만 인덱스 효과가 있다는 점도 기억해 두자.
기존 anon·service_role 키를 쓰고 있는데 지금 바꿔야 하나요?
공식 문서는 2026년 말까지 레거시 키를 폐기할 예정이라고 안내한다. 새 키를 만들어도 기존 키는 따로 비활성화하기 전까지 함께 동작하니, 여유 있을 때 새 키로 옮기고 확인이 끝난 뒤 레거시 키를 끄는 순서가 안전하다.
마치며
바이브 코딩으로 앱을 빨리 만드는 건 이제 어렵지 않다. 어려운 건 “남의 데이터가 내 화면에 안 보인다”를 “남이 내 데이터를 절대 못 가져간다”로 바꾸는 일이고, Supabase에서 그 일을 맡는 게 RLS다. 정책 몇 줄을 쓰는 데는 십 분이면 충분하지만, 그게 정말 막고 있는지 확인하는 테스트까지 갖추는 데는 반나절쯤 든다. 첫 사용자를 받기 전에 그 반나절을 쓰는 게, 사고가 난 뒤 사과 메일을 쓰는 것보다 훨씬 싸다.
함께 읽으면 좋은 글도 남겨 둔다. Lovable과 Bolt 중 무엇으로 MVP를 만들지 고민 중이라면 Lovable.dev vs Bolt.new 비교에서 Supabase 연동 시 주의점을 다뤘다. 프로토타입을 실제 서비스 구조로 옮기는 과정은 바이브 코딩 프로덕션 아키텍처 실전 가이드에, API 키를 앱에 넣으면 안 되는 이유와 프록시 구조는 공공데이터 API 앱 연동 보안 아키텍처에 정리했다. 테스트를 통과했는데도 출시하면 안 되는 상황은 드라이브라디오·MEMVIA 출시 직전 사후분석에서 더 볼 수 있다.
참고한 공식 문서: Supabase Row Level Security, Supabase API keys (2026년 10월 12일 확인)
