여행 리뷰 사이트 개발기 (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 정책으로 인한 데이터 격리
- 관리자 권한으로 전체 접근 허용
- 외래키 제약조건으로 데이터 무결성 보장
댓글 0개
- 첫 댓글을 남겨보세요!