Supabase RLS 권한 설계: GRANT·policy·키 경계와 데이터베이스 테스트

··설명 약 14분·작성: 해비·블로그 소개

Supabase GRANT와 RLS policy의 권한을 일반 사용자 역할로 어떻게 시험하나요?

GRANT는 역할이 테이블에서 할 수 있는 작업을, RLS policy는 접근할 수 있는 행을 정합니다. 사용자 권한은 관리자 요청이 아니라 일반 사용자 역할로 시험해야 합니다.

적용 조건·범위: 권한 행렬에는 익명 요청, A 자신의 행, A가 B의 행을 요청하는 경우와 소유권 이전을 나눠 적습니다. 로컬 pgTAP 테스트는 이 기대값을 반복 검사하며 실제 이메일 가입·로그인 검증을 대신하지 않습니다.

본문 근거: supabase.com/docs/guides/database/postgres/row-level-security · supabase.com/docs/guides/local-development/testing/overview · supabase.com/docs/guides/local-development/testing/pgtap-extended

API key와 Postgres role은 같은 개념이 아닙니다

2026년 9월 Supabase 공식 문서 기준 새 프로젝트는 publishable key와 secret key 사용을 권장합니다.

Publishable key
→ 공개 환경에서 사용
→ 비로그인: anon
→ 로그인: authenticated

Secret key
→ 서버 전용
→ service_role
→ RLS 우회

기존 anon key와 service_role key는 레거시 키입니다.

Supabase는 2026년 말까지 레거시 키를 폐기하는 방향을 안내하고 있으므로 새 예제에서는 publishable / secret 명칭을 기준으로 설명합니다.

다만 RLS policy에서 사용하는 역할 이름은 여전히 anon, authenticated 같은 Postgres role입니다.

API key 이름
≠
Postgres role 이름

secret key도 GRANT 오류까지 없애는 것은 아닙니다

secret key는 service_role 권한으로 RLS를 우회합니다.

하지만 Supabase 공식 문서가 설명하듯 Postgres는 table grant를 먼저 평가한 뒤 RLS를 평가합니다.

따라서 missing GRANT와 RLS policy 문제는 증상이 다를 수 있습니다.

GRANT 없음
→ 작업 자체 permission error

GRANT 있음 + policy 불일치
→ 접근 가능한 행이 없거나 policy 위반

정책 디버깅에서 이 둘을 구분해야 합니다.

권한 행렬을 먼저 작성하기

코드부터 쓰기 전에 기대 동작을 표로 정리합니다.

profiles

역할/사용자 SELECT INSERT UPDATE DELETE
anon 거부 거부 거부 거부
A → A profile 허용 허용 허용 거부
A → B profile 거부 거부 거부 거부

tasks

역할/사용자 SELECT INSERT UPDATE DELETE
anon 거부 거부 거부 거부
A → A task 허용 허용 허용 허용
A → B task 미노출 거부 변경 없음 삭제 없음
A → owner_id=B 해당 없음 거부 거부 해당 없음

이 표가 테스트의 기대값이 됩니다.

policy에는 대상 역할을 명시합니다

create policy "tasks_select_own"
on public.tasks
for select
to authenticated
using (
  (select auth.uid()) = owner_id
);

비로그인 요청에서 auth.uid()는 null입니다.

사용자 데이터 policy는 authenticated 역할을 명시하고 필요하면 null 조건도 명시해 의도를 분명하게 합니다.

migration에서 GRANT와 RLS를 같이 관리하기

supabase/migrations/07_grants_rls.sql:

begin;

alter table public.profiles
enable row level security;

alter table public.tasks
enable row level security;

revoke all
on table public.profiles
from anon;

revoke all
on table public.tasks
from anon;

grant select, insert, update
on table public.profiles
to authenticated;

grant select, insert, update, delete
on table public.tasks
to authenticated;

commit;

정책 자체는 6회차 migration에 존재합니다.

7회차 파일은 현재 애플리케이션 역할에 필요한 table privilege가 무엇인지 다시 명시하는 보강 migration입니다.

실제 프로젝트에서는 migration history를 임의로 수정하지 않고 새 migration으로 변경 이력을 추가합니다.

데이터베이스 테스트 준비

Supabase CLI의 pgTAP 테스트는 로컬 개발 데이터베이스에서 실행합니다. 5~7회차 migration을 먼저 적용하고, 운영 데이터베이스에는 실행하지 않습니다.

supabase test new tasks_rls.test
supabase test db

아래 완성 파일은 외부 supabase_test_helpers 없이 pgTAP와 PostgreSQL 역할 전환을 사용합니다. CLI 테스트 연결은 fixture를 준비할 수 있는 로컬 관리자 역할에서 시작합니다. 가상 사용자 UUID는 테스트 데이터일 뿐 실제 Auth 회원가입이나 이메일 발송을 수행하지 않습니다. 모든 fixture는 트랜잭션 마지막 rollback으로 원복됩니다.

RLS가 켜져 있는지 먼저 검사

완성 파일의 첫 두 검사는 pg_class.relrowsecurity에서 profiles와 tasks의 RLS 활성화 여부를 확인합니다. 테이블 소유자·관리자 컨텍스트로 fixture를 만든 뒤, 정책 검사는 authenticated 또는 anon 역할로 전환해서 실행해야 합니다.

사용자 두 명 만들기

고정된 가상 UUID A/B를 auth.users, profiles, tasks에 넣고 set local role authenticated와 request.jwt.claim.sub으로 테스트 요청의 신원을 구성합니다. 이 설정은 관리자 권한의 로컬 DB 테스트에서만 사용하며 브라우저 앱이 사용자 신원을 정하는 방식이 아닙니다.

