Y J S

μ—¬ν–‰ 리뷰 μ‚¬μ΄νŠΈ 개발기 (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 μ •μ±…μœΌλ‘œ μΈν•œ 데이터 격리
  • κ΄€λ¦¬μž κΆŒν•œμœΌλ‘œ 전체 μ ‘κ·Ό ν—ˆμš©
  • μ™Έλž˜ν‚€ μ œμ•½μ‘°κ±΄μœΌλ‘œ 데이터 무결성 보μž₯