๐Ÿš€ ์˜จํ•(on-fit) Supabase RLS ๊ธฐ๋ฐ˜ API ๊ตฌ์กฐ ๊ฐœ์„ ๊ธฐ โ€” sbAdmin ์ œ๊ฑฐ & SSR ํด๋ผ์ด์–ธํŠธ ๋ชจ๋“ˆํ™”

์กฐ์ค€ํ˜•ยท2025๋…„ 11์›” 12์ผ

์˜จํ•

๋ชฉ๋ก ๋ณด๊ธฐ
6/16

1๏ธโƒฃ ๋ฐฐ๊ฒฝ โ€” ์™œ sbAdmin์„ ๊ฑท์–ด๋‚ด๊ธฐ๋กœ ํ–ˆ๋‚˜?

์ฒ˜์Œ์—” ์„œ๋ฒ„ ๋ผ์šฐํŠธ์—์„œ Supabase๋ฅผ ๋‹ค๋ฃฐ ๋•Œ sbAdmin (Service Role Key ๊ธฐ๋ฐ˜ ๊ด€๋ฆฌ์ž ํด๋ผ์ด์–ธํŠธ) ์„ ์ผ๋‹ค.

์ด ๋ฐฉ์‹์€ RLS(Row Level Security)๋ฅผ ์™„์ „ํžˆ ์šฐํšŒํ•˜๊ธฐ ๋•Œ๋ฌธ์—,

๋กœ๊ทธ์ธ ์—ฌ๋ถ€๋‚˜ author_id ๊ฐ™์€ ์ฒดํฌ๋ฅผ ์„œ๋ฒ„์—์„œ ์ง์ ‘ ์ฒ˜๋ฆฌํ•ด์•ผ ํ–ˆ๋‹ค.

const { data, error } = await sbAdmin.from('posts').insert({
  title,
  author_id: user.id, // ์ง์ ‘ ์ง€์ •
})

๋ฌผ๋ก  ์ž˜ ์ž‘๋™ํ•˜๊ธด ํ•˜์ง€๋งŒ,

Service Role Key๋Š” ๋ชจ๋“  ๊ถŒํ•œ์„ ๊ฐ€์ง„ ์Šˆํผ ๊ด€๋ฆฌ์ž ํ‚ค์ด๊ธฐ ๋•Œ๋ฌธ์—

์ž˜๋ชป ์“ฐ๋ฉด ์ผ๋ฐ˜ ์‚ฌ์šฉ์ž๊ฐ€ ์ „์ฒด ๋ฐ์ดํ„ฐ๋ฅผ ์ง€์›Œ๋ฒ„๋ฆด ์ˆ˜๋„ ์žˆ๋Š” ์œ„ํ—˜ํ•œ ๊ตฌ์กฐ์˜€๋‹ค.


2๏ธโƒฃ ๋ชฉํ‘œ โ€” โ€œ์•ˆ์ „ํ•œ RLS ๊ตฌ์กฐ๋กœ ์ „ํ™˜ํ•˜์žโ€

Supabase์˜ ์žฅ์  ์ค‘ ํ•˜๋‚˜๋Š” RLS(ํ–‰ ์ˆ˜์ค€ ๋ณด์•ˆ) ์ด๋‹ค.

์ด๊ฑธ ์ œ๋Œ€๋กœ ํ™œ์šฉํ•˜๋ฉด DB ์ž์ฒด์—์„œ โ€œ๋ˆ„๊ฐ€ ์–ด๋–ค ๋ฐ์ดํ„ฐ๋ฅผ ๋ณผ ์ˆ˜ ์žˆ๋Š”์ง€โ€ ์ œ์–ดํ•  ์ˆ˜ ์žˆ๋‹ค.

๊ทธ๋ž˜์„œ ์•„๋ž˜์ฒ˜๋Ÿผ ์ •์ฑ…์„ ์ถ”๊ฐ€ํ–ˆ๋‹ค ๐Ÿ‘‡