A는 A의 작업만 볼 수 있어야 합니다

results_eq는 사용자 A에게 A task만, 사용자 B에게 B task만 보이는지 확인합니다. 익명 역할에는 SELECT 자체가 거부되어야 합니다. 실제 Supabase Auth 로그인 검증은 별도입니다.

B 작업을 수정해도 변경되면 안 됩니다

UPDATE·DELETE를 담는 WITH 절은 문장 최상위에 놓습니다. 함수 인수 안의 하위 쿼리에 데이터 변경 CTE를 넣으면 PostgreSQL 문법 오류가 발생합니다. RETURNING 행 수를 검사하면 RLS 때문에 다른 사용자 작업이 수정·삭제되지 않았는지 확인할 수 있습니다.

소유권 이전은 거부되어야 합니다

자기 작업의 owner_id를 B로 바꾸거나 B 소유의 작업을 INSERT하면 SQLSTATE 42501로 거부되어야 합니다. 단순히 아무 오류가 나는지만 검사하면 잘못된 SQL도 정책 성공으로 오해할 수 있으므로 기대 SQLSTATE와 메시지를 검사합니다. PostgreSQL 버전·언어 설정이 달라 메시지가 바뀌면 먼저 실제 오류 원인을 확인합니다.

테스트 파일 전체 예시

supabase/tests/tasks_rls.test.sql:

-- Run only against a disposable development/test database, after migrations 05, 06, 07.
-- Everything, including fixtures, is rolled back. No passwords or real identities are used.
begin;
create schema if not exists extensions;
create extension if not exists pgtap with schema extensions;
grant usage on schema extensions to anon, authenticated;
set local search_path = public, extensions;
select plan(12);
select ok((select relrowsecurity from pg_class where oid = 'public.profiles'::regclass), 'profiles RLS enabled');
select ok((select relrowsecurity from pg_class where oid = 'public.tasks'::regclass), 'tasks RLS enabled');

insert into auth.users (id)
values ('00000000-0000-4000-8000-000000000001'),
       ('00000000-0000-4000-8000-000000000002');
insert into public.profiles (id, display_name)
values ('00000000-0000-4000-8000-000000000001', 'A'),
       ('00000000-0000-4000-8000-000000000002', 'B');
insert into public.tasks (owner_id, title)
values ('00000000-0000-4000-8000-000000000001', 'A task'),
       ('00000000-0000-4000-8000-000000000002', 'B task');

set local role authenticated;
select set_config('request.jwt.claim.sub', '00000000-0000-4000-8000-000000000001', true);
select results_eq(
  $$ select title from public.tasks order by title $$,
  $$ values ('A task'::text) $$,
  'A sees only A tasks'
);

with changed as (
  update public.tasks set title = 'changed by A'
  where title = 'B task' returning id
)
select is((select count(*)::integer from changed), 0, 'A cannot update B task');

select throws_ok(
  $$ update public.tasks
     set owner_id = '00000000-0000-4000-8000-000000000002'
     where title = 'A task' $$,
  '42501',
  'new row violates row-level security policy for table "tasks"',
  'A cannot transfer ownership to B'
);

with removed as (
  delete from public.tasks where title = 'B task' returning id
)
select is((select count(*)::integer from removed), 0, 'A cannot delete B task');

with inserted as (
  insert into public.tasks(owner_id, title)
  values ('00000000-0000-4000-8000-000000000001', 'A new task') returning id
)
select is((select count(*)::integer from inserted), 1, 'A may insert own task');

with changed as (
  update public.tasks set status = 'done'
  where title = 'A new task' returning id
)
select is((select count(*)::integer from changed), 1, 'A may update own task');

select throws_ok(
  $$ insert into public.tasks(owner_id, title)
     values ('00000000-0000-4000-8000-000000000002', 'forged') $$,
  '42501',
  'new row violates row-level security policy for table "tasks"',
  'A cannot insert a task owned by B'
);

with removed as (
  delete from public.tasks where title = 'A new task' returning id
)
select is((select count(*)::integer from removed), 1, 'A may delete own task');

select set_config('request.jwt.claim.sub', '00000000-0000-4000-8000-000000000002', true);
select results_eq(
  $$ select title from public.tasks order by title $$,
  $$ values ('B task'::text) $$,
  'B sees only B tasks'
);

reset role;
set local role anon;
select throws_ok(
  $$ select * from public.tasks $$,
  '42501', 'permission denied for table tasks',
  'anonymous cannot read private tasks'
);
reset role;
select * from finish();
rollback;

테스트는 성공 사례만 쓰지 않습니다

allow
owner SELECT/INSERT/UPDATE/DELETE

deny
anon 접근
other user SELECT/UPDATE/DELETE
owner_id 변경

특히 deny 테스트가 사라지면 정책이 넓어졌을 때 발견하기 어렵습니다.

자주 발생하는 오류

secret key로 RLS를 테스트한다

secret key는 service_role로 RLS를 우회하므로 사용자 정책 검증이 되지 않습니다.

정책만 검사하고 GRANT는 검사하지 않는다

permission error와 policy 결과를 구분해야 합니다.

관리자 역할로 정책 검사까지 실행한다

fixture 준비 뒤 authenticated 또는 anon 역할로 전환했는지 확인합니다. 관리자 역할은 RLS를 우회할 수 있어 사용자 권한 테스트를 대신할 수 없습니다.

이 기술만 따로 연습하기

