Supabase 사용자별 CRUD와 RLS: 두 계정으로 데이터 격리 검증하기

2026.09.05·수정 2026.09.13·약 50분·작성: 해비·블로그 소개

내 노트만 읽고 고치는 권한 실습

로그인한 두 사용자가 각자의 학습 노트를 추가·조회·수정·삭제하는 작은 화면을 만듭니다. 같은 SQL을 로컬 PostgreSQL에서 실행해 RLS를 확인하고, 실제 프로젝트에서는 계정 A와 B로 서로의 노트를 건드릴 수 없는지 별도로 검사합니다.

목차

1. 화면의 로그인과 데이터의 소유권 나누기

앞선 Next.js와 Supabase 연결 글에서 프로젝트 URL, 클라이언트 생성, 첫 조회를 익혔다면 이제 개인 데이터를 다룰 차례입니다. 이번에는 HTML 폼과 JavaScript 이벤트에 집중할 수 있도록 별도 Vite 폴더에서 실습합니다. 기존 Next.js 앱의 파일을 덮어쓰지 않습니다. 데이터 권한을 이해한 뒤 같은 쿼리를 Next.js의 실행 위치에 맞게 옮길 수 있습니다.

완성할 기능은 개인 학습 노트입니다. 제목 하나를 저장하며, 사용자 A의 노트는 A만 다룰 수 있고 B의 노트는 B만 다룰 수 있어야 합니다. 로그아웃 상태에는 테이블 권한을 주지 않습니다. CSS로 버튼을 숨기거나 목록에서 타인의 항목을 빼는 것은 화면 처리입니다. 다른 요청을 직접 보내더라도 같은 접근 규칙이 유지되어야 합니다.

사용자별 데이터 소유 범위를 분리합니다.
사용자별 데이터 소유 범위를 분리합니다.

publishable key는 앱을 식별하는 공개 설정이고 로그인 세션은 사용자를 식별합니다. 같은 공개 키로 접속해도 비로그인 요청은 anon, 로그인 요청은 authenticated 역할을 사용합니다. sb_secret_ 키나 이전 service_role 키는 RLS 검사용 클라이언트에 넣지 않습니다. Supabase API 키 문서에서 두 권한의 차이를 확인할 수 있습니다.

확인할 것 이 실습의 기준
소유자 learning_notes.owner_id와 요청의 auth.uid()가 같습니다.
추가 새 행의 소유자가 본인이어야 합니다.
수정 기존 행도 본인 소유이고 수정 결과도 본인 소유여야 합니다.
비로그인 SELECT·INSERT·UPDATE·DELETE 모두 거부합니다.

2. 테이블과 네 가지 RLS 정책 만들기

기존 서비스 데이터와 분리된 개발용 Supabase 프로젝트를 준비합니다. learning_notes라는 테이블이 없는지 먼저 확인하고, 다음 내용을 schema.sql로 저장합니다. Supabase SQL Editor에서 이 파일의 전체 SQL을 한 번 실행하세요. 이름이 이미 있다면 중단하고 기존 용도를 확인합니다. 예제는 테이블을 지우거나 기존 정책을 덮어쓰지 않도록 작성했습니다.

schema.sql

begin;
create table public.learning_notes (
  id uuid primary key default gen_random_uuid(),
  owner_id uuid not null default auth.uid()
    references auth.users(id) on delete cascade,
  title text not null check (char_length(btrim(title)) between 1 and 120),
  created_at timestamptz not null default now()
);
create index learning_notes_owner_id_idx on public.learning_notes(owner_id);
alter table public.learning_notes enable row level security;
revoke all on table public.learning_notes from public, anon, authenticated;
grant select, insert, update, delete on table public.learning_notes to authenticated;
create policy notes_read on public.learning_notes
  for select to authenticated using ((select auth.uid()) = owner_id);
create policy notes_create on public.learning_notes
  for insert to authenticated with check ((select auth.uid()) = owner_id);
create policy notes_change on public.learning_notes
  for update to authenticated
  using ((select auth.uid()) = owner_id)
  with check ((select auth.uid()) = owner_id);
create policy notes_remove on public.learning_notes
  for delete to authenticated using ((select auth.uid()) = owner_id);
commit;

