์ฒ์์ ์๋ฒ ๋ผ์ฐํธ์์ 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๋ ๋ชจ๋ ๊ถํ์ ๊ฐ์ง ์ํผ ๊ด๋ฆฌ์ ํค์ด๊ธฐ ๋๋ฌธ์
์๋ชป ์ฐ๋ฉด ์ผ๋ฐ ์ฌ์ฉ์๊ฐ ์ ์ฒด ๋ฐ์ดํฐ๋ฅผ ์ง์๋ฒ๋ฆด ์๋ ์๋ ์ํํ ๊ตฌ์กฐ์๋ค.
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) ๋ ํ์ ์์ด์ก๋ค โ
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 ์ ์ฑ ๋๋ถ์ ๋ก๊ทธ์ธ๋ ์ฌ์ฉ์๋ง ์์ ํ๊ฒ ์ฟผ๋ฆฌํ ์ ์๋ค.
route-helpers.tsprofiles, 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 ๋ผ์ฐํธ๊ฐ ๊น๋ํด์ก๋ค.
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 ์ ์ฑ ์ ๋ง๊ฒ ์์ ํ๊ฒ ์๋
โ ์ฟ ํค/ํ ํฐ ์๋ ์ฒ๋ฆฌ
โ ์ค๋ณต ๋ก์ง ์ ๊ฑฐ๋ก ๊น๋ํ ์ฝ๋ ์ ์ง
/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 ์ ์ฑ ์ ๋งก๊ธฐ๊ณ ,
์๋ฒ๋ ์ธ์ฆ๋ ์์ฒญ๋ง ์์ ํ๊ฒ ํ๋ ค๋ณด๋ด๋ ์ญํ ๋ง ํ๋ฉด ๋๋ค.โ