이 글의 완성 실습 ZIP을 별도 폴더에 풀어 단독 샘플로 사용합니다. 이전 회차의 작업 폴더는 필요하지 않습니다. Node.js 24 계열과 ZIP의 package-lock.json을 기준으로 README의 설치·환경 변수 절차를 한 번 진행합니다. 필요한 개발용 Supabase 프로젝트와 공개 가능한 URL·publishable key는 독자가 준비합니다. 실습은 운영 데이터와 분리합니다. 전체 파일은 아래 코드 보기에서도 확인할 수 있습니다.

독립 입력은 supabase/tests/tasks_rls.test.sql 전체 파일과 docs/authorization-matrix.md입니다. 테스트는 고정된 A/B UUID를 자체 fixture로 준비하고 트랜잭션을 되돌리는 구성입니다. 이전 글에서 실제 계정 실험을 끝냈다고 가정하지 않습니다.

  1. 각 assertions를 실행하기 전에 anon, A 자신의 행, A가 B 행 요청의 예상 결과를 권한 행렬에 적습니다. SELECT 0행과 권한 오류를 구별합니다.
  2. README의 schema·policy·grant 순서를 확인합니다. 정책만 있고 테이블 GRANT가 없는 경우가 어느 층에서 막히는지 표시합니다.
  3. 이미 준비한 격리된 Supabase 로컬 테스트 환경에서 supabase test db를 실행합니다. 이 명령은 로컬 Supabase 실행 환경이 별도로 필요하며 ZIP 압축 해제만으로 DB가 생기지는 않습니다.
  4. 실행 환경이 없다면 테스트 파일에서 set local role 및 JWT 사용자 컨텍스트가 바뀌는 위치를 추적하고 예상표를 작성합니다. 이 경우 실행 PASS라고 적지 않습니다.

기대 결과: 실행한 경우 테스트 출력과 권한 행렬이 짝을 이루어야 합니다. 정적 독해만 한 경우 결과물은 예상표이며 데이터베이스 회귀 테스트 통과와 구분됩니다.

자가 확인·변형: 테스트 요청을 관리자 역할로 바꾸면 사용자 격리 테스트가 될까요? RLS를 우회할 수 있어 동일한 검증이 아닙니다. fixture 준비 역할과 요청 검증 역할을 분리해야 합니다.

검증 범위: 실제 Supabase 요청, 계정 생성, 메일 발송, 원격 DB 정책·실행 계획 검증은 이 편집에서 실행하지 않았습니다. 위 결과는 독자가 확인할 기대값입니다.

학습 완료 체크리스트

  • publishable key와 secret key의 권한 차이를 설명할 수 있습니다.
  • API key와 Postgres role을 구분할 수 있습니다.
  • GRANT가 RLS보다 먼저 평가된다는 것을 알고 있습니다.
  • 권한 행렬을 작성할 수 있습니다.
  • allow/deny RLS 테스트를 함께 작성할 수 있습니다.
  • supabase test db의 목적을 설명할 수 있습니다.

선택 실습: 할 일 앱에 적용하기

TaskFlow는 작업을 추가·완료·삭제하는 할 일 앱입니다. 이 선택 실습에서는 데이터 저장·인증·접근 권한을 연결합니다.

이번 주제: Supabase RLS 권한 설계: GRANT·policy·키 경계와 데이터베이스 테스트.

선택: 연재 프로젝트와 전체 자료

연재 프로젝트를 이어 만드는 분을 위한 자료입니다. 이 글의 개념과 예제만 찾으셨다면 필요한 파일과 실행 방법만 펼쳐보세요.

TaskFlow 프로젝트 적용

이전 단계 시작 ZIP을 먼저 별도 작업 폴더에 풉니다. 현재 회차 ZIP은 적용 후 비교할 완성본입니다.

6회차 시작 ZIP에 07_grants_rls.sql, tasks_rls.test.sql, authorization-matrix.md를 추가합니다. 예상표와 자동 검증을 연결하되 실제 DB 실행 전에는 PASS로 기록하지 않습니다.

이전 체크포인트

supabase-step-06-owned-crud

이번 변경 파일

docs/
└─ authorization-matrix.md

supabase/
├─ migrations/
│  └─ 07_grants_rls.sql
└─ tests/
   └─ tasks_rls.test.sql
전체 파일·코드 보기

실습 프로젝트 전체 코드

아래는 이 회차까지의 코드를 누적한 독립 실행용 최소 Next.js App Router 학습 앱 전체입니다. 기존 Next.js 38편 앱 전체를 복제한 것이 아닙니다. ZIP에는 의존성 잠금 파일 package-lock.json도 포함되며 본문에서는 이 자동 생성 잠금 파일만 생략합니다. 기존 TaskFlow 앱을 이어서 사용한다면 기존 설정을 보존하고 해당 회차 변경 파일을 적용하세요.

.env.example

NEXT_PUBLIC_SUPABASE_URL=
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=

.gitignore

node_modules/
.next/
.env.local
*.tsbuildinfo

app/auth/confirmed/page.tsx

export default function AuthConfirmedPage() {
  return (
    <main>
      <h1>이메일 확인 안내</h1>
      <p>메일의 확인 링크를 완료했다면 로그인 화면에서 이메일과 비밀번호로 로그인해 주세요.</p>
      <p>이 페이지를 열었다는 사실만으로 인증 상태를 판정하지 않습니다.</p>
    </main>
  );
}

app/layout.tsx

import type { ReactNode } from "react";
export default function RootLayout({ children }: { children: ReactNode }) { return <html lang="ko"><body>{children}</body></html>; }

app/login/page.tsx

import { AuthForm } from '@/components/auth/auth-form';


export default function LoginPage() {
  return (
    <main>
      <h1>TaskFlow 로그인</h1>
      <AuthForm />
    </main>
  );
}

