Supabase + Next.js 풀스택 개발 가이드
Supabase + Next.js 풀스택 개발 가이드
🚀 개요
Supabase는 Firebase의 오픈소스 대안으로, PostgreSQL 기반의 백엔드 서비스를 제공합니다. Next.js와 함께 사용하면 강력하고 확장 가능한 풀스택 애플리케이션을 빠르게 구축할 수 있습니다.
왜 Supabase + Next.js인가?
Supabase의 장점:
- 🗄️ PostgreSQL 기반의 강력한 데이터베이스
- 🔐 내장된 인증 시스템 (소셜 로그인 포함)
- 📡 실시간 구독 (Real-time subscriptions)
- 🛡️ Row Level Security (RLS)
- 📁 파일 스토리지
- 🔧 자동 생성되는 REST/GraphQL API
Next.js의 장점:
- ⚡ 뛰어난 성능 (SSR, SSG, ISR)
- 📱 풀스택 개발 가능 (API Routes)
- 🎨 모던 React 기능 지원
- 🚀 간편한 배포 (Vercel)
🛠️ 프로젝트 설정
1. Next.js 프로젝트 생성
npx create-next-app@latest my-supabase-app
cd my-supabase-app
2. Supabase 클라이언트 설치
npm install @supabase/supabase-js
npm install @supabase/auth-helpers-nextjs
npm install @supabase/auth-helpers-react
npm install @supabase/auth-ui-react
npm install @supabase/auth-ui-shared
3. 환경 변수 설정
.env.local
파일 생성:
NEXT_PUBLIC_SUPABASE_URL=your_supabase_project_url
NEXT_PUBLIC_SUPABASE_ANON_KEY=your_supabase_anon_key
SUPABASE_SERVICE_ROLE_KEY=your_service_role_key
4. Supabase 클라이언트 설정
lib/supabase.js
파일 생성:
import { createClient } from '@supabase/supabase-js';
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
export const supabase = createClient(supabaseUrl, supabaseKey);
lib/supabase-server.js
(서버사이드용):
import { createServerComponentClient } from '@supabase/auth-helpers-nextjs';
import { cookies } from 'next/headers';
export const createServerSupabaseClient = () => {
return createServerComponentClient({ cookies });
};
🗄️ 데이터베이스 설계
블로그 애플리케이션 예시
Supabase 대시보드에서 SQL 에디터를 사용하여 테이블 생성:
-- 프로필 테이블
CREATE TABLE profiles (
id UUID PRIMARY KEY REFERENCES auth.users(id),
email TEXT,
full_name TEXT,
avatar_url TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- 게시글 테이블
CREATE TABLE posts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
title TEXT NOT NULL,
content TEXT,
excerpt TEXT,
slug TEXT UNIQUE NOT NULL,
published BOOLEAN DEFAULT FALSE,
author_id UUID REFERENCES profiles(id),
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- 댓글 테이블
CREATE TABLE comments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
content TEXT NOT NULL,
post_id UUID REFERENCES posts(id) ON DELETE CASCADE,
author_id UUID REFERENCES profiles(id),
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- 카테고리 테이블
CREATE TABLE categories (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT UNIQUE NOT NULL,
description TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- 게시글-카테고리 관계 테이블
CREATE TABLE post_categories (
post_id UUID REFERENCES posts(id) ON DELETE CASCADE,
category_id UUID REFERENCES categories(id) ON DELETE CASCADE,
PRIMARY KEY (post_id, category_id)
);
RLS (Row Level Security) 설정
-- RLS 활성화
ALTER TABLE profiles ENABLE ROW LEVEL SECURITY;
ALTER TABLE posts ENABLE ROW LEVEL SECURITY;
ALTER TABLE comments ENABLE ROW LEVEL SECURITY;
-- 프로필 정책
CREATE POLICY "프로필은 자신만 수정 가능" ON profiles
FOR ALL USING (auth.uid() = id);
-- 게시글 정책
CREATE POLICY "게시글은 누구나 읽기 가능" ON posts
FOR SELECT USING (published = true OR auth.uid() = author_id);
CREATE POLICY "게시글은 작성자만 생성/수정 가능" ON posts
FOR ALL USING (auth.uid() = author_id);
-- 댓글 정책
CREATE POLICY "댓글은 누구나 읽기 가능" ON comments
FOR SELECT USING (true);
CREATE POLICY "댓글은 로그인한 사용자만 작성 가능" ON comments
FOR INSERT WITH CHECK (auth.uid() = author_id);
🔐 인증 시스템 구현
1. 인증 컨텍스트 설정
contexts/AuthContext.js
:
'use client';
import { createContext, useContext, useEffect, useState } from 'react';
import { supabase } from '@/lib/supabase';
const AuthContext = createContext({});
export const useAuth = () => {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuth must be used within AuthProvider');
}
return context;
};
export const AuthProvider = ({ children }) => {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const getSession = async () => {
const {
data: { session }
} = await supabase.auth.getSession();
setUser(session?.user ?? null);
setLoading(false);
};
getSession();
const {
data: { subscription }
} = supabase.auth.onAuthStateChange(async (event, session) => {
setUser(session?.user ?? null);
setLoading(false);
});
return () => subscription?.unsubscribe();
}, []);
const signUp = async (email, password, metadata = {}) => {
const { data, error } = await supabase.auth.signUp({
email,
password,
options: {
data: metadata
}
});
return { data, error };
};
const signIn = async (email, password) => {
const { data, error } = await supabase.auth.signInWithPassword({
email,
password
});
return { data, error };
};
const signOut = async () => {
const { error } = await supabase.auth.signOut();
return { error };
};
const value = {
user,
loading,
signUp,
signIn,
signOut
};
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
};
2. 로그인 컴포넌트
components/auth/LoginForm.jsx
:
'use client';
import { useState } from 'react';
import { useAuth } from '@/contexts/AuthContext';
import { Auth } from '@supabase/auth-ui-react';
import { ThemeSupa } from '@supabase/auth-ui-shared';
import { supabase } from '@/lib/supabase';
export default function LoginForm() {
const { user } = useAuth();
if (user) {
return <div>이미 로그인되어 있습니다.</div>;
}
return (
<div className="max-w-md mx-auto mt-8 p-6 bg-white rounded-lg shadow-md">
<Auth
supabaseClient={supabase}
appearance={{ theme: ThemeSupa }}
providers={['google', 'github']}
redirectTo={`${window.location.origin}/auth/callback`}
onlyThirdPartyProviders={false}
showLinks={true}
localization={{
variables: {
sign_in: {
email_label: '이메일',
password_label: '비밀번호',
button_label: '로그인',
loading_button_label: '로그인 중...',
link_text: '이미 계정이 있으신가요? 로그인하세요'
},
sign_up: {
email_label: '이메일',
password_label: '비밀번호',
button_label: '회원가입',
loading_button_label: '가입 중...',
link_text: '계정이 없으신가요? 회원가입하세요'
}
}
}}
/>
</div>
);
}
3. 인증 콜백 처리
app/auth/callback/route.js
:
import { createRouteHandlerClient } from '@supabase/auth-helpers-nextjs';
import { cookies } from 'next/headers';
import { NextResponse } from 'next/server';
export async function GET(request) {
const requestUrl = new URL(request.url);
const code = requestUrl.searchParams.get('code');
if (code) {
const supabase = createRouteHandlerClient({ cookies });
await supabase.auth.exchangeCodeForSession(code);
}
return NextResponse.redirect(requestUrl.origin);
}
📊 데이터 CRUD 작업
1. 게시글 생성
components/posts/CreatePost.jsx
:
'use client';
import { useState } from 'react';
import { supabase } from '@/lib/supabase';
import { useAuth } from '@/contexts/AuthContext';
export default function CreatePost() {
const { user } = useAuth();
const [title, setTitle] = useState('');
const [content, setContent] = useState('');
const [loading, setLoading] = useState(false);
const handleSubmit = async (e) => {
e.preventDefault();
if (!user) return;
setLoading(true);
const slug = title
.toLowerCase()
.replace(/ /g, '-')
.replace(/[^\w-]+/g, '');
const { data, error } = await supabase
.from('posts')
.insert([
{
title,
content,
slug,
author_id: user.id,
published: true
}
])
.select();
if (error) {
console.error('Error creating post:', error);
} else {
console.log('Post created:', data);
setTitle('');
setContent('');
}
setLoading(false);
};
if (!user) {
return <div>로그인이 필요합니다.</div>;
}
return (
<form onSubmit={handleSubmit} className="max-w-2xl mx-auto p-6">
<div className="mb-4">
<label className="block text-sm font-medium mb-2">제목</label>
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
className="w-full p-3 border rounded-lg focus:ring-2 focus:ring-blue-500"
required
/>
</div>
<div className="mb-4">
<label className="block text-sm font-medium mb-2">내용</label>
<textarea
value={content}
onChange={(e) => setContent(e.target.value)}
rows={10}
className="w-full p-3 border rounded-lg focus:ring-2 focus:ring-blue-500"
required
/>
</div>
<button
type="submit"
disabled={loading}
className="w-full bg-blue-500 text-white py-3 rounded-lg hover:bg-blue-600 disabled:opacity-50"
>
{loading ? '게시 중...' : '게시글 작성'}
</button>
</form>
);
}
2. 게시글 목록 조회
components/posts/PostList.jsx
:
'use client';
import { useState, useEffect } from 'react';
import { supabase } from '@/lib/supabase';
import Link from 'next/link';
export default function PostList() {
const [posts, setPosts] = useState([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetchPosts();
}, []);
const fetchPosts = async () => {
try {
const { data, error } = await supabase
.from('posts')
.select(
`
*,
profiles:author_id (
full_name,
avatar_url
)
`
)
.eq('published', true)
.order('created_at', { ascending: false });
if (error) throw error;
setPosts(data || []);
} catch (error) {
console.error('Error fetching posts:', error);
} finally {
setLoading(false);
}
};
if (loading) {
return <div className="text-center py-8">로딩 중...</div>;
}
return (
<div className="max-w-4xl mx-auto p-6">
<h1 className="text-3xl font-bold mb-8">최신 게시글</h1>
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
{posts.map((post) => (
<article
key={post.id}
className="bg-white rounded-lg shadow-md overflow-hidden hover:shadow-lg transition-shadow"
>
<div className="p-6">
<h2 className="text-xl font-semibold mb-3">
<Link
href={`/posts/${post.slug}`}
className="text-gray-900 hover:text-blue-600"
>
{post.title}
</Link>
</h2>
{post.excerpt && (
<p className="text-gray-600 mb-4 line-clamp-3">
{post.excerpt}
</p>
)}
<div className="flex items-center justify-between text-sm text-gray-500">
<span>{post.profiles?.full_name || '익명'}</span>
<time dateTime={post.created_at}>
{new Date(post.created_at).toLocaleDateString('ko-KR')}
</time>
</div>
</div>
</article>
))}
</div>
</div>
);
}
📡 실시간 기능 구현
실시간 댓글 시스템
components/comments/CommentSection.jsx
:
'use client';
import { useState, useEffect } from 'react';
import { supabase } from '@/lib/supabase';
import { useAuth } from '@/contexts/AuthContext';
export default function CommentSection({ postId }) {
const { user } = useAuth();
const [comments, setComments] = useState([]);
const [newComment, setNewComment] = useState('');
const [loading, setLoading] = useState(true);
useEffect(() => {
fetchComments();
// 실시간 구독 설정
const subscription = supabase
.channel(`comments:${postId}`)
.on(
'postgres_changes',
{
event: '*',
schema: 'public',
table: 'comments',
filter: `post_id=eq.${postId}`
},
(payload) => {
if (payload.eventType === 'INSERT') {
setComments((prev) => [...prev, payload.new]);
} else if (payload.eventType === 'DELETE') {
setComments((prev) =>
prev.filter((comment) => comment.id !== payload.old.id)
);
}
}
)
.subscribe();
return () => {
supabase.removeChannel(subscription);
};
}, [postId]);
const fetchComments = async () => {
try {
const { data, error } = await supabase
.from('comments')
.select(
`
*,
profiles:author_id (
full_name,
avatar_url
)
`
)
.eq('post_id', postId)
.order('created_at', { ascending: true });
if (error) throw error;
setComments(data || []);
} catch (error) {
console.error('Error fetching comments:', error);
} finally {
setLoading(false);
}
};
const handleSubmit = async (e) => {
e.preventDefault();
if (!user || !newComment.trim()) return;
try {
const { error } = await supabase.from('comments').insert([
{
content: newComment.trim(),
post_id: postId,
author_id: user.id
}
]);
if (error) throw error;
setNewComment('');
} catch (error) {
console.error('Error creating comment:', error);
}
};
return (
<div className="mt-8">
<h3 className="text-2xl font-bold mb-6">댓글 ({comments.length})</h3>
{user && (
<form onSubmit={handleSubmit} className="mb-8">
<textarea
value={newComment}
onChange={(e) => setNewComment(e.target.value)}
placeholder="댓글을 작성해주세요..."
rows={3}
className="w-full p-3 border rounded-lg focus:ring-2 focus:ring-blue-500"
/>
<button
type="submit"
disabled={!newComment.trim()}
className="mt-2 px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 disabled:opacity-50"
>
댓글 작성
</button>
</form>
)}
<div className="space-y-4">
{comments.map((comment) => (
<div key={comment.id} className="bg-gray-50 p-4 rounded-lg">
<div className="flex items-center mb-2">
<span className="font-medium">
{comment.profiles?.full_name || '익명'}
</span>
<time className="ml-2 text-sm text-gray-500">
{new Date(comment.created_at).toLocaleDateString('ko-KR')}
</time>
</div>
<p className="text-gray-700">{comment.content}</p>
</div>
))}
</div>
</div>
);
}
📁 파일 업로드 (Storage)
이미지 업로드 컴포넌트
components/upload/ImageUpload.jsx
:
'use client';
import { useState } from 'react';
import { supabase } from '@/lib/supabase';
import { useAuth } from '@/contexts/AuthContext';
export default function ImageUpload({ onUpload }) {
const { user } = useAuth();
const [uploading, setUploading] = useState(false);
const uploadImage = async (event) => {
try {
setUploading(true);
if (!event.target.files || event.target.files.length === 0) {
throw new Error('파일을 선택해주세요.');
}
const file = event.target.files[0];
const fileExt = file.name.split('.').pop();
const fileName = `${user.id}/${Date.now()}.${fileExt}`;
const { data, error } = await supabase.storage
.from('images')
.upload(fileName, file);
if (error) throw error;
// 공개 URL 가져오기
const {
data: { publicUrl }
} = supabase.storage.from('images').getPublicUrl(fileName);
onUpload(publicUrl);
} catch (error) {
console.error('Error uploading image:', error);
alert(error.message);
} finally {
setUploading(false);
}
};
return (
<div>
<label className="block text-sm font-medium mb-2">이미지 업로드</label>
<input
type="file"
accept="image/*"
onChange={uploadImage}
disabled={uploading}
className="block w-full text-sm text-gray-500 file:mr-4 file:py-2 file:px-4 file:rounded-full file:border-0 file:text-sm file:font-semibold file:bg-blue-50 file:text-blue-700 hover:file:bg-blue-100"
/>
{uploading && <p className="mt-2 text-sm text-gray-500">업로드 중...</p>}
</div>
);
}
🔍 고급 기능
1. 전문 검색 구현
// utils/search.js
export const searchPosts = async (query) => {
const { data, error } = await supabase
.from('posts')
.select(
`
*,
profiles:author_id (
full_name
)
`
)
.or(`title.ilike.%${query}%, content.ilike.%${query}%`)
.eq('published', true)
.order('created_at', { ascending: false });
return { data, error };
};
// PostgreSQL 전문 검색 사용
export const fullTextSearch = async (query) => {
const { data, error } = await supabase.rpc('search_posts', {
search_query: query
});
return { data, error };
};
2. 페이지네이션
const [posts, setPosts] = useState([]);
const [loading, setLoading] = useState(false);
const [hasMore, setHasMore] = useState(true);
const POSTS_PER_PAGE = 10;
const loadMorePosts = async (page = 0) => {
setLoading(true);
const from = page * POSTS_PER_PAGE;
const to = from + POSTS_PER_PAGE - 1;
const { data, error } = await supabase
.from('posts')
.select('*')
.eq('published', true)
.order('created_at', { ascending: false })
.range(from, to);
if (data && data.length < POSTS_PER_PAGE) {
setHasMore(false);
}
if (page === 0) {
setPosts(data || []);
} else {
setPosts((prev) => [...prev, ...(data || [])]);
}
setLoading(false);
};
🚀 배포 및 최적화
1. Vercel 배포
# Vercel CLI 설치
npm i -g vercel
# 프로젝트 배포
vercel
# 환경 변수 설정
vercel env add NEXT_PUBLIC_SUPABASE_URL
vercel env add NEXT_PUBLIC_SUPABASE_ANON_KEY
vercel env add SUPABASE_SERVICE_ROLE_KEY
2. 성능 최적화
데이터베이스 최적화:
-- 인덱스 생성
CREATE INDEX posts_published_created_idx ON posts (published, created_at DESC);
CREATE INDEX comments_post_id_idx ON comments (post_id);
CREATE INDEX profiles_id_idx ON profiles (id);
Next.js 최적화:
// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
images: {
domains: ['your-supabase-project.supabase.co']
},
experimental: {
appDir: true
}
};
module.exports = nextConfig;
3. 보안 강화
-- RLS 정책 강화
CREATE POLICY "게시글 작성자만 수정 가능" ON posts
FOR UPDATE USING (auth.uid() = author_id);
CREATE POLICY "댓글 작성자만 삭제 가능" ON comments
FOR DELETE USING (auth.uid() = author_id);
-- 함수 보안
CREATE OR REPLACE FUNCTION public.is_admin()
RETURNS BOOLEAN AS $$
BEGIN
RETURN EXISTS (
SELECT 1 FROM profiles
WHERE id = auth.uid() AND role = 'admin'
);
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
📚 추가 학습 리소스
공식 문서
실습 프로젝트 아이디어
- 블로그 플랫폼: 게시글, 댓글, 좋아요 기능
- 할 일 관리 앱: 실시간 협업, 파일 첨부
- 소셜 미디어: 팔로우, 피드, 알림 시스템
- 이커머스: 상품 관리, 주문, 결제 연동
- 채팅 애플리케이션: 실시간 메시지, 파일 공유
고급 주제
- Database Functions: PostgreSQL 함수 작성
- Edge Functions: Deno 기반 서버리스 함수
- Webhook: 외부 서비스와 연동
- Multi-tenancy: 테넌트별 데이터 분리
- Performance Monitoring: 쿼리 성능 모니터링
Supabase와 Next.js의 조합은 현대적인 웹 애플리케이션 개발에 이상적인 스택입니다. PostgreSQL의 강력함과 Next.js의 유연함을 활용하여 확장 가능하고 성능이 뛰어난 애플리케이션을 구축해보세요! 🎯