# SQL 두 줄로 백엔드 권한 코드를 지웠다 — 1인 개발자 Vercel + Supabase RLS 구성 Vercel+Supabase 1인 개발자 최소 인프라. RLS로 권한 코드 없애기, using/with check 차이, 무료 티어 조건. - source: https://polroute.com/posts/minimal-infra-vercel-supabase/ - category: 아키텍처 - published: 2026-08-05 --- 사이드 프로젝트 인프라를 고를 때 기준은 하나입니다. 내가 운영하지 않아도 되는 것. 새벽에 서버가 죽어도 나 대신 누가 살려주는 구성만 씁니다. 비용 얘기가 아니라 시간 얘기입니다. 혼자 하는 프로젝트에서 제일 비싼 자원은 서버비가 아니라 주말 저녁 두 시간이에요. 그 두 시간을 nginx 설정이랑 인증서 갱신에 쓰기 시작하면 정작 만들려던 건 영원히 안 만들어집니다. ## 구성 - **Vercel** — 프론트엔드랑 API 라우트. git push가 곧 배포. - **Supabase** — Postgres, Auth, Storage. RLS로 권한을 DB에서 해결. - **Cloudflare** — DNS랑 정적 블로그. 이 블로그가 여기서 서빙됩니다. 경계는 이렇게 잡았습니다. 상태가 있으면 Supabase, 없으면 Vercel이나 Cloudflare. 이 한 줄이 "이 로직을 어디 둘까"를 대부분 결정해줍니다. 세션도 파일도 카운터도 다 Supabase로 보내면, 프론트엔드 레이어는 언제든 통째로 버리고 다시 만들 수 있는 상태로 남습니다. 정적 블로그를 Vercel이 아니라 Cloudflare Pages에 둔 이유는 별거 없습니다. 빌드해서 파일만 뿌리면 되는 사이트에 서버리스 런타임이 필요 없어서요. ## RLS가 백엔드 코드를 대체한다 이 구성의 핵심입니다. 권한 검사를 API 코드에 쓰는 대신 정책으로 선언합니다. ```sql 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는 다르다 여기서 한 번 데였습니다. 두 절은 [검사 시점이 다릅니다](https://supabase.com/docs/guides/database/postgres/row-level-security). - `using` — 기존 행이 보이는지 판단합니다. SELECT / UPDATE / DELETE에 적용. - `with check` — 새로 쓰는 값이 유효한지 판단합니다. INSERT / UPDATE에 적용. UPDATE에는 둘 다 필요합니다. `using`만 쓰면 내 행만 수정 가능한 건 맞는데, 수정하면서 `owner_id`를 남의 것으로 바꿔 넘기는 걸 못 막습니다. ```sql 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 [공식 문서](https://supabase.com/docs/guides/database/postgres/row-level-security)도 이 대목만 Danger로 표시해 뒀습니다. > 노출된 스키마에 RLS가 꺼진 테이블이 있으면, 그 테이블에 권한이 있는 모든 롤이 읽고 쓸 수 있습니다. 노출된 스키마의 모든 테이블에 RLS를 켜세요. **정책을 추가한다고 기존 권한이 사라지지는 않습니다.** ![Supabase 공식 문서의 Danger 경고 박스 — 노출된 스키마에서 RLS가 꺼진 테이블은 grant가 있는 모든 롤이 읽고 쓸 수 있으며, 정책을 추가해도 기존 grant는 사라지지 않는다는 내용](/images/minimal-infra-vercel-supabase-1.webp) 마지막 문장이 특히 중요합니다. 정책은 권한을 **더하는** 장치가 아니라 이미 열린 문에 거는 자물쇠라서, 문 자체를 안 잠그면(RLS를 안 켜면) 자물쇠는 아무 데도 안 걸려 있습니다. 새 테이블 만들 때마다 같은 마이그레이션 파일에 `alter table ... enable row level security;`를 붙여두세요. Supabase 대시보드가 RLS 꺼진 테이블에 경고를 띄워주니, 배포 전에 한 번 훑는 것도 습관 들일 만합니다. ### 키는 두 종류고 하나는 절대 클라이언트에 두면 안 된다 - publishable(anon) 키는 공개 전제입니다. 이 블로그도 그냥 소스에 박아뒀습니다. RLS를 지나가는 키라서 정책만 제대로 있으면 노출돼도 문제없습니다. - **service_role 키는 RLS를 통째로 우회합니다.** 서버 환경변수 밖으로 나가는 순간 DB 전체가 열립니다. 클라이언트 번들, 프론트 `.env`, 깃 저장소 어디에도 두면 안 됩니다. publishable 키가 공개돼도 되는 건 덜 중요한 키라서가 아니라 RLS가 실제로 방어선이기 때문입니다. RLS가 없으면 이 키도 그냥 마스터키예요. 앞의 두 절이 중요한 이유가 여기 있습니다. ## 쓰기는 테이블이 아니라 함수로 이 블로그 조회수 위젯이 실제 사례입니다. 처음엔 `post_views` 테이블에 INSERT 정책을 열까 했는데, 그러면 누구나 요청을 반복해서 카운트를 조작할 수 있죠. 대신 함수 하나만 노출했습니다. ```sql 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`을 같이 박아둔 건 취향이 아니라 [공식 권장](https://supabase.com/docs/guides/database/functions)입니다. `security definer` 함수에서 search_path를 고정하지 않으면 호출자가 스키마를 바꿔치기해 함수 소유자 권한으로 엉뚱한 걸 실행시킬 수 있습니다. `security definer` 덕에 테이블 정책은 계속 잠긴 채로, 함수가 허용한 방식의 쓰기만 통과합니다. 쓰기 권한을 테이블이 아니라 동작 단위로 주는 건데, 클라이언트에서 DB를 직접 부르는 구성이라면 이게 제일 중요한 습관인 것 같습니다. 클라이언트 쪽은 이게 전부입니다. ```ts const SUPABASE_URL = "https://xxxx.supabase.co"; const SUPABASE_KEY = "sb_publishable_..."; // publishable key, public by design export async function rpc(fn: string, args: object = {}): Promise { 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주일간 활동이 없으면 일시정지됩니다.** 하루에 요청 몇 개만 들어와도 유지되는 수준이고, 정지 전에 경고 메일이 한 번 옵니다. 그래도 데모 링크를 보내놓고 잊고 있다가 상대가 못 여는 상황은 겪을 수 있어요. ([공식 문서](https://supabase.com/docs/guides/platform/free-project-pausing)) - 커넥션 수가 먼저 걸립니다. 서버리스 함수에서 매 요청 커넥션을 새로 열면 금방 한계에 닿으니 [커넥션 풀러 엔드포인트](https://supabase.com/docs/guides/database/connecting-to-postgres)를 쓰세요. - 콜드 스타트는 그냥 받아들이는 편이 낫습니다. 첫 요청이 느린 걸 없애겠다고 워밍 크론을 도는 순간, 안 하기로 했던 운영이 다시 시작됩니다. 유료 전환이 필요해지는 시점이면 이미 프로젝트가 검증된 뒤라 그때 고민해도 늦지 않습니다. ## 이 구성을 버려야 할 때 만능은 아닙니다. 셋 중 하나에 해당하면 다른 걸 봐야 합니다. - 몇 분 이상 도는 작업이 필요할 때. 서버리스 함수 실행 시간 제한에 걸립니다. 영상 인코딩이나 대량 크롤링 같은 건 별도 워커가 필요해요. - 권한 규칙이 RLS로 안 풀릴 때. 여러 테이블을 조인해야 판정되는 권한은 정책 안에서 표현하기도 어렵고 표현해도 성능이 안 나옵니다. 이럴 땐 서버 레이어를 두는 게 맞습니다. - DB를 옮길 계획이 실제로 있을 때. RLS랑 `security definer` 함수에 로직을 넣을수록 Postgres가 아니라 Supabase에 묶입니다. 표준 기능이라 이론상 이식은 되지만 `auth.uid()` 같은 건 그대로 안 넘어갑니다. ## 결론 작게 시작해서 필요할 때만 확장하는 건 인프라도 코드랑 같습니다. 여기서 작다는 건 기능이 적다는 게 아니라 내가 새벽에 일어나서 고쳐야 할 게 적다는 뜻이고요. 이 구성 위에 실제로 뭘 얹었는지는 따로 정리해 뒀습니다. 로그인은 [구글 소셜 로그인](/posts/claude-code-google-social-login/)과 [카카오 로그인](/posts/claude-code-kakao-social-login/), 정적 블로그 배포는 [Cloudflare Pages 연결](/posts/cloudflare-pages-deploy-custom-domain/) 쪽입니다. 지금 이 블로그의 운영 부담은 마크다운 파일 하나 추가하고 push하는 것뿐입니다. 그 정도면 됐습니다.