app/page.tsx

import Link from "next/link";
export default function Home() { return <main><h1>TaskFlow Supabase</h1><Link href="/tasks">작업 목록</Link> <Link href="/login">로그인</Link></main>; }

app/tasks/page.tsx

import { createPublicSupabaseClient } from '@/lib/supabase/public-server';


type PublicTask = {
  id: number;
  title: string;
  created_at: string;
};


export const dynamic = 'force-dynamic';


export default async function TasksPage() {
  const supabase = createPublicSupabaseClient();


  const { data, error } = await supabase
    .from('public_tasks_lab')
    .select('id, title, created_at')
    .order('id');


  if (error) {
    return (
      <main>
        <h1>TaskFlow</h1>
        <p>Supabase 데이터를 불러오지 못했습니다.</p>
        <pre>{error.message}</pre>
      </main>
    );
  }


  const tasks = (data ?? []) as PublicTask[];


  return (
    <main>
      <h1>TaskFlow</h1>


      {tasks.length === 0 ? (
        <p>공개 작업이 없습니다.</p>
      ) : (
        <ul>
          {tasks.map((task) => (
            <li key={task.id}>{task.title}</li>
          ))}
        </ul>
      )}
    </main>
  );
}

components/auth/auth-form.tsx

'use client';


import { FormEvent, useEffect, useState } from 'react';
import type { Session } from '@supabase/supabase-js';


import { createClient } from '@/lib/supabase/client';


export function AuthForm() {
  const supabase = createClient();


  const [session, setSession] = useState<Session | null>(null);
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  const [message, setMessage] = useState('');
  const [pending, setPending] = useState(false);


  useEffect(() => {
    void supabase.auth.getSession().then(({ data }) => {
      setSession(data.session);
    });


    const {
      data: { subscription },
    } = supabase.auth.onAuthStateChange((_event, nextSession) => {
      setSession(nextSession);
    });


    return () => subscription.unsubscribe();
  }, [supabase]);


  async function signUp(event: FormEvent<HTMLFormElement>) {
    event.preventDefault();
    setPending(true);
    setMessage('');


    const { data, error } = await supabase.auth.signUp({
      email,
      password,
    });


    setPending(false);


    if (error) {
      setMessage(error.message);
      return;
    }


    setMessage(
      data.session
        ? '가입과 로그인이 완료되었습니다.'
        : '가입 요청이 완료되었습니다. 이메일을 확인해 주세요.',
    );
  }


  async function signIn() {
    setPending(true);
    setMessage('');


    const { error } = await supabase.auth.signInWithPassword({
      email,
      password,
    });


    setPending(false);
    setMessage(error ? error.message : '로그인했습니다.');
  }


  async function signOut() {
    setPending(true);
    setMessage('');


    const { error } = await supabase.auth.signOut();


    setPending(false);
    setMessage(error ? error.message : '로그아웃했습니다.');
  }


  if (session) {
    return (
      <section>
        <p>로그인 상태입니다.</p>
        <p>{session.user.email}</p>
        <button type="button" onClick={signOut} disabled={pending}>
          로그아웃
        </button>
        {message && <p>{message}</p>}
      </section>
    );
  }


  return (
    <form onSubmit={signUp}>
      <label>
        이메일
        <input
          type="email"
          value={email}
          onChange={(event) => setEmail(event.target.value)}
          required
        />
      </label>


      <label>
        비밀번호
        <input
          type="password"
          value={password}
          onChange={(event) => setPassword(event.target.value)}
          minLength={8}
          required
        />
      </label>


      <button type="submit" disabled={pending}>
        회원가입
      </button>


      <button type="button" onClick={signIn} disabled={pending}>
        로그인
      </button>


      {message && <p>{message}</p>}
    </form>
  );
}

docs/auth-email-template.md

# Auth Email Template


## 대상


Confirm signup


## 제목


TaskFlow 회원가입을 확인해 주세요


## 버튼


이메일 확인하기


## 반드시 유지할 값


ConfirmationURL


## 검증


- 제목 확인
- 본문 확인
- 링크 존재 확인
- 클릭 후 이메일 확인 상태 확인
- redirect 확인


## 주의


2026-06-03 이후 무료 플랜 기본 이메일 발송 인프라의 템플릿 커스터마이징 제한을 발행/적용 시점에 다시 확인한다.

docs/auth-redirect-checklist.md

# Auth Redirect Checklist


- 로컬 Redirect URL 등록
- 운영 Redirect URL 등록
- Site URL 확인
- emailRedirectTo와 허용 URL 일치
- 링크 클릭 후 최종 이동 확인
- 실제 토큰/개인정보를 문서에 기록하지 않음

docs/authorization-matrix.md

# TaskFlow Authorization Matrix


## profiles


anon
- SELECT deny
- INSERT deny
- UPDATE deny
- DELETE deny


authenticated owner
- SELECT allow
- INSERT allow
- UPDATE allow
- DELETE deny


authenticated other
- all deny


## tasks


anon
- all deny


authenticated owner
- SELECT allow
- INSERT allow
- UPDATE allow
- DELETE allow


authenticated other
- SELECT hidden
- INSERT with other's owner_id deny
- UPDATE no rows
- DELETE no rows


ownership transfer
- deny

docs/backend-choice.md

# TaskFlow Backend Choice


## 시작점


next-step-38-cache-update


## 선택


Supabase


## 이유


