μ¬ν 리뷰 μ¬μ΄νΈ κ°λ°κΈ° (Next.js + Supabase)
νλ‘μ νΈ κ°μ
μ΄ νλ‘μ νΈλ μ¬ν μΌμ κ³Ό 리뷰λ₯Ό κΈ°λ‘ν μ μλ κ°μΈ μ¬ν λμ보λλ₯Ό λ§λλ κ²μ λͺ©νλ‘ μμνμ΅λλ€.
μ¬μ©μλ μ¬ν μΌμ μ λ¬λ ₯ κΈ°λ°μΌλ‘ κ΄λ¦¬νκ³ , κ° μ¬νμ§μ λν νκΈ°λ μ₯μ μ 보λ₯Ό κΈ°λ‘ν μ μμ΅λλ€.
κΈ°μ μ€ν
- Frontend: Next.js 15 (App Router)
- Database & Auth: Supabase
- State Management: React Query
- Styling: Tailwind CSS
- Date Handling: react-calendar (λ‘μΊ
ko-KR) - Auth Provider: SupabaseAuthProvider (컀μ€ν Provider)
- React Query Provider: ReactQueryProvider
μ£Όμ κΈ°λ₯
- λ‘κ·ΈμΈν μ¬μ©μλ³ μ¬ν λ°μ΄ν° κ΄λ¦¬ (SSR κΈ°λ°)
- λ¬λ ₯μμ λ μ§ μ ν β μ¬ν μΌμ μ‘°ν λ° μΆκ°
- μ¬ν λͺ¨λ¬(TravelModal)μ ν΅ν΄ μ μ¬ν μμ±
- μ¬νμ§, νκΈ° λ± νμ λ°μ΄ν°(Review, Destination) μ°κ²°
- 곡κ°/λΉκ³΅κ° μ€μ (is_public)
μ£Όμ ꡬ쑰
app/
βββ layout.tsx # μ μ Provider (Auth + React Query)
βββ dashboard/
β βββ page.tsx # μλ² μ»΄ν¬λνΈ (SSR)
β βββ DashboardClient.tsx # ν΄λΌμ΄μΈνΈ μ»΄ν¬λνΈ
β βββ TravelModal.tsx # μ¬ν μΆκ° λͺ¨λ¬
βββ providers/
βββ query_provider.tsx
βββ supabase_auth_provider.tsx
λ°μ΄ν°λ² μ΄μ€ μ€ν€λ§
Trip ν μ΄λΈ
CREATE TABLE IF NOT EXISTS public.trip (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
title TEXT NOT NULL,
slug TEXT UNIQUE,
start_date DATE NOT NULL,
end_date DATE NOT NULL,
description TEXT,
is_public BOOLEAN DEFAULT true,
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now()
);
Reviews ν μ΄λΈ
CREATE TABLE IF NOT EXISTS public.reviews (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
trip_id UUID NOT NULL REFERENCES public.trip(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
content TEXT NOT NULL,
rating INT CHECK (rating >= 1 AND rating <= 5),
created_at TIMESTAMPTZ DEFAULT now()
);
Destinations ν μ΄λΈ
CREATE TABLE public.destinations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
trip_id UUID NOT NULL REFERENCES public.trip(id) ON DELETE CASCADE,
name TEXT NOT NULL,
description TEXT,
day INT CHECK (day > 0),
order_num INT,
created_at TIMESTAMPTZ DEFAULT now()
);
Profiles ν μ΄λΈ
CREATE TABLE public.profiles (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL UNIQUE REFERENCES auth.users(id) ON DELETE CASCADE,
nickname TEXT NOT NULL,
role TEXT DEFAULT 'user',
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now()
);
μλν ν¨μ λ° νΈλ¦¬κ±°
user_id μλ μ€μ νΈλ¦¬κ±°
μ¬ν μΆκ° μ μλμΌλ‘ νμ¬ λ‘κ·ΈμΈν μ¬μ©μμ IDλ₯Ό μ€μ νλ νΈλ¦¬κ±°:
-- Trip ν
μ΄λΈμ©
CREATE OR REPLACE FUNCTION public.set_trip_user_id()
RETURNS trigger AS $$
BEGIN
IF NEW.user_id IS NULL AND auth.uid() IS NOT NULL THEN
NEW.user_id := auth.uid();
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
CREATE TRIGGER trg_trip_set_user_id
BEFORE INSERT ON public.trip
FOR EACH ROW
EXECUTE FUNCTION public.set_trip_user_id();
-- Profile ν
μ΄λΈμ©
CREATE OR REPLACE FUNCTION public.set_profiles_user_id()
RETURNS trigger AS $$
BEGIN
IF auth.uid() IS NOT NULL THEN
NEW.user_id := auth.uid();
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
CREATE TRIGGER trg_profiles_set_user_id
BEFORE INSERT ON public.profiles
FOR EACH ROW
EXECUTE FUNCTION public.set_profiles_user_id();
RLS μ μ±
Trip ν μ΄λΈ RLS
-- μ¬μ©μλ μμ μ μ¬νλ§ μ‘°ν
CREATE POLICY "Trips: user can view own"
ON public.trip
FOR SELECT
USING (user_id = auth.uid() OR is_public = true);
-- μ¬μ©μλ μμ μ μ¬νλ§ μμ
CREATE POLICY "Trips: user can update own"
ON public.trip
FOR UPDATE
USING (user_id = auth.uid());
-- μ¬μ©μλ μμ μ μ¬νλ§ μμ
CREATE POLICY "Trips: user can delete own"
ON public.trip
FOR DELETE
USING (user_id = auth.uid());
-- κ΄λ¦¬μλ λͺ¨λ μμ
κ°λ₯
CREATE POLICY "Trips: admin full access"
ON public.trip
FOR ALL
USING (
EXISTS (
SELECT 1 FROM public.profiles p
WHERE p.user_id = auth.uid() AND p.role = 'admin'
)
);
Reviews ν μ΄λΈ RLS
-- μ¬μ©μλ μμ μ λ¦¬λ·°λ§ μ‘°ν
CREATE POLICY "Reviews: user can view"
ON public.reviews
FOR SELECT
USING (
user_id = auth.uid()
OR EXISTS (
SELECT 1 FROM public.trip t
WHERE t.id = trip_id AND t.is_public = true
)
);
-- μ¬μ©μλ μμ μ λ¦¬λ·°λ§ μμ±
CREATE POLICY "Reviews: user can insert"
ON public.reviews
FOR INSERT
WITH CHECK (user_id = auth.uid());
-- κ΄λ¦¬μλ λͺ¨λ μμ
κ°λ₯
CREATE POLICY "Reviews: admin can do anything"
ON public.reviews
FOR ALL
USING (
EXISTS (
SELECT 1 FROM public.profiles p
WHERE p.user_id = auth.uid() AND p.role = 'admin'
)
);
Profiles ν μ΄λΈ RLS
-- μ¬μ©μλ μμ μ νλ‘νλ§ μ‘°ν
CREATE POLICY "Profiles: user can view own"
ON public.profiles
FOR SELECT
USING (user_id = auth.uid());
-- μ¬μ©μλ μμ μ νλ‘νλ§ μμ±
CREATE POLICY "Profiles: user can insert"
ON public.profiles
FOR INSERT
WITH CHECK (auth.uid() IS NOT NULL);
-- μ¬μ©μλ μμ μ νλ‘νλ§ μμ
CREATE POLICY "Profiles: user can update own"
ON public.profiles
FOR UPDATE
USING (user_id = auth.uid());
ν΅μ¬ ꡬν ν¬μΈνΈ
SSRκ³Ό ν΄λΌμ΄μΈνΈ λ°μ΄ν°μ μΌκ΄μ± 보μ₯
// app/dashboard/page.tsx (μλ² μ»΄ν¬λνΈ)
import { createServerClient } from "@supabase/ssr";
import { cookies } from "next/headers";
export default async function DashboardPage() {
const cookieStore = cookies();
const supabase = createServerClient(/* ... */);
const { data: travels } = await supabase
.from("trip")
.select("*")
.order("start_date", { ascending: false });
return <DashboardClient initialTravels={travels} />;
}
μλ μΈμ¦ μ²λ¦¬
νΈλ¦¬κ±°λ₯Ό ν΅ν΄ ν΄λΌμ΄μΈνΈμμ user_idλ₯Ό λͺ
μνμ§ μμλ μλμΌλ‘ νμ¬ λ‘κ·ΈμΈν μ¬μ©μ IDκ° μ€μ λ©λλ€:
// ν΄λΌμ΄μΈνΈμμ κ°λ¨νκ² μΆκ°
await supabase.from("trip").insert({
title: "μ μ£Όλ μ¬ν",
start_date: "2025-01-01",
end_date: "2025-01-05",
description: "μ νΌμ¬ν",
// user_idλ μλμΌλ‘ μ€μ λ¨!
});
보μ κ°ν
- RLS μ μ± μΌλ‘ μΈν λ°μ΄ν° 격리
- κ΄λ¦¬μ κΆνμΌλ‘ μ 체 μ κ·Ό νμ©
- μΈλν€ μ μ½μ‘°κ±΄μΌλ‘ λ°μ΄ν° λ¬΄κ²°μ± λ³΄μ₯