← 목록으로ARCHITECTURE

SQL 두 줄로 백엔드 권한 코드를 지웠다 — 1인 개발자 Vercel + Supabase RLS 구성

SQL 두 줄로 백엔드 권한 코드를 지웠다 — 1인 개발자 Vercel + Supabase RLS 구성

사이드 프로젝트 인프라를 고를 때 기준은 하나입니다. 내가 운영하지 않아도 되는 것. 새벽에 서버가 죽어도 나 대신 누가 살려주는 구성만 씁니다.

비용 얘기가 아니라 시간 얘기입니다. 혼자 하는 프로젝트에서 제일 비싼 자원은 서버비가 아니라 주말 저녁 두 시간이에요. 그 두 시간을 nginx 설정이랑 인증서 갱신에 쓰기 시작하면 정작 만들려던 건 영원히 안 만들어집니다.

구성

  • Vercel — 프론트엔드랑 API 라우트. git push가 곧 배포.
  • Supabase — Postgres, Auth, Storage. RLS로 권한을 DB에서 해결.
  • Cloudflare — DNS랑 정적 블로그. 이 블로그가 여기서 서빙됩니다.

경계는 이렇게 잡았습니다. 상태가 있으면 Supabase, 없으면 Vercel이나 Cloudflare. 이 한 줄이 “이 로직을 어디 둘까”를 대부분 결정해줍니다. 세션도 파일도 카운터도 다 Supabase로 보내면, 프론트엔드 레이어는 언제든 통째로 버리고 다시 만들 수 있는 상태로 남습니다.

정적 블로그를 Vercel이 아니라 Cloudflare Pages에 둔 이유는 별거 없습니다. 빌드해서 파일만 뿌리면 되는 사이트에 서버리스 런타임이 필요 없어서요.

RLS가 백엔드 코드를 대체한다

이 구성의 핵심입니다. 권한 검사를 API 코드에 쓰는 대신 정책으로 선언합니다.

alter table projects enable row level security;

create policy "owner can read"
  on projects for select
  using (auth.uid() = owner_id);

이 두 줄이 미들웨어랑 가드 함수, 테스트 케이스 여러 개를 대체합니다. 클라이언트에서 바로 supabase.from("projects").select()를 호출해도 되는 이유고요. 권한이 API 경로마다가 아니라 테이블 한 곳에 정의되니까, 새 화면 만들면서 권한 검사를 빠뜨리는 일 자체가 없어집니다.

using과 with check는 다르다

여기서 한 번 데였습니다. 두 절은 검사 시점이 다릅니다.

  • using — 기존 행이 보이는지 판단합니다. SELECT / UPDATE / DELETE에 적용.
  • with check — 새로 쓰는 값이 유효한지 판단합니다. INSERT / UPDATE에 적용.

UPDATE에는 둘 다 필요합니다. using만 쓰면 내 행만 수정 가능한 건 맞는데, 수정하면서 owner_id를 남의 것으로 바꿔 넘기는 걸 못 막습니다.

create policy "owner can update"
  on projects for update
  using (auth.uid() = owner_id)        -- 내 행만 대상으로
  with check (auth.uid() = owner_id);  -- 수정 후에도 여전히 내 행

with check를 빼먹으면 자기 행을 남한테 넘기는 건 되는데 정작 본인은 그 뒤로 접근을 못 합니다. 에러 없이 데이터만 사라지는 부류라 원인 찾는 데 한참 걸렸습니다.

enable row level security를 빼먹으면 정책은 장식이다

제일 위험한 실수입니다. RLS를 안 켠 테이블에 정책을 아무리 써도 그 정책은 평가되지 않습니다. 테이블은 그냥 전부 열려 있습니다. 정책을 썼다는 사실이 오히려 안심시켜서 더 고약해요.

Supabase 공식 문서도 이 대목만 Danger로 표시해 뒀습니다.

노출된 스키마에 RLS가 꺼진 테이블이 있으면, 그 테이블에 권한이 있는 모든 롤이 읽고 쓸 수 있습니다. 노출된 스키마의 모든 테이블에 RLS를 켜세요. 정책을 추가한다고 기존 권한이 사라지지는 않습니다.

Supabase 공식 문서의 Danger 경고 박스 — 노출된 스키마에서 RLS가 꺼진 테이블은 grant가 있는 모든 롤이 읽고 쓸 수 있으며, 정책을 추가해도 기존 grant는 사라지지 않는다는 내용

마지막 문장이 특히 중요합니다. 정책은 권한을 더하는 장치가 아니라 이미 열린 문에 거는 자물쇠라서, 문 자체를 안 잠그면(RLS를 안 켜면) 자물쇠는 아무 데도 안 걸려 있습니다.

새 테이블 만들 때마다 같은 마이그레이션 파일에 alter table ... enable row level security;를 붙여두세요. Supabase 대시보드가 RLS 꺼진 테이블에 경고를 띄워주니, 배포 전에 한 번 훑는 것도 습관 들일 만합니다.

키는 두 종류고 하나는 절대 클라이언트에 두면 안 된다

  • publishable(anon) 키는 공개 전제입니다. 이 블로그도 그냥 소스에 박아뒀습니다. RLS를 지나가는 키라서 정책만 제대로 있으면 노출돼도 문제없습니다.
  • service_role 키는 RLS를 통째로 우회합니다. 서버 환경변수 밖으로 나가는 순간 DB 전체가 열립니다. 클라이언트 번들, 프론트 .env, 깃 저장소 어디에도 두면 안 됩니다.

publishable 키가 공개돼도 되는 건 덜 중요한 키라서가 아니라 RLS가 실제로 방어선이기 때문입니다. RLS가 없으면 이 키도 그냥 마스터키예요. 앞의 두 절이 중요한 이유가 여기 있습니다.