- PostgreSQL 관계형 데이터 모델을 직접 학습한다.
- Supabase Auth와 Postgres 사용자를 연결한다.
- GRANT와 RLS를 이용해 사용자별 데이터 격리를 학습한다.
- Next.js App Router에서 쿠키 기반 SSR 인증을 연결한다.
- 관계 조회와 인덱스까지 하나의 데이터 계층에서 학습한다.


## 이번 과정에서 사용하는 기능


- PostgreSQL
- Auth
- Data API
- Row Level Security
- @supabase/supabase-js
- @supabase/ssr


## 이번 과정에서 필수로 다루지 않는 기능


- Realtime
- Storage
- Edge Functions


필요 기능이 아니므로 과정 범위를 무리하게 늘리지 않는다.


## 보안 원칙


- 브라우저에는 publishable key만 사용한다.
- secret key는 클라이언트에 포함하지 않는다.
- 사용자 데이터는 RLS를 활성화한다.
- 화면에서 숨기는 것으로 권한 검증을 대신하지 않는다.
- 실제 인증 토큰과 개인정보를 학습 자료에 저장하지 않는다.


## 백엔드 트랙


Firebase 완료본을 이어서 수정하지 않는다.


Next.js TaskFlow 최종 체크포인트에서 별도 Supabase 브랜치를 시작한다.


## 다음 단계


Supabase 프로젝트를 만들고 공개 학습 데이터를 한 번 조회한다.

docs/data-model.md

# TaskFlow Supabase Data Model


## Auth


auth.users
- Supabase Auth 계정
- 앱 프로필 데이터를 직접 추가하지 않음


## profiles


public.profiles
- id → auth.users.id
- display_name
- created_at


## tasks


public.tasks
- id
- owner_id → public.profiles.id
- title
- status
- created_at
- updated_at


## 관계


auth.users
→ public.profiles
→ public.tasks


## 보안 상태


profiles RLS enabled
tasks RLS enabled


anon 권한 없음
authenticated 권한 없음


6회차에서 필요한 GRANT와 RLS policy를 추가한다.

email-template/complete/confirmation.html

<h2>TaskFlow 회원가입을 확인해 주세요</h2>


<p>
  TaskFlow 회원가입이 요청되었습니다.
</p>


<p>
  아래 버튼을 눌러 이메일 확인을 완료해 주세요.
</p>


<p>
  <a href="{{ .ConfirmationURL }}">
    이메일 확인하기
  </a>
</p>


<p>
  회원가입을 요청하지 않았다면 이 메일을 무시해 주세요.
</p>

email-template/starter/confirmation.html

<h2>Confirm your signup</h2>


<p>Follow this link to confirm your user:</p>


<p>
  <a href="{{ .ConfirmationURL }}">
    Confirm your mail
  </a>
</p>

lib/supabase/client.ts

'use client';


import { createClient as createSupabaseClient, type SupabaseClient } from '@supabase/supabase-js';


let client: SupabaseClient | undefined;


export function createClient() {
  if (client) {
    return client;
  }


  const url = process.env.NEXT_PUBLIC_SUPABASE_URL;
  const key = process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY;


  if (!url || !key) {
    throw new Error('Supabase 공개 설정이 필요합니다.');
  }


  client = createSupabaseClient(url, key);


  return client;
}

lib/supabase/public-server.ts

import 'server-only';


import { createClient } from '@supabase/supabase-js';


export function createPublicSupabaseClient() {
  const url = process.env.NEXT_PUBLIC_SUPABASE_URL;
  const key = process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY;


  if (!url || !key) {
    throw new Error('Supabase 공개 설정이 필요합니다.');
  }


  return createClient(url, key, {
    auth: {
      persistSession: false,
      autoRefreshToken: false,
      detectSessionInUrl: false,
    },
  });
}

lib/tasks/supabase-task-repository.ts

import type {
  CreateTaskInput,
  Task,
  TaskStatus,
} from '@/lib/types/task';


import { createClient } from '@/lib/supabase/client';


async function getCurrentUserId() {
  const supabase = createClient();


  const {
    data: { user },
    error,
  } = await supabase.auth.getUser();


  if (error || !user) {
    throw new Error('로그인이 필요합니다.');
  }


  return user.id;
}


export async function ensureProfile() {
  const supabase = createClient();
  const userId = await getCurrentUserId();


  const { data, error } = await supabase
    .from('profiles')
    .select('id')
    .eq('id', userId)
    .maybeSingle();


  if (error) {
    throw error;
  }


  if (data) {
    return;
  }


  const { error: insertError } = await supabase
    .from('profiles')
    .insert({
      id: userId,
      display_name: null,
    });


  if (insertError) {
    throw insertError;
  }
}


export async function getTasks() {
  const supabase = createClient();


  const { data, error } = await supabase
    .from('tasks')
    .select(
      'id, owner_id, title, status, created_at, updated_at',
    )
    .order('created_at', {
      ascending: false,
    });


  if (error) {
    throw error;
  }


  return (data ?? []) as Task[];
}


export async function createTask(
  input: CreateTaskInput,
) {
  const supabase = createClient();
  const userId = await getCurrentUserId();


  const title = input.title.trim();


  if (!title) {
    throw new Error('작업 제목을 입력해 주세요.');
  }


  const { data, error } = await supabase
    .from('tasks')
    .insert({
      owner_id: userId,
      title,
      status: 'todo',
    })
    .select(
      'id, owner_id, title, status, created_at, updated_at',
    )
    .single();


  if (error) {
    throw error;
  }


  return data as Task;
}


export async function updateTaskStatus(
  taskId: string,
  status: TaskStatus,
) {
  const supabase = createClient();


  const { data, error } = await supabase
    .from('tasks')
    .update({
      status,
      updated_at: new Date().toISOString(),
    })
    .eq('id', taskId)
    .select(
      'id, owner_id, title, status, created_at, updated_at',
    )
    .maybeSingle();


  if (error) {
    throw error;
  }


  return data as Task | null;
}


