PostSQL 기반 오픈소스 BaaS(Backend as a Service) 플랫폼
- Firebase 대안
- 데이터 베이스, 사용자 인증, 실시간 데이터, 스토리지 등 기능 제공
설정 Next 16(App Router)
pnpm add @supabase/ssr @supabase/supabase-js환경변수
NEXT_PUBLIC_SUPABASE_URL=
NEXT_PUBLIC_SUPABASE_ANON_KEY=NEXT_PUBLIC_: 클라이언트 컴포넌트에서도 접근
server, client 분리 (lib/supabase)
server.ts: 서버 컴포넌트client.ts:use client컴포넌트 사용middleware.ts: 세션 갱신
src/lib/supabase/server.ts
import { createServerClient } from '@supabase/ssr'
import { cookies } from 'next/headers'
export async function createClient() {
const cookieStore = await cookies()
return createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll() {
return cookieStore.getAll()
},
setAll(cookiesToSet) {
try {
cookiesToSet.forEach(({ name, value, options }) =>
cookieStore.set(name, value, options)
)
} catch {
// Server Component에서는 쿠키를 수정할 수 없으므로 에러가 나도 무시합니다.
// middleware나 Server Action에서 처리하게 됩니다.
}
},
},
}
)
}src/lib/supabase/client.ts
import { createBrowserClient } from '@supabase/ssr'
export function createClient() {
return createBrowserClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
)
}스키마 (PostgreSQL)
UUID |
고유 식별자 |
PRIMARY KEY |
column 한 번만 정의 |
NOT NULL |
값이 꼭 있어야함 |
REFERENCES |
다른 테이블 참조 |
UNIQUE |
중복되면 안됨 |
삭제
DROP TABLE IF EXISTS 테이블 CASCADE;
-- 함수 및 트리거 삭제
DROP FUNCTION IF EXISTS 함수 CASCADE;Method
.from('테이블 명') |
대상 테이블 지정 |
.select('column1, column2') |
지정된 테이블의 column 선택 .select() .select('*') 모든 column 선택 |
.eq('column', 'value') |
지정한 값과 일치하는 데이터만 필터링 |
.single() |
결과 배열에서 객체 추출 데이터 1 (0 이거나 2이상 에러) |
.maybeSingle() |
데이터 0 또는 1 (2이상 에러) : 존재 여부 확인 |
.insert() |
데이터 추가, 객체형태로 전달 .insert({name: 'new'}) .insert([{name: 'new 1'}, {name: 'new 2'}]) .insert({...}).select().single() : 아이템 추가 후 리스트에 바로 보여줘야하는 경우 |
.upsert() |
Update + Insert 여러 데이터 한번에 추가 |
Next 16 + Supabase Auth
1. middleware
Supabase middleware 헬퍼
- supabase 서버 컴포넌트는 쿠키를 읽을수만 있으므로 쿠키 set을 next의 proxy(middleware)에 위임
- 인증 토큰 확인, 갱신
- 쿠키 토큰 검사 후 NextResponse 객체 받기
src/lib/supabase/middleware.ts
import { createServerClient } from '@supabase/ssr';
import { NextResponse, type NextRequest } from 'next/server';
export async function updateSession(request: NextRequest) {
// 토큰 갱신 시 다시 set
let supabaseResponse = NextResponse.next({ request });
const supabase = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll() {
return request.cookies.getAll();
},
// 토큰 갱신 시 Supabase가 자동 호출 (getUser())
setAll(cookiesToSet) {
cookiesToSet.forEach(({ name, value }) => request.cookies.set(name, value));
// 객체 새로 만들기
supabaseResponse = NextResponse.next({ request });
// 쿠키 set
cookiesToSet.forEach(({ name, value, options }) =>
response.cookies.set(name, value, options),
);
},
},
},
);
// 세션 갱신 (만료 시 자동 refresh)
const {
data: { user },
} = await supabase.auth.getUser();
// 보호 라우트 처리
const { pathname } = request.nextUrl; // 현재 사용자가 요청한 경로
const isPublic =
pathname === '/' || pathname.startsWith('/signup') || pathname.startsWith('/login');
const isAuthPage = pathname.startsWith('/signup') || pathname.startsWith('/login');
if ((!user && !isPublic) || (user && isAuthPage)) {
const url = request.nextUrl.clone();
url.pathname = '/';
return NextResponse.redirect(url);
}
return supabaseResponse;
}request.url“https://…” (string)request.nextUrlNextURL 객체request.nextUrl.clone()원본 URL 객체를 복사한 뒤 수정 → 도메인, 프로토콜 자동으로 유지, 원본이 오염되지 않음
getSessionClient 측 확인, 반복적 조회해야하는 경우getUserServer 측 확인, 항상 최신의 인증 정보 제공, 보안 강화
Next 16 Proxy (middleware)
src/proxy.ts
import { type NextRequest } from 'next/server';
import { updateSession } from '@/lib/supabase/middleware';
export async function proxy(request: NextRequest) {
return await updateSession(request);
}
export const config = {
matcher: [
/*
* 제외:
* - _next/static
* - _next/image
* - favicon.ico
* - 이미지 확장자
* - .well-known
*/
'/((?!_next/static|_next/image|favicon.ico|.well-known|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)',
],
};2. Server Actions
- form 제출 처리, 상태 반환
NEXT_PUBLIC_SITE_URL=http://localhost:3000 # 개발
NEXT_PUBLIC_SITE_URL=https://myapp.com # 배포2-1. URL 쿼리 파라미터로 상태 반환
'use server'사용 이유- Client의 FormData를 연결
cookies()사용
src/app/auth/actions.ts
'use server';
import { createClient } from '@/lib/supabase/server';
import { redirect } from 'next/navigation';
export async function signUp(formData: FormData) {
const supabase = await createClient();
const { error } = await supabase.auth.signUp({
email: formData.get('email') as string,
password: formData.get('password') as string,
});
if (error) redirect('/signup?error=' + error.message);
redirect('/signup?message=check_email'); // check_email 상태
}
export async function login(formData: FormData) {
const supabase = await createClient();
const { error } = await supabase.auth.signInWithPassword({
email: formData.get('email') as string,
password: formData.get('password') as string,
});
if (error) redirect('/login?error=' + error.message); // error 상태
redirect('/');
}
export async function loginWithGoogle() {
const supabase = await createClient();
const { data, error } = await supabase.auth.signInWithOAuth({
provider: 'google',
options: {
redirectTo: `${process.env.NEXT_PUBLIC_SITE_URL}/auth/callback`,
},
});
if (data.url) redirect(data.url);
}2-2. useActionState 사용
React 19 이상 폼 처리 방식
- Server Action 에러 객체 반환, 클라이언트에서 Hook으로 받아 처리
useFormState(React 18 이전 버전)
export async function login(이전 상태, FormData){
...
}const [state, formAction, isPending] = useActionState(실행할 Server Action, 초기 상태값)src/app/auth/action.ts
'use server';
import { createClient } from '@/lib/supabase/server';
import { redirect } from 'next/navigation';
export type FormState = {
error?: string;
payload?: {
email?: string;
};
};
export async function signup(prevState: FormState, formData: FormData) {
const supabase = await createClient();
const email = formData.get('email') as string;
const password = formData.get('password') as string;
if (!email || !password) {
return { error: 'empty', payload: { email } };
}
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(email)) {
return { error: 'email_vallid', payload: { email } };
}
const { data, error } = await supabase.auth.signUp({
email,
password,
});
if (error) return { error: error.code, payload: { email } };
if (data.user && (!data.user.identities || data.user.identities.length === 0)) {
return { error: 'email_exists', payload: { email } };
}
redirect('/');
}src/app/signup/page.tsx
'use client';
import { FormState, signup } from '@/app/auth/actions';
import { useActionState } from 'react';
const SIGNUP_ERROR_MSG: Record<string, string> = {
empty: '입력하세요',
AuthWeakPasswordError: '1',
weak_password: '8자 이상',
email_exists: '이미 가입',
email_vallid: '이메일 형식이 올바르지 않습니다.',
email_address_invalid: '사용할 수 없는 이메일 주소입니다.',
over_email_send_rate_limit: 'limit',
};
export default function SignupPage() {
const [state, formAction, isPending] = useActionState<FormState, FormData>(signup, {
error: undefined,
payload: { email: '' },
});
console.log(state?.error);
const errorMsg = state?.error
? SIGNUP_ERROR_MSG[state.error] || '회원가입 중 오류가 발생했습니다.'
: null;
return (
<>
<form action={formAction}>
<div>
<input
type="text"
name="email"
placeholder="이메일"
defaultValue={state?.payload?.email || ''}
/>
</div>
<div>
<input type="password" name="password" placeholder="비밀번호" />
</div>
<div className="mt-10">
<button type="submit" disabled={isPending} className="bg-brand-500 w-full">
가입
</button>
</div>
<div className="mt-10">{errorMsg && <p className="text-red-500">{errorMsg}</p>}</div>
</form>
</>
);
}OAuth (Google Auth)
- 소셜 로그인
- 각 인증 마친 후 redirect
(auth/callback?code=XXXX)
src/app/auth/callback/route.ts
import { createClient } from '@/lib/supabase/server';
import { NextResponse } from 'next/server';
export async function GET(request: Request) {
const { searchParams, origin } = new URL(request.url); // origin: redirect 주소
const code = searchParams.get('code'); // XXXX
const next = searchParams.get('next') ?? '/';
if (code) {
const supabase = await createClient();
const { error } = await supabase.auth.exchangeCodeForSession(code); // access token + refresh token 교환, 쿠키 저장
if (!error) {
return NextResponse.redirect(`${origin}${next}`);
}
}
return NextResponse.redirect(`${origin}/login?error=auth_callback_failed`);loginloginWithGoogle차이login사용자 → Supabase 세션 발급loginWithGoogle사용자 → Google 서버 왕복 →/auth/callback에서 토큰 교환(exchangeCodeForSession)
3. Server Component에서 유저정보 가져오기
src/app/page.tsx
import { createClient } from '@/lib/supabase/server';
export default async function Home() {
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
...
}4. Pages
로그인, 회원가입
src/app/login/page.tsx
import { login } from '@/app/auth/actions';
import Link from 'next/link';
export default async function Login({
searchParams,
}: {
searchParams: Promise<{ error?: string }>;
}) {
const { error } = await searchParams;
return (
<>
<form action={login}>
<input type="email" name="email" placeholder="이메일" required />
<input type="password" name="password" placeholder="비밀번호" required />
<button type="submit">Login</button>
</form>
{error && <p className="text-red-500">{error}</p>}
<Link href="/signup">Sign up</Link>
</>
);
}src/app/signup/page.tsx
import { signUp } from '@/app/auth/actions';
export default async function Signup({
searchParams,
}: {
searchParams: Promise<{ error?: string; message?: string }>;
}) {
const { error, message } = await searchParams;
return (
<>
<form action={signUp}>
<input type="email" name="email" placeholder="이메일" required />
<input type="password" name="password" placeholder="비밀번호" required />
{error && <p className="text-red-500">{error}</p>}
{message === 'check_email' && <p className="text-red-500">확인</p>}
<button type="submit">가입</button>
</form>
</>
);
}5. Client Component에서 유저정보 가져오기
5-1. Server Component에서 props로 직접 전달
src/app/playlist/page.tsx
import Search from '@/components/search/Search';
import Turntable from '@/components/turntable/Turntable';
import AlbumCoverList from '@/components/AlbumCoverList';
import { createClient } from '@/lib/supabase/server';
import { redirect } from 'next/navigation';
export default async function Playlist() {
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) redirect('/');
return (
<div>
<Search userId={user.id} />
<AlbumCoverList userId={user.id} />
<Turntable />
</div>
);
}export default function Search({ userId }: { userId: string }) {}5-2. AuthProvider
- provider 전역 설정, 상태 관리
- 인증 상태 전환 UI (로그인/로그아웃 이벤트 감지)
layout.tsx
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="ko" className={`h-full antialiased`}>
<body className="min-h-full flex flex-col">
<TanstackProvider>
<AuthProvider>{children}</AuthProvider>
</TanstackProvider>
</body>
</html>
);
}src/providers/AuthProvider.tsx
'use client';
import { createContext, ReactNode, useContext, useEffect, useState } from 'react';
import { createClient } from '@/lib/supabase/client';
import type { User } from '@supabase/supabase-js';
const AuthContext = createContext<{ user: User | null } | null>(null); // 전역 state 관리
export default function AuthProvider({ children }: { children: ReactNode }) {
const supabase = createClient();
const [user, setUser] = useState<User | null>(null); // user state
useEffect(() => {
supabase.auth.getUser().then(({ data }) => {
setUser(data.user); // 쿠키에 세션 있으면 User, 없으면 null
});
const {
data: { subscription },
} = supabase.auth.onAuthStateChange((event, session) => {
setUser(session?.user ?? null);
});
return () => subscription.unsubscribe(); // unmount 시 subscription 해제
}, []);
return <AuthContext.Provider value={{ user }}>{children}</AuthContext.Provider>;
}
export function useUser() {
return useContext(AuthContext); // 전역 state 값 꺼내기
}createContext전역 State (user) 관리onAuthStateChange((event, session)=>{})'INITIAL_SESSION', 'SIGNED_IN', ...각 이벤트session.userstate 업데이트