-- ๋ˆ„๊ตฌ๋‚˜ ๊ฒŒ์‹œ๊ธ€ ์กฐํšŒ ๊ฐ€๋Šฅ
create policy "Anyone can read posts"
on posts for select
to public using (true);

-- ๋กœ๊ทธ์ธ ์œ ์ €๋งŒ ์ž์‹ ์˜ ๊ธ€ ์ž‘์„ฑ ๊ฐ€๋Šฅ
create policy "Authenticated users can insert own posts"
on posts for insert
to authenticated
with check (auth.uid() = author_id);

-- ์ž‘์„ฑ์ž ๋ณธ์ธ๋งŒ ์ˆ˜์ •/์‚ญ์ œ ๊ฐ€๋Šฅ
create policy "Authors can update/delete own posts"
on posts
for update using (auth.uid() = author_id)
with check (auth.uid() = author_id);

์ด์ œ๋ถ€ํ„ฐ๋Š” DB๊ฐ€ ์ž๋™์œผ๋กœ auth.uid()๋ฅผ ๊ฒ€์‚ฌํ•ด์ฃผ๊ธฐ ๋•Œ๋ฌธ์—

์„œ๋ฒ„ ์ฝ”๋“œ์—์„œ ์ง์ ‘ user.id๋ฅผ ๋น„๊ตํ•  ํ•„์š”๋„ ์—†๊ณ ,

Service Role Key(sbAdmin) ๋„ ํ•„์š” ์—†์–ด์กŒ๋‹ค โœ…


3๏ธโƒฃ Supabase SSR ํด๋ผ์ด์–ธํŠธ ๊ตฌ์กฐ๋กœ ์ „ํ™˜

Next.js์˜ Route Handler (app/api/...) ์—์„œ๋Š”

@supabase/ssr์˜ createServerClient()๋ฅผ ์ด์šฉํ•ด SSR์šฉ ํด๋ผ์ด์–ธํŠธ๋ฅผ ๋งŒ๋“ค ์ˆ˜ ์žˆ๋‹ค.

์ด ํด๋ผ์ด์–ธํŠธ๋Š” cookies()๋ฅผ ํ†ตํ•ด ์‚ฌ์šฉ์ž ์„ธ์…˜์„ ์ž๋™์œผ๋กœ ์ธ์‹ํ•˜๊ณ 

๋กœ๊ทธ์ธ ์ƒํƒœ๋ฅผ ๊ธฐ๋ฐ˜์œผ๋กœ Supabase ์ฟผ๋ฆฌ๋ฅผ ์‹คํ–‰ํ•œ๋‹ค.

์˜ˆ์ „์—๋Š” ์ด๋ ‡๊ฒŒ ์ž‘์„ฑํ–ˆ๋˜ ์ฝ”๋“œ๊ฐ€ ๐Ÿ‘‡

// sbAdmin (๊ด€๋ฆฌ์ž ๊ถŒํ•œ)
const { data, error } = await sbAdmin.from('posts').select('*')

์ด์ œ๋Š” ์ด๋ ‡๊ฒŒ ๋ฐ”๋€Œ์—ˆ๋‹ค ๐Ÿ‘‡

// โœ… SSR Client (RLS ์ ์šฉ)
const supabase = createServerClient(url, anonKey, { cookies })
const { data } = await supabase.from('posts').select('*')

์ด์ œ๋Š” anon key๋งŒ์œผ๋กœ๋„

RLS ์ •์ฑ… ๋•๋ถ„์— ๋กœ๊ทธ์ธ๋œ ์‚ฌ์šฉ์ž๋งŒ ์•ˆ์ „ํ•˜๊ฒŒ ์ฟผ๋ฆฌํ•  ์ˆ˜ ์žˆ๋‹ค.


4๏ธโƒฃ ๊ณตํ†ต ๋กœ์ง์„ ๋ชจ๋“ˆํ™” โ€” route-helpers.ts

profiles, posts ๋“ฑ ์—ฌ๋Ÿฌ API ๋ผ์šฐํŠธ์—์„œ