export async function deleteTask(
  taskId: string,
) {
  const supabase = createClient();


  const { data, error } = await supabase
    .from('tasks')
    .delete()
    .eq('id', taskId)
    .select('id')
    .maybeSingle();


  if (error) {
    throw error;
  }


  return data;
}

lib/types/task.ts

export type TaskStatus =
  | 'todo'
  | 'done';


export type Task = {
  id: string;
  owner_id: string;
  title: string;
  status: TaskStatus;
  created_at: string;
  updated_at: string;
};


export type CreateTaskInput = {
  title: string;
};

next-env.d.ts

/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts";
import "./.next/types/root-params.d.ts";

// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

package.json

{
  "name": "taskflow-supabase-checkpoint",
  "private": true,
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start",
    "typecheck": "tsc --noEmit"
  },
  "dependencies": {
    "next": "16.3.5",
    "react": "19.3.0",
    "react-dom": "19.3.0",
    "@supabase/supabase-js": "2.116.0",
    "server-only": "0.0.1"
  },
  "devDependencies": {
    "typescript": "5.9.3",
    "@types/node": "24.10.1",
    "@types/react": "19.2.7",
    "@types/react-dom": "19.2.3"
  }
}

README.md

# Supabase 07 완료 체크포인트 — rls-policy-test

원문 회차 코드를 누적한 독립 실행용 최소 Next.js App Router 학습 앱입니다. 이전 Next.js 38편 앱 전체나 추가 CRUD 화면을 복제한 프로젝트가 아닙니다.

Node.js 24.18.0, npm 11.16.0 기준입니다.

1. `npm ci`
2. `.env.example`을 `.env.local`로 복사하고 개발 전용 Supabase URL/publishable key를 입력합니다.
3. 아래 순서대로 개발 DB에 해당 SQL을 한 번씩 적용합니다. 같은 객체가 있으면 덮어쓰지 않습니다.
4. `npm run dev` 후 `/tasks`를 확인합니다.
5. `npm run typecheck` 및 `npm run build`를 실행합니다.

SQL: `supabase/learning/02_public_tasks_lab.sql` → `supabase/migrations/05_taskflow_schema.sql` → `06_profiles_tasks_rls.sql` → `07_grants_rls.sql`.

`/login`은 이메일/비밀번호 인증 화면입니다. 실제 가입/확인 메일은 본인 개발 프로젝트에서 직접 검증하세요.
이 회차는 `lib/tasks/supabase-task-repository.ts`의 CRUD 함수를 학습합니다. 새 CRUD UI는 포함하지 않으며 `/tasks`는 2편 공개 조회 화면입니다. 개인 작업 서버 조회 화면은 8편부터 교체됩니다.
`supabase/tests/tasks_rls.test.sql`은 pgTAP이 가능한 폐기 가능한 개발 DB에서 실행할 자급형 transaction 테스트입니다. 고정 가상 UUID만 쓰며 끝에 rollback합니다. 운영 DB에서 실행하지 마세요. `tests.*` 외부 helper는 필요 없습니다. Supabase CLI의 `supabase test db`는 별도의 로컬 Supabase/Docker 환경을 요구하므로 이 프로젝트 환경에서는 실행하지 않았습니다.

실제 secret/service_role 키, 비밀번호, 원격 DB 계정은 포함하지 않습니다. node_modules/.next/.env.local은 제외했습니다. ZIP의 package-lock.json으로 정확한 의존성을 재현하세요.

supabase/learning/02_public_tasks_lab.sql

begin;


create table public.public_tasks_lab (
  id bigint generated always as identity primary key,
  title text not null,
  created_at timestamptz not null default now()
);


insert into public.public_tasks_lab (title)
values
  ('Supabase 연결 확인'),
  ('RLS 공개 읽기 확인');


alter table public.public_tasks_lab
enable row level security;


revoke all
on table public.public_tasks_lab
from public, anon, authenticated;


grant select
on table public.public_tasks_lab
to anon, authenticated;


create policy "public_tasks_lab_read"
on public.public_tasks_lab
for select
to anon, authenticated
using (true);


commit;

supabase/migrations/05_taskflow_schema.sql

begin;


create table public.profiles (
  id uuid primary key
    references auth.users(id)
    on delete cascade,
  display_name text,
  created_at timestamptz not null default now()
);


create table public.tasks (
  id uuid primary key default gen_random_uuid(),
  owner_id uuid not null
    references public.profiles(id)
    on delete cascade,
  title text not null,
  status text not null default 'todo',
  created_at timestamptz not null default now(),
  updated_at timestamptz not null default now(),


  constraint tasks_status_check
    check (status in ('todo', 'done')),


  constraint tasks_title_check
    check (
      char_length(btrim(title))
      between 1 and 120
    )
);


create index tasks_owner_id_idx
on public.tasks(owner_id);


alter table public.profiles
enable row level security;


alter table public.tasks
enable row level security;


revoke all
on table public.profiles
from public, anon, authenticated;


revoke all
on table public.tasks
from public, anon, authenticated;


commit;

supabase/migrations/06_profiles_tasks_rls.sql

begin;


grant select, insert, update
on table public.profiles
to authenticated;


grant select, insert, update, delete
on table public.tasks
to authenticated;


create policy "profiles_select_self"
on public.profiles
for select
to authenticated
using (
  (select auth.uid()) is not null
  and (select auth.uid()) = id
);