id는 노트 식별자, owner_id는 작성자 계정 식별자입니다. auth.users 외래 키는 존재하는 계정에 연결하고, 계정을 삭제하면 그 계정의 노트도 삭제되도록 했습니다. 제목은 앞뒤 일반 공백을 제거해 계산한 길이가 1~120자여야 하며, 내부 공백은 글자 수에 포함합니다. 이 CHECK 조건은 길이만 검사하고 저장 값을 바꾸지는 않습니다. 로그인한 요청이 소유자 값을 생략하면 default auth.uid()가 채웁니다. 값이 자동으로 들어가는 편의와 접근 허용 여부는 별개이므로 정책도 함께 둡니다.

GRANT는 어떤 작업을 할 수 있는지, RLS는 어떤 행에 적용할 수 있는지 결정합니다. USING은 기존 행, WITH CHECK는 추가·수정 후 행을 검사합니다. UPDATE에는 SELECT 정책도 필요합니다. 이 예제는 각 작업을 별도 정책으로 둡니다. Supabase RLS 문서의 작업별 정책 설명과 비교해 보세요.

특히 수정 정책의 두 조건을 함께 읽어 보세요. A가 자기 노트를 수정할 수 있어도 owner_id를 B의 ID로 바꾸면 결과 행이 조건에 맞지 않아 거부됩니다. 이 정책은 소유권을 보호합니다. 제목 이외의 모든 열을 불변으로 만드는 정책은 아니므로, 생성 시각이나 ID도 변경 금지가 필요하다면 별도의 열 권한 설계를 추가해야 합니다. 열 단위 권한 문서를 다음 확장 자료로 사용할 수 있습니다.

3. 로그인과 CRUD 화면 연결하기

Node.js 공식 다운로드에서 운영체제에 맞는 Node.js 24와 npm을 설치합니다. 터미널에서 node -vv24.로 시작하고 npm -v가 버전을 출력하는지 확인한 뒤 빈 폴더에 아래 파일을 만듭니다. 의존성 버전은 이 글의 로컬 검증에 사용한 값으로 고정했습니다. 예제에서는 npm을 처음 실행해 생성된 package-lock.json도 함께 보관해 같은 설치를 재현합니다.

package.json