์ฟ ํ‚ค ํ•ธ๋“ค๋ง๊ณผ ์ธ์ฆ ๋กœ์ง์ด ๋ฐ˜๋ณต๋˜๊ธธ๋ž˜,

์ด๋ฅผ src/lib/supabase/route-helpers.ts๋กœ ๋ถ„๋ฆฌํ–ˆ๋‹ค.

// src/lib/supabase/route-helpers.ts
import { cookies } from "next/headers";
import { createServerClient, type CookieOptions } from "@supabase/ssr";
import { NextResponse } from "next/server";

export async function createSSRClient() {
  const cookieStore = await cookies();
  return createServerClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
    {
      cookies: {
        async get(name: string) {
          const all = await cookieStore;
          return all.get(name)?.value;
        },
        async set(name: string, value: string, options: CookieOptions) {
          const all = await cookieStore;
          all.set({ name, value, ...options });
        },
        async remove(name: string, options: CookieOptions) {
          const all = await cookieStore;
          all.delete({ name, ...options });
        },
      },
    }
  );
}

/** ๋กœ๊ทธ์ธ ์œ ์ € ๊ฐ•์ œ ์ฒดํฌ */
export async function requireUserOr401(supabase: Awaited<ReturnType<typeof createSSRClient>>) {
  const { data: { user }, error } = await supabase.auth.getUser();
  if (error || !user) {
    return {
      ok: false as const,
      response: NextResponse.json({ ok: false, error: "UNAUTHORIZED" }, { status: 401 }),
    };
  }
  return { ok: true as const, user };
}

/** ์‘๋‹ต ํ—ฌํผ */
export function ok<T extends Record<string, any>>(data: T, init?: ResponseInit) {
  return NextResponse.json({ ok: true, ...data }, init);
}
export function fail(message: string, status = 400) {
  return NextResponse.json({ ok: false, error: message }, { status });
}

/** KST(date+time) โ†’ ISO ๋ณ€ํ™˜ */
export function toISOFromKST(date: string, time: string) {
  const d = new Date(`${date}T${time}:00+09:00`);
  if (isNaN(d.getTime())) return null;
  return d.toISOString();
}

์ด ํŒŒ์ผ ํ•˜๋‚˜๋กœ ๋ชจ๋“  API ๋ผ์šฐํŠธ๊ฐ€ ๊น”๋”ํ•ด์กŒ๋‹ค.


5๏ธโƒฃ posts ๋ผ์šฐํŠธ ์˜ˆ์‹œ

app/api/posts/route.ts๋Š” ์ด์ œ ์ด๋ ‡๊ฒŒ ๋‹จ์ˆœํ•ด์กŒ๋‹ค ๐Ÿ‘‡

import { createSSRClient, requireUserOr401, ok, fail, toISOFromKST } from "@/lib/supabase/route-helpers";

export const runtime = "nodejs";

export async function GET() {
  const supabase = await createSSRClient();
  const { data, error } = await supabase.from("posts").select("*").order("created_at", { ascending: false });
  if (error) return fail(error.message, 500);
  return ok({ items: data });
}

export async function POST(req: Request) {
  const supabase = await createSSRClient();
  const auth = await requireUserOr401(supabase);
  if (!auth.ok) return auth.response;

  const b = await req.json();
  const required = ["title", "sport", "location", "date", "time"];
  const missing = required.filter((k) => !b[k]);
  if (missing.length) return fail(`ํ•„์ˆ˜๊ฐ’ ๋ˆ„๋ฝ: ${missing.join(", ")}`, 400);

  const dateISO = toISOFromKST(b.date, b.time);
  if (!dateISO) return fail("์ž˜๋ชป๋œ ๋‚ ์งœ/์‹œ๊ฐ„ ํ˜•์‹", 400);

  const payload = {
    title: b.title,
    sport: b.sport,
    location: b.location,
    date_time: dateISO,
    author_id: auth.user.id,
    level: b.level ?? "๋ธŒ๋ก ์ฆˆ",
    status: b.status ?? "๋ชจ์ง‘์ค‘",
  };

  const { data, error } = await supabase.from("posts").insert(payload).select().single();
  if (error) return fail(error.message, 400);
  return ok({ item: data }, { status: 201 });
}