create policy "profiles_insert_self"
on public.profiles
for insert
to authenticated
with check (
  (select auth.uid()) is not null
  and (select auth.uid()) = id
);


create policy "profiles_update_self"
on public.profiles
for update
to authenticated
using (
  (select auth.uid()) = id
)
with check (
  (select auth.uid()) = id
);


create policy "tasks_select_own"
on public.tasks
for select
to authenticated
using (
  (select auth.uid()) is not null
  and (select auth.uid()) = owner_id
);


create policy "tasks_insert_own"
on public.tasks
for insert
to authenticated
with check (
  (select auth.uid()) is not null
  and (select auth.uid()) = owner_id
);


create policy "tasks_update_own"
on public.tasks
for update
to authenticated
using (
  (select auth.uid()) = owner_id
)
with check (
  (select auth.uid()) = owner_id
);


create policy "tasks_delete_own"
on public.tasks
for delete
to authenticated
using (
  (select auth.uid()) = owner_id
);


commit;

supabase/migrations/07_grants_rls.sql

begin;


alter table public.profiles
enable row level security;


alter table public.tasks
enable row level security;


revoke all
on table public.profiles
from anon;


revoke all
on table public.tasks
from anon;


grant select, insert, update
on table public.profiles
to authenticated;


grant select, insert, update, delete
on table public.tasks
to authenticated;


commit;

supabase/tests/tasks_rls.test.sql

-- Run only against a disposable development/test database, after migrations 05, 06, 07.
-- Everything, including fixtures, is rolled back. No passwords or real identities are used.
begin;
create schema if not exists extensions;
create extension if not exists pgtap with schema extensions;
grant usage on schema extensions to anon, authenticated;
set local search_path = public, extensions;
select plan(12);
select ok((select relrowsecurity from pg_class where oid = 'public.profiles'::regclass), 'profiles RLS enabled');
select ok((select relrowsecurity from pg_class where oid = 'public.tasks'::regclass), 'tasks RLS enabled');

insert into auth.users (id)
values ('00000000-0000-4000-8000-000000000001'),
       ('00000000-0000-4000-8000-000000000002');
insert into public.profiles (id, display_name)
values ('00000000-0000-4000-8000-000000000001', 'A'),
       ('00000000-0000-4000-8000-000000000002', 'B');
insert into public.tasks (owner_id, title)
values ('00000000-0000-4000-8000-000000000001', 'A task'),
       ('00000000-0000-4000-8000-000000000002', 'B task');

set local role authenticated;
select set_config('request.jwt.claim.sub', '00000000-0000-4000-8000-000000000001', true);
select results_eq(
  $$ select title from public.tasks order by title $$,
  $$ values ('A task'::text) $$,
  'A sees only A tasks'
);

with changed as (
  update public.tasks set title = 'changed by A'
  where title = 'B task' returning id
)
select is((select count(*)::integer from changed), 0, 'A cannot update B task');

select throws_ok(
  $$ update public.tasks
     set owner_id = '00000000-0000-4000-8000-000000000002'
     where title = 'A task' $$,
  '42501',
  'new row violates row-level security policy for table "tasks"',
  'A cannot transfer ownership to B'
);

with removed as (
  delete from public.tasks where title = 'B task' returning id
)
select is((select count(*)::integer from removed), 0, 'A cannot delete B task');

with inserted as (
  insert into public.tasks(owner_id, title)
  values ('00000000-0000-4000-8000-000000000001', 'A new task') returning id
)
select is((select count(*)::integer from inserted), 1, 'A may insert own task');

with changed as (
  update public.tasks set status = 'done'
  where title = 'A new task' returning id
)
select is((select count(*)::integer from changed), 1, 'A may update own task');

select throws_ok(
  $$ insert into public.tasks(owner_id, title)
     values ('00000000-0000-4000-8000-000000000002', 'forged') $$,
  '42501',
  'new row violates row-level security policy for table "tasks"',
  'A cannot insert a task owned by B'
);

with removed as (
  delete from public.tasks where title = 'A new task' returning id
)
select is((select count(*)::integer from removed), 1, 'A may delete own task');

select set_config('request.jwt.claim.sub', '00000000-0000-4000-8000-000000000002', true);
select results_eq(
  $$ select title from public.tasks order by title $$,
  $$ values ('B task'::text) $$,
  'B sees only B tasks'
);

reset role;
set local role anon;
select throws_ok(
  $$ select * from public.tasks $$,
  '42501', 'permission denied for table tasks',
  'anonymous cannot read private tasks'
);
reset role;
select * from finish();
rollback;

tsconfig.json

{
  "compilerOptions": {
    "target": "ES2017",
    "lib": [
      "dom",
      "dom.iterable",
      "esnext"
    ],
    "skipLibCheck": true,
    "strict": true,
    "noEmit": true,
    "esModuleInterop": true,
    "module": "esnext",
    "moduleResolution": "bundler",
    "resolveJsonModule": true,
    "isolatedModules": true,
    "jsx": "react-jsx",
    "incremental": true,
    "plugins": [
      {
        "name": "next"
      }
    ],
    "paths": {
      "@/*": [
        "./*"
      ]
    },
    "allowJs": true
  },
  "include": [
    "next-env.d.ts",
    "**/*.ts",
    "**/*.tsx",
    ".next/types/**/*.ts",
    ".next/dev/types/**/*.ts"
  ],
  "exclude": [
    "node_modules"
  ]
}

실행·결과 확인

supabase test db

정상적으로 준비된 로컬 환경의 기대 결과는 PASS입니다.

이 글 작성 시 실제 CLI 테스트를 실행하지 않았다면 출력 예시는 “예상 결과”로 표시하고, 실제 PASS라고 단정하지 않습니다.