{
  "name": "supabase-owned-notes",
  "version": "1.0.0",
  "description": "",
  "scripts": {
    "dev": "vite",
    "build": "vite build",
    "test": "node rls.test.mjs"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "type": "module",
  "dependencies": {
    "@electric-sql/pglite": "0.5.8",
    "@supabase/supabase-js": "2.115.0",
    "vite": "8.2.2"
  },
  "private": true,
  "engines": {
    "node": ">=24.19.0 <25"
  }
}

.env.example.env.local로 복사합니다. 빈 두 값에 본인 개발 프로젝트의 URL과 sb_publishable_로 시작하는 공개 키를 채우세요. 파일에 넣을 실제 값은 Supabase의 Connect 또는 API Keys 화면에서 확인합니다. 비밀번호와 관리자 키는 넣지 않습니다. Vite에서는 VITE_ 접두사 값이 브라우저 번들에 포함됩니다.

.env.example

VITE_SUPABASE_URL=
VITE_SUPABASE_PUBLISHABLE_KEY=

.gitignore

node_modules/
dist/
dist-test/
.next/
.expo/
playwright-report/
test-results/
output/
*.tsbuildinfo
.env
.env.local
.env.*.local

폼에 label과 상태 안내 영역을 두었습니다. HTML을 저장한 뒤 JavaScript에서 로그인 결과에 따라 폼과 노트 영역을 표시합니다. 기존 프로젝트의 스타일을 가져오기 전에 기본 동작부터 확인하세요.

index.html

<!doctype html>
<html lang="ko">
<head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>내 학습 노트</title></head>
<body>
  <main>
    <h1>내 학습 노트</h1>
    <p id="identity">로그인 상태 확인 중</p>
    <p id="status" role="status" aria-live="polite"></p>
    <form id="login">
      <label>이메일 <input name="email" type="email" autocomplete="username" required></label>
      <label>비밀번호 <input name="password" type="password" autocomplete="current-password" required></label>
      <button>로그인</button>
    </form>
    <section id="notes" hidden>
      <button id="logout" type="button">로그아웃</button>
      <form id="create">
        <label>새 노트 제목 <input name="title" maxlength="120" required></label>
        <button>추가</button>
      </form>
      <button id="refresh" type="button">목록 새로고침</button>
      <ul id="list"></ul>
    </section>
  </main>
  <script type="module" src="/main.js"></script>
</body>
</html>

다음 코드는 생성 시 소유자 값을 보내지 않고 제목만 보냅니다. 조회에는 일부러 owner_id 필터를 넣지 않았습니다. 필터 없이 요청해도 데이터베이스가 자기 행만 돌려주어야 하기 때문입니다. 수정·삭제에는 대상 노트의 ID 필터를 두고 .select("id")로 영향을 받은 행을 확인합니다. UPDATE 반환값 문서DELETE 문서를 참고하세요.

main.js

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

const url = import.meta.env.VITE_SUPABASE_URL;
const key = import.meta.env.VITE_SUPABASE_PUBLISHABLE_KEY;
if (!url || !key?.startsWith('sb_publishable_')) {
  throw new Error('프로젝트 URL과 publishable key를 .env.local에 설정하세요.');
}
export const supabase = createClient(url, key);
const login = document.querySelector('#login');
const notes = document.querySelector('#notes');
const list = document.querySelector('#list');
const status = document.querySelector('#status');
let busy = false;
function oneRow(result) {
  if (result.error) throw result.error;
  if (result.data?.length !== 1) throw new Error('변경된 노트가 없습니다. 삭제 여부와 접근 권한을 확인하세요.');
}
async function action(task) {
  if (busy) return;
  busy = true;
  document.querySelectorAll('button').forEach((button) => { button.disabled = true; });
  status.textContent = '처리 중';
  try {
    await task();
    status.textContent = '완료';
  } catch (error) {
    status.textContent = `${error.code ? `[${error.code}] ` : ''}${error.message}`;
  } finally {
    busy = false;
    document.querySelectorAll('button').forEach((button) => { button.disabled = false; });
  }
}
async function loadNotes() {
  const { data, error } = await supabase.from('learning_notes')
    .select('id, owner_id, title, created_at').order('created_at', { ascending: false });
  if (error) throw error;
  list.replaceChildren();
  for (const note of data) {
    const li = document.createElement('li');
    const title = document.createElement('span');
    title.textContent = `${note.title} (${note.id}) `;
    const edit = document.createElement('button');
    edit.textContent = '수정';
    edit.onclick = () => {
      const value = window.prompt('새 제목', note.title);
      if (value === null || value.trim() === '') return;
      action(async () => {
        oneRow(await supabase.from('learning_notes').update({ title: value.trim() })
          .eq('id', note.id).select('id'));
        await loadNotes();
      });
    };
    const remove = document.createElement('button');
    remove.textContent = '삭제';
    remove.onclick = () => {
      if (!window.confirm('이 노트를 삭제할까요?')) return;
      action(async () => {
        oneRow(await supabase.from('learning_notes').delete().eq('id', note.id).select('id'));
        await loadNotes();
      });
    };
    li.append(title, edit, remove);
    list.append(li);
  }
  if (data.length === 0) {
    const empty = document.createElement('li');
    empty.textContent = '작성한 노트가 없습니다.';
    list.append(empty);
  }
}
async function syncScreen() {
  list.replaceChildren();
  const { data, error } = await supabase.auth.getSession();
  if (error) throw error;
  const user = data.session?.user;
  login.hidden = Boolean(user);
  notes.hidden = !user;
  document.querySelector('#identity').textContent = user ? `${user.email} / ${user.id}` : '로그인해 주세요.';
  if (user) await loadNotes();
}
login.addEventListener('submit', (event) => {
  event.preventDefault();
  const form = new FormData(login);
  action(async () => {
    const { error } = await supabase.auth.signInWithPassword({
      email: String(form.get('email')), password: String(form.get('password')),
    });
    if (error) throw error;
    login.reset();
    await syncScreen();
  });
});
document.querySelector('#create').addEventListener('submit', (event) => {
  event.preventDefault();
  const form = event.currentTarget;
  const title = String(new FormData(form).get('title')).trim();
  if (!title) return;
  action(async () => {
    oneRow(await supabase.from('learning_notes').insert({ title }).select('id'));
    form.reset();
    await loadNotes();
  });
});
document.querySelector('#logout').onclick = () => action(async () => {
  const { error } = await supabase.auth.signOut();
  if (error) throw error;
  await syncScreen();
});
document.querySelector('#refresh').onclick = () => action(syncScreen);
// The callback itself is synchronous; Supabase work runs after it returns.
supabase.auth.onAuthStateChange(() => { setTimeout(() => { if (!busy) action(syncScreen); }, 0); });
action(syncScreen);

제목은 textContent로 표시하여 사용자가 입력한 HTML을 실행하지 않습니다. getSession()은 이 브라우저 화면의 표시 상태를 정하는 데만 사용합니다. 실제 데이터 접근은 요청 세션을 받은 데이터베이스의 정책이 판단합니다. 상태 오류와 빈 목록도 구분했으며, 변경 결과가 0행이면 완료로 오인하지 않도록 메시지를 표시합니다. 로그인 요청의 형태는 signInWithPassword 문서에서 확인할 수 있습니다.

아래 복사 명령은 Bash 기준입니다. Windows 명령 프롬프트에서는 copy .env.example .env.local을 사용하거나 편집기에서 같은 내용으로 .env.local 파일을 만들어도 됩니다.

npm install
cp .env.example .env.local
# 편집기로 .env.local의 두 값을 채운 뒤 실행합니다.
npm run dev

터미널에 표시된 로컬 주소를 엽니다. 서버를 시작한 뒤 환경 값을 바꾸었다면 다시 시작합니다. 아직 가입 계정이 없다면 앞선 회원가입 흐름을 완료하거나 개발 프로젝트의 Auth 사용자 관리에서 테스트용 계정 A와 B를 준비하고, 이메일 확인이 필요한 설정에서는 확인까지 마칩니다. 이 화면은 이미 준비된 계정으로 로그인하는 실습입니다.

4. 같은 SQL을 로컬 PostgreSQL에서 검증하기

로그인 여부와 행 접근 권한을 각각 확인합니다.
로그인 여부와 행 접근 권한을 각각 확인합니다.

권한 코드를 눈으로 읽는 것만으로 끝내지 않고 실제 PostgreSQL 엔진에 요청을 보냅니다. PGlite 공식 문서의 인메모리 실행 방식을 사용합니다. 다음 테스트는 매 실행마다 새 데이터베이스를 만들며, 바로 위 schema.sql을 그대로 읽습니다. 테스트 전용으로 auth.usersauth.uid()를 흉내 내는 부분만 준비합니다. 이 모의 Auth SQL을 Supabase SQL Editor에 실행하면 안 됩니다.

rls.test.mjs

import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import { PGlite } from '@electric-sql/pglite';

// Only this disposable database receives the Auth stub.
const db = new PGlite();
const A = '11111111-1111-4111-8111-111111111111';
const B = '22222222-2222-4222-8222-222222222222';
let passed = 0;
async function check(name, run) {
  await run();
  passed += 1;
  console.log(`PASS ${name}`);
}
async function asUser(role, uid, run) {
  assert.ok(['authenticated', 'anon'].includes(role));
  await db.exec(`set role ${role}`);
  await db.query("select set_config('request.jwt.claim.sub', $1, false)", [uid]);
  try { return await run(); }
  finally { await db.exec('reset role'); }
}
async function denied(sql, params = []) {
  await assert.rejects(db.query(sql, params), (error) => error.code === '42501');
}
try {
  await db.exec(`
    create role anon nologin nosuperuser nobypassrls;
    create role authenticated nologin nosuperuser nobypassrls;
    create schema auth;
    create table auth.users (id uuid primary key);
    create function auth.uid() returns uuid language sql stable as
      $$select nullif(current_setting('request.jwt.claim.sub', true), '')::uuid$$;
    grant usage on schema public, auth to anon, authenticated;
    grant execute on function auth.uid() to anon, authenticated;
  `);
  await db.query('insert into auth.users(id) values ($1), ($2)', [A, B]);
  await db.exec(await readFile(new URL('./schema.sql', import.meta.url), 'utf8'));
  const a = await asUser('authenticated', A, async () => {
    const r = await db.query("insert into public.learning_notes(title) values ('A note') returning *");
    await check('owner insert and default owner_id', () => assert.equal(r.rows[0].owner_id, A));
    return r.rows[0].id;
  });
  const b = await asUser('authenticated', B, async () => {
    const r = await db.query("insert into public.learning_notes(title) values ('B note') returning *");
    await check('second owner insert', () => assert.equal(r.rows[0].owner_id, B));
    return r.rows[0].id;
  });
  for (const [uid, ownId, otherId, otherUid] of [[A, a, b, B], [B, b, a, A]]) {
    const who = uid === A ? 'A' : 'B';
    await asUser('authenticated', uid, async () => {
      await check(`${who} unfiltered list contains only own row`, async () => {
        const r = await db.query('select id from public.learning_notes');
        assert.deepEqual(r.rows, [{ id: ownId }]);
      });
      await check(`${who} cannot select other UUID`, async () => {
        const r = await db.query('select id from public.learning_notes where id=$1', [otherId]);
        assert.equal(r.rows.length, 0);
      });
      await check(`${who} can update own row`, async () => {
        const r = await db.query("update public.learning_notes set title='changed' where id=$1 returning title", [ownId]);
        assert.deepEqual(r.rows, [{ title: 'changed' }]);
      });
      await check(`${who} cannot update other UUID`, async () => {
        const r = await db.query("update public.learning_notes set title='intrusion' where id=$1 returning id", [otherId]);
        assert.equal(r.rows.length, 0);
      });
      await check(`${who} cannot delete other UUID`, async () => {
        const r = await db.query('delete from public.learning_notes where id=$1 returning id', [otherId]);
        assert.equal(r.rows.length, 0);
      });
      await check(`${who} cannot insert for another owner`, () => denied(
        "insert into public.learning_notes(owner_id,title) values ($1,'spoof')", [otherUid]));
      await check(`${who} cannot transfer ownership`, () => denied(
        'update public.learning_notes set owner_id=$1 where id=$2', [otherUid, ownId]));
    });
  }
  await asUser('anon', '', async () => {
    for (const [name, sql] of [
      ['select', 'select * from public.learning_notes'],
      ['insert', "insert into public.learning_notes(title) values ('anon')"],
      ['update', "update public.learning_notes set title='anon'"],
      ['delete', 'delete from public.learning_notes'],
    ]) await check(`anon ${name} denied by grant`, () => denied(sql));
  });
  await asUser('authenticated', '', async () => {
    await check('authenticated with missing uid sees zero rows', async () => {
      const r = await db.query('select id from public.learning_notes');
      assert.equal(r.rows.length, 0);
    });
  });
  await check('privileged inspection confirms both rows survived attacks', async () => {
    const r = await db.query('select owner_id, title from public.learning_notes order by owner_id');
    assert.deepEqual(r.rows, [{ owner_id: A, title: 'changed' }, { owner_id: B, title: 'changed' }]);
  });
  for (const [uid, id] of [[A, a], [B, b]]) {
    await asUser('authenticated', uid, () => check('owner delete returns exactly one row', async () => {
      const r = await db.query('delete from public.learning_notes where id=$1 returning id', [id]);
      assert.deepEqual(r.rows, [{ id }]);
    }));
  }
  console.log(`${passed} checks passed; local PostgreSQL RLS only, Auth is a stub.`);
} finally {
  await db.close();
}

개발 서버가 첫 터미널에서 실행 중이면 같은 프로젝트 폴더에 두 번째 터미널을 열어 다음 명령을 실행합니다. 또는 첫 터미널에서 Ctrl+C로 서버를 멈추고 실행한 뒤, 다음 계정 검사 전에 npm run dev로 다시 시작합니다.

npm test
npm run build

본문의 SQL과 테스트 파일을 실행한 로컬 결과는 24 checks passed였습니다. A와 B 모두 자기 행 추가·조회·수정·삭제가 가능했고, 타인 행 조회·수정·삭제는 0행이었으며, 소유자 위조와 소유권 이전은 42501로 거부되었습니다. 비로그인 네 작업도 권한 오류로 거부되었습니다. Vite 빌드와 JavaScript 구문 검사도 통과했습니다.

검증 범위는 분명히 구분해야 합니다. 이것은 PostgreSQL의 행 권한 실행을 확인한 결과입니다. Supabase Auth의 실제 비밀번호 로그인, JWT 검증, PostgREST 응답, 네트워크, 브라우저 렌더링은 실행하지 않았습니다. 로컬의 set_config는 테스트를 위한 사용자 식별자 주입이며 실제 인증 검증을 대체하지 않습니다. 다음 계정 실습이 남아 있습니다.

5. 실제 계정 두 개로 API 경계 확인하기

일반 창과 시크릿 창 또는 별도의 브라우저 프로필을 사용해 A와 B의 저장소를 분리합니다. 같은 브라우저 프로필의 탭 두 개는 로그인 저장소를 공유할 수 있어 두 사용자를 구분하는 검사가 되지 않을 수 있습니다. 개발 프로젝트에서 버려도 되는 노트만 만드세요.

순서 실제 확인
1. A로 로그인 노트를 추가하고 새로고침해 남아 있는지 봅니다. 제목을 수정한 뒤 다시 새로고침합니다. 노트 ID와 A 계정 ID를 기록합니다.
2. B로 로그인 A의 노트가 보이지 않아야 합니다. B의 노트를 추가하고 B의 노트 ID를 기록합니다.
3. 직접 요청 검사 아래 probe.js로 B가 A의 노트 ID를 지정해도 조회·수정·삭제가 막히는지 확인합니다.
4. A 창 재확인 A 노트의 제목과 소유자가 그대로인지 확인합니다. 방향을 바꾸어 A가 B 노트를 공격하는 검사도 반복합니다.
5. 본인 삭제 각 계정에서 자기 노트를 삭제하고 새로고침해 사라졌는지 확인합니다.
6. 로그아웃 노트 목록이 화면에서 비워지고 로그인 폼으로 돌아오는지 확인합니다.

다음 파일은 개발 서버에서만 불러오는 직접 요청 검사입니다. 정책이 잘못되어 있으면 지정한 테스트 노트가 실제로 수정·삭제될 수 있으므로 운영 데이터에 사용하지 않습니다. 검사는 예상 결과가 한 번이라도 다르면 중단합니다. 계정과 노트 ID가 같은 프로젝트의 값인지 확인하고 실행하세요.

probe.js

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

export async function probe(otherOwnerId, otherNoteId, ownNoteId) {
  const uuid = /^[0-9a-f]{8}(-[0-9a-f]{4}){3}-[0-9a-f]{12}$/i;
  if (![otherOwnerId, otherNoteId, ownNoteId].every((id) => uuid.test(id))) {
    throw new Error('세 값에 실제 테스트 계정/노트 UUID를 입력하세요.');
  }
  const { data, error } = await supabase.auth.getUser();
  if (error || !data.user || data.user.id === otherOwnerId) {
    throw new Error('다른 소유자 계정으로 로그인한 상태에서 실행하세요.');
  }
  const own = await supabase.from('learning_notes').select('id').eq('id', ownNoteId);
  if (own.error || own.data?.length !== 1) throw new Error('본인 테스트 노트가 필요합니다.');
  function empty(result, name) {
    if (result.error || result.data?.length !== 0) throw new Error(`${name}: 예상은 오류 없는 0행입니다.`);
    console.log(`PASS ${name}: 0행`);
  }
  function denied(result, name) {
    if (result.error?.code !== '42501') throw new Error(`${name}: 예상 권한 오류가 아닙니다.`);
    console.log(`PASS ${name}: 42501`);
  }
  empty(await supabase.from('learning_notes').select('id').eq('id', otherNoteId), '타인 조회');
  empty(await supabase.from('learning_notes').update({ title: '격리 검사 변경 시도' })
    .eq('id', otherNoteId).select('id'), '타인 수정');
  empty(await supabase.from('learning_notes').delete().eq('id', otherNoteId).select('id'), '타인 삭제');
  denied(await supabase.from('learning_notes').insert({ owner_id: otherOwnerId, title: '소유자 위조 시도' }), '소유자 위조');
  denied(await supabase.from('learning_notes').update({ owner_id: otherOwnerId }).eq('id', ownNoteId), '소유권 이전');
  const anonymous = createClient(import.meta.env.VITE_SUPABASE_URL,
    import.meta.env.VITE_SUPABASE_PUBLISHABLE_KEY, {
      auth: { persistSession: false, autoRefreshToken: false, detectSessionInUrl: false },
    });
  denied(await anonymous.from('learning_notes').select('id'), '비로그인 조회');
  denied(await anonymous.from('learning_notes').insert({ title: '비로그인' }), '비로그인 추가');
  denied(await anonymous.from('learning_notes').update({ title: '비로그인' }).eq('id', otherNoteId), '비로그인 수정');
  denied(await anonymous.from('learning_notes').delete().eq('id', otherNoteId), '비로그인 삭제');
  console.log('계정 창을 바꾸어 원본 노트가 그대로인지 확인하세요.');
}

B로 로그인한 창의 개발자 도구 Console에서 다음을 실행합니다. 세 번의 입력창에 A 계정 ID, A의 테스트 노트 ID, B 본인의 테스트 노트 ID를 순서대로 넣습니다. 이 ID들은 비밀번호나 토큰이 아닙니다. 코드를 실행하기 전에 입력한 대상이 삭제해도 되는 개발용 노트인지 확인하세요.

const { probe } = await import('/probe.js');
await probe(
  window.prompt('타인 계정 UUID'),
  window.prompt('타인의 테스트 노트 UUID'),
  window.prompt('본인의 테스트 노트 UUID')
);

예상 결과는 타인 행 조회·수정·삭제의 0행 로그 3개와 위조·이전·비로그인 작업의 42501 로그 6개입니다. 이것은 실제 계정에서 사용자가 확인해야 할 예상 결과이며 이 글 작성 환경에서 수행했다고 주장하는 결과가 아닙니다. 0행을 확인한 후에는 반드시 A 창에서 원본이 그대로인지 다시 확인하세요. 정상 응답 코드만으로 변경 거부를 판정하지 않습니다.

정책을 읽고 결과를 예측하는 추가 과제

완성 화면이 동작했다면 네 정책을 문법 암기에서 접근 규칙 설명으로 바꿔 보세요. 개발 프로젝트 SQL Editor에서 다음 읽기 전용 쿼리를 실행하면 learning_notes에 적용된 정책을 확인할 수 있습니다. 이 관리 화면에서 데이터가 보이는 것은 사용자 API 접근이 허용됐다는 증거가 아닙니다. SQL Editor는 브라우저 사용자의 권한으로 실행되는 검사와 다릅니다.

select policyname, cmd, roles, qual, with_check
from pg_policies
where schemaname = 'public' and tablename = 'learning_notes'
order by policyname;

기대 결과는 notes_read, notes_create, notes_change, notes_remove 네 정책입니다. 같은 테이블에 넓게 허용하는 기존 정책이 추가되어 있다면 새로 만든 네 정책만 읽어서는 실제 권한을 판단할 수 없습니다. permissive 정책들은 허용 범위가 합쳐질 수 있으므로, 계획에 없는 정책을 발견하면 즉시 지우기보다 정책 목적과 적용 역할을 조사하세요.

예측할 요청 정책을 읽는 순서 예상 판정
A가 자기 노트 제목 수정 SELECT → UPDATE USING → WITH CHECK 기존·결과 소유자가 A이면 1행
A가 자기 노트 owner_id를 B로 변경 기존 행은 통과하나 결과 행 검사 실패 42501 권한 오류
B가 A 노트 ID로 제목 수정 기존 행이 B의 허용 범위 밖 오류 없는 0행, A 데이터 유지
익명이 목록 요청 GRANT에서 SELECT 권한 없음 RLS 조건 이전에 권한 오류

main.js의 oneRow가 왜 필요한지도 설명해 보세요. error가 없다는 사실만 검사하면 이미 삭제된 ID를 수정했을 때도 성공 문구를 보여 줄 수 있습니다. 이 앱은 select로 반환 행을 요구하고 정확히 1행인지 확인합니다. 동시에 두 요청이 같은 행을 고치는 충돌까지 방지하는 것은 아니므로 공동 편집 앱으로 확장할 때는 버전 컬럼·수정 시각 조건 같은 동시성 설계를 별도로 추가해야 합니다.

2026-09-13 재검증: 공개 ZIP을 다시 내려받아 complete의 SQL·HTML·main.js·probe.js·rls.test.mjs를 본문과 대조했습니다. Node 24.19.0 / npm 11.9.0에서 npm ci, npm test의 24 checks, Vite production build를 다시 실행해 통과했습니다. package.json의 Node engines와 .gitignore는 ZIP과 동일하게 본문을 맞췄습니다. UI는 기본 HTML을 사용하며 별도 CSS 파일은 없습니다. Supabase 실제 인증·PostgREST·브라우저 두 계정·운영 서버 검사는 이 결과에 포함되지 않습니다.

공식 정책 동작은 Supabase RLS 문서에서 2026-09-13 확인했습니다. 시작·완성 ZIP에서 complete만 위 결과의 대상이며 starter의 미완성 TODO가 통과했다고 주장하지 않습니다.

6. 결과를 해석하고 SSR 단계로 연결하기

관찰한 결과 확인할 위치
본인 목록까지 0행 로그인한 계정 ID, 행의 owner_id, SELECT 정책을 비교합니다.
42501 오류 로그인 상태와 GRANT, INSERT/UPDATE의 WITH CHECK 조건을 확인합니다.
추가 직후 결과가 안 돌아옴 INSERT 정책뿐 아니라 반환 행을 읽는 SELECT 정책도 확인합니다.
수정·삭제가 오류 없이 0행 존재하지 않는 ID 또는 접근할 수 없는 행일 수 있습니다. 성공 문구로 처리하지 않습니다.
A의 노트가 B에게 보임 진행을 멈추고 개발 테이블의 RLS 활성화, 넓게 허용한 추가 정책, 사용 키를 확인합니다.
로컬 테스트만 통과 실제 Auth와 API 구성은 별도입니다. 두 계정 체크리스트를 완료합니다.

노트 생성 후 반환값을 받으려면 INSERT 뒤에도 select를 연결해야 합니다. INSERT 문서처럼 요청 성공과 반환 데이터는 구분해서 읽습니다. 이 예제의 oneRow 함수는 화면 메시지가 실제 변경 결과를 반영하도록 만든 장치입니다.

두 계정의 접근 결과를 기록했다면 Next.js Supabase SSR 인증 글에서 쿠키와 요청별 서버 클라이언트를 이어서 익힙니다. 여기의 브라우저 클라이언트를 서버 전역 변수로 재사용하지 마세요. 서버에서는 그 요청의 사용자 세션을 전달해야 같은 RLS 정책이 사용자 기준으로 작동합니다.

학습 완료 기준은 자기 노트의 CRUD 성공, 타인 데이터의 직접 요청 차단, 소유자 위조와 이전 차단, 로그아웃 요청 거부를 각각 설명하고 재현할 수 있는 것입니다. 로컬 테스트 로그와 실제 두 계정 확인 기록을 함께 남겨 두면 정책을 수정했을 때 같은 기준으로 다시 검사할 수 있습니다.

시작·완성 예제 파일

ZIP에는 시작본(starter), 완성본(complete), 실행 안내가 들어 있습니다. 압축을 푼 뒤 README의 준비 사항과 실행 순서를 확인하세요.

실습 ZIP 내려받기

Supabase 실습은 본인 프로젝트의 URL과 공개 키를 환경 변수에 설정하고 제공된 SQL을 적용한 뒤, 두 계정으로 데이터 격리를 확인하세요. 실제 Supabase 로그인과 두 계정 검증은 직접 진행해야 합니다.

이 글이 도움이 되었나요?

조회 중

Supabase 학습 순서

필수 4개 · 전체 5개

읽음 기록 관리

전체 과정 목차 (5개)
  1. 필수 학습 · Next.js Supabase 연결 방법: App Router 설정과 데이터 조회
  2. 필수 길잡이 · Supabase vs Neon vs Firebase Data Connect 차이와 선택 기준
  3. 선택 참고 · Supabase 회원가입 방법: 확인 메일 제목·본문·버튼 문구를 한국어로 바꾸기
  4. 필수 학습 · Supabase 사용자별 CRUD와 RLS: 두 계정으로 데이터 격리 검증하기 현재 글
  5. 필수 학습 · Next.js Supabase SSR 인증: @supabase/ssr와 쿠키 설정

새 글 받아보기

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

RSS 피드 구독하기

댓글 남기기