쓰기는 테이블이 아니라 함수로

이 블로그 조회수 위젯이 실제 사례입니다. 처음엔 post_views 테이블에 INSERT 정책을 열까 했는데, 그러면 누구나 요청을 반복해서 카운트를 조작할 수 있죠. 대신 함수 하나만 노출했습니다.

create function blog_hit_post(p_slug text)
returns void
language plpgsql
security definer   -- 함수 소유자 권한으로 실행. 테이블은 계속 잠겨 있다
set search_path = public
as $$
begin
  insert into post_views (slug, day, visitor)
  values (p_slug, current_date, md5(...))  -- IP 해시, 일 단위
  on conflict do nothing;                  -- 같은 방문자·같은 날은 무시
end;
$$;

set search_path = public을 같이 박아둔 건 취향이 아니라 공식 권장입니다. security definer 함수에서 search_path를 고정하지 않으면 호출자가 스키마를 바꿔치기해 함수 소유자 권한으로 엉뚱한 걸 실행시킬 수 있습니다.

security definer 덕에 테이블 정책은 계속 잠긴 채로, 함수가 허용한 방식의 쓰기만 통과합니다. 쓰기 권한을 테이블이 아니라 동작 단위로 주는 건데, 클라이언트에서 DB를 직접 부르는 구성이라면 이게 제일 중요한 습관인 것 같습니다.

클라이언트 쪽은 이게 전부입니다.

const SUPABASE_URL = "https://xxxx.supabase.co";
const SUPABASE_KEY = "sb_publishable_..."; // publishable key, public by design

export async function rpc<T>(fn: string, args: object = {}): Promise<T> {
  const res = await fetch(`${SUPABASE_URL}/rest/v1/rpc/${fn}`, {
    method: "POST",
    headers: { apikey: SUPABASE_KEY, "Content-Type": "application/json" },
    body: JSON.stringify(args),
  });
  if (!res.ok) throw new Error(String(res.status));
  return res.json();
}

supabase-js는 안 넣었습니다. 이 블로그가 DB에 하는 일이 RPC 호출 두 개뿐이라 클라이언트 라이브러리를 번들에 넣을 이유가 없었어요. Auth나 Realtime 쓰게 되면 그때 넣으면 됩니다.

스키마 변경은 대시보드가 아니라 파일로

Supabase 대시보드는 클릭으로 컬럼을 추가할 수 있어서 편한데, 그렇게 만든 스키마는 재현이 안 됩니다. 로컬이랑 프로덕션이 갈라지는 순간 원인 추적이 막혀요.

supabase/migrations/
  20260801000000_create_post_views.sql
  20260803000000_add_blog_hit_post.sql

정책도 마이그레이션 파일 안에 같이 씁니다. RLS 정책은 설정이 아니라 스키마의 일부니까요. 나중에 “이 테이블 정책 어디 있냐”를 물어볼 수 있게 되는 것만으로 값을 합니다.

비용과 한계

무료 티어로 MAU 수천까지는 버팁니다. 다만 무료 구간에서 실제로 걸리는 것들이 몇 개 있어요.

  • 무료 프로젝트는 1주일간 활동이 없으면 일시정지됩니다. 하루에 요청 몇 개만 들어와도 유지되는 수준이고, 정지 전에 경고 메일이 한 번 옵니다. 그래도 데모 링크를 보내놓고 잊고 있다가 상대가 못 여는 상황은 겪을 수 있어요. (공식 문서)
  • 커넥션 수가 먼저 걸립니다. 서버리스 함수에서 매 요청 커넥션을 새로 열면 금방 한계에 닿으니 커넥션 풀러 엔드포인트를 쓰세요.
  • 콜드 스타트는 그냥 받아들이는 편이 낫습니다. 첫 요청이 느린 걸 없애겠다고 워밍 크론을 도는 순간, 안 하기로 했던 운영이 다시 시작됩니다.

유료 전환이 필요해지는 시점이면 이미 프로젝트가 검증된 뒤라 그때 고민해도 늦지 않습니다.

이 구성을 버려야 할 때

만능은 아닙니다. 셋 중 하나에 해당하면 다른 걸 봐야 합니다.

  • 몇 분 이상 도는 작업이 필요할 때. 서버리스 함수 실행 시간 제한에 걸립니다. 영상 인코딩이나 대량 크롤링 같은 건 별도 워커가 필요해요.
  • 권한 규칙이 RLS로 안 풀릴 때. 여러 테이블을 조인해야 판정되는 권한은 정책 안에서 표현하기도 어렵고 표현해도 성능이 안 나옵니다. 이럴 땐 서버 레이어를 두는 게 맞습니다.
  • DB를 옮길 계획이 실제로 있을 때. RLS랑 security definer 함수에 로직을 넣을수록 Postgres가 아니라 Supabase에 묶입니다. 표준 기능이라 이론상 이식은 되지만 auth.uid() 같은 건 그대로 안 넘어갑니다.

결론

작게 시작해서 필요할 때만 확장하는 건 인프라도 코드랑 같습니다. 여기서 작다는 건 기능이 적다는 게 아니라 내가 새벽에 일어나서 고쳐야 할 게 적다는 뜻이고요.

이 구성 위에 실제로 뭘 얹었는지는 따로 정리해 뒀습니다. 로그인은 구글 소셜 로그인카카오 로그인, 정적 블로그 배포는 Cloudflare Pages 연결 쪽입니다.

지금 이 블로그의 운영 부담은 마크다운 파일 하나 추가하고 push하는 것뿐입니다. 그 정도면 됐습니다.