โœ… RLS ์ •์ฑ…์— ๋งž๊ฒŒ ์•ˆ์ „ํ•˜๊ฒŒ ์ž‘๋™

โœ… ์ฟ ํ‚ค/ํ† ํฐ ์ž๋™ ์ฒ˜๋ฆฌ

โœ… ์ค‘๋ณต ๋กœ์ง ์ œ๊ฑฐ๋กœ ๊น”๋”ํ•œ ์ฝ”๋“œ ์œ ์ง€


6๏ธโƒฃ ํด๋ผ์ด์–ธํŠธ์—์„œ๋Š” axios๋กœ /api/posts ํ˜ธ์ถœ

axios ์ธ์Šคํ„ด์Šค(src/lib/axios.ts)๋Š” ๊ทธ๋Œ€๋กœ ์œ ์ง€๋œ๋‹ค.

๋‹จ, ๋ธŒ๋ผ์šฐ์ €์—์„œ /api/...๋ฅผ ํ˜ธ์ถœํ•  ๋•Œ๋งŒ ์‚ฌ์šฉํ•œ๋‹ค.

'use client'
import { useEffect, useState } from 'react'
import { api } from '@/lib/axios'

export default function Home() {
  const [posts, setPosts] = useState([])

  useEffect(() => {
    api.get('/api/posts').then((res) => setPosts(res.data.items))
  }, [])

  return <div>{posts.map(p => <div key={p.id}>{p.title}</div>)}</div>
}

์ด ๊ตฌ์กฐ๋กœ

React(axios) โ†’ Next API(Route Handler) โ†’ Supabase(RLS) โ†’ DB

์ด๋ ‡๊ฒŒ ์•ˆ์ „ํ•˜๊ณ  ์ผ๊ด€๋œ ํŒŒ์ดํ”„๋ผ์ธ์ด ์™„์„ฑ๋๋‹ค.


โœ… ๋งˆ๋ฌด๋ฆฌ

์ด๋ฒˆ ๋ฆฌํŒฉํ„ฐ๋ง์œผ๋กœ ์–ป์€ ๊ฒƒ ๐Ÿ‘‡

ํ•ญ๋ชฉํšจ๊ณผ
sbAdmin ์ œ๊ฑฐRLS ๊ธฐ๋ฐ˜ ๋ณด์•ˆ ํ™•๋ฆฝ
createSSRClient ์‚ฌ์šฉ์ž๋™ ์ฟ ํ‚ค ์ธ์ฆ ์ฒ˜๋ฆฌ
route-helpers.ts ๊ณตํ†ตํ™”์ค‘๋ณต ์ œ๊ฑฐ, ์œ ์ง€๋ณด์ˆ˜ ํŽธ์˜์„ฑ
ํด๋ผ์ด์–ธํŠธ axios ํ˜ธ์ถœ ๋ถ„๋ฆฌ๋ธŒ๋ผ์šฐ์ €โ€“์„œ๋ฒ„โ€“DB ๋ช…ํ™•ํ•œ ๊ณ„์ธต ๊ตฌ์กฐ

๐Ÿ“˜ ์ •๋ฆฌ

โ€œ์ด์ œ๋Š” Supabase์˜ RLS ์ •์ฑ…์— ๋งก๊ธฐ๊ณ ,

์„œ๋ฒ„๋Š” ์ธ์ฆ๋œ ์š”์ฒญ๋งŒ ์•ˆ์ „ํ•˜๊ฒŒ ํ˜๋ ค๋ณด๋‚ด๋Š” ์—ญํ• ๋งŒ ํ•˜๋ฉด ๋œ๋‹ค.โ€

profile
์ฝ”๋ฆฐ์ด

0๊ฐœ์˜ ๋Œ“๊ธ€