완료 체크포인트

supabase-step-07-rls-policy-test

프로젝트 적용 후 확인 기록

독자 질문: 수동으로 통과한 A/B 정책이 migration 이후에도 그대로 유지되는가?

따라 할 단계

  1. docs/authorization-matrix.md에 anon, A, B 각각의 SELECT·INSERT·UPDATE·DELETE 예상 결과를 기록한다.
  2. 테스트 fixture에서 A/B 계정을 준비하고 각 요청을 해당 사용자 역할과 JWT 컨텍스트로 실행한다.
  3. supabase test db를 실행할 수 있는 로컬 환경에서 허용 케이스와 거부 케이스를 함께 확인하고 정책을 변경한 뒤 재실행한다.

예상 결과와 A/B 계정 범위

  • A 자기 행과 B 자기 행은 허용, 상대방 행 SELECT/UPDATE/DELETE와 상대방 owner_id INSERT는 거부되거나 0행이어야 한다. anon은 tasks 작업이 거부되어야 한다.
  • 테스트 PASS는 명령을 실제 실행한 경우에만 기록한다. 이 글 편집 중 실제 supabase test db와 A/B 요청은 실행하지 않아 미확인이다.

흔한 오류 점검

  • 관리자/service_role로 테스트하면 RLS를 우회할 수 있으므로 fixture 준비와 사용자 역할 테스트를 분리한다.
  • policy가 있어도 GRANT가 없으면 요청이 실패한다. UPDATE에는 기존 행 USING과 변경 후 행 WITH CHECK를 함께 점검한다.

검증 기록: 이번 편집에서 실제 Supabase 요청·A/B 계정 실험은 실행하지 않았으므로 미확인입니다.

다음 학습

8회차에서 동일한 A/B 세션이 브라우저와 서버 요청에 전달되는지 확인한다.

다음 학습

다음 글에서는 @supabase/ssr을 설치해 브라우저 중심 Auth를 쿠키 기반 SSR 구조로 전환합니다.

다음 글:

Next.js Supabase SSR 인증: @supabase/ssr와 쿠키 설정

공식 문서

확인일: 2026-09-21

체크포인트 다운로드

supabase-step-07-rls-policy-test 완료 ZIP 다운로드

시작 체크포인트: 6회차 완료 ZIP

글 소개·학습 준비·전체 목차
방패와 데이터베이스로 표현한 RLS 권한과 정책 테스트 표지

Supabase · 7 / 9 · 고급 학습 · RLS 권한 테스트

GRANT는 역할이 테이블에서 수행할 작업을, RLS policy는 그 작업이 접근할 행을 제한합니다. 사용자 정책을 시험할 때에는 일반 사용자 역할로 자기 행 허용과 타인 행 거부를 함께 확인해야 합니다.

권한 행렬에는 익명 요청, A 자신의 행, A가 B의 행을 요청하는 경우와 소유권 이전을 나눠 적습니다. 로컬 pgTAP 테스트는 이 기대값을 반복 검사하며 실제 이메일 가입·로그인 검증을 대신하지 않습니다.

이번 글에서는 먼저 권한 행렬을 문서로 고정하고, GRANT와 RLS policy를 migration으로 관리한 뒤, pgTAP 기반 데이터베이스 테스트로 정책 회귀를 확인합니다.

이번 글에서 배우는 내용

  • publishable key와 secret key
  • legacy anon / service_role 키
  • Postgres의 anon / authenticated / service_role 역할
  • GRANT와 RLS policy의 실행 순서
  • policy의 TO authenticated
  • auth.uid()가 비로그인에서 null인 이유
  • 권한 행렬 작성
  • pgTAP 데이터베이스 테스트
  • supabase test db
  • 허용 테스트와 거부 테스트를 함께 작성하는 방법

선수 지식

  • 6회차 profiles/tasks RLS
  • SELECT / INSERT / UPDATE / DELETE
  • Supabase CLI 기본
  • 로컬 Supabase 프로젝트 구조

목차

  1. API key와 Postgres role은 같은 개념이 아닙니다
  2. secret key도 GRANT 오류까지 없애는 것은 아닙니다
  3. 권한 행렬을 먼저 작성하기
  4. policy에는 대상 역할을 명시합니다
  5. migration에서 GRANT와 RLS를 같이 관리하기
  6. 데이터베이스 테스트 준비
  7. RLS가 켜져 있는지 먼저 검사
  8. 사용자 두 명 만들기
  9. A는 A의 작업만 볼 수 있어야 합니다
  10. B 작업을 수정해도 변경되면 안 됩니다
  11. 소유권 이전은 거부되어야 합니다
  12. 테스트 파일 전체 예시
  13. 테스트는 성공 사례만 쓰지 않습니다
  14. 자주 발생하는 오류
  15. 학습 완료 체크리스트
  16. TaskFlow 프로젝트 적용
  17. 실습 프로젝트 전체 코드
  18. 실행·결과 확인
  19. 다음 학습
  20. 공식 문서
  21. 단독 실습과 결과 확인

이 글이 도움이 되었나요?

조회 중

분류별 과정 이동 (선택)

읽음 기록

읽음 — / 전체 9개 · 필수 8개

이 글을 쓴 해비

웹사이트를 제작·운영하고, 개인 서비스와 자동화·AI 도구를 만듭니다. 직접 만들며 배운 내용과 문제 해결 과정을 BlogFlow에 기록합니다.

새 글 받아보기

RSS 리더에서 BlogFlow의 새 글을 확인할 수 있습니다.

RSS 피드 구독하기

댓글 남기기