13. Next.js 실전 통합
미들웨어는 방어선이 아니라 UX다. 진짜 방어선은 RLS다
왜 @supabase/ssr인가
섹션 제목: “왜 @supabase/ssr인가”브라우저 전용 supabase-js의 기본 동작은 세션을 localStorage 에 저장하는 것이다.
서버는 localStorage를 읽을 수 없으니 SSR에서 사용자를 알 수 없다.
@supabase/ssr이 하는 일은 이것을 쿠키로 옮기는 것이다.
- 세션을 쿠키에 저장한다 → 요청과 함께 서버로 전달된다
- 서버(미들웨어, Server Component, Route Handler)에서 세션을 읽을 수 있다
- 토큰 갱신 결과를 쿠키에 다시 써 준다
npm install @supabase/supabase-js @supabase/ssrNEXT_PUBLIC_SUPABASE_URL=https://<ref>.supabase.coNEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=sb_publishable_xxxxx
# 서버 전용 — NEXT_PUBLIC_ 을 절대 붙이지 않는다SUPABASE_SECRET_KEY=sb_secret_xxxxx로컬 스택을 쓸 때는 URL을 http://127.0.0.1:54321로,
키를 supabase start 출력의 anon key로 바꾼다.
.env.example을 커밋해서 어떤 변수가 필요한지 팀에 알린다.
클라이언트 4종
섹션 제목: “클라이언트 4종”flowchart LR
B["브라우저<br/>client.ts"] --> S["Supabase"]
SC["서버 컴포넌트 · 액션<br/>server.ts"] --> S
MW["미들웨어<br/>middleware.ts"] --> S
AD["관리자<br/>admin.ts — server-only"] --> S
classDef ok fill:#dcfce7,stroke:#16a34a,color:#14532d
classDef bad fill:#fee2e2,stroke:#dc2626,color:#7f1d1d
classDef key fill:#dbeafe,stroke:#2563eb,color:#1e3a8a
classDef mute fill:#f1f5f9,stroke:#94a3b8,color:#334155
class B,SC ok
class AD bad
class S key
class MW mute
앞의 셋은 RLS가 적용되는 경로이고, admin.ts만 우회한다.
브라우저 클라이언트
섹션 제목: “브라우저 클라이언트”import { createBrowserClient } from '@supabase/ssr'import type { Database } from '@/lib/database.types'
export function createClient() { return createBrowserClient<Database>( process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!, )}내부적으로 싱글턴이라 여러 번 호출해도 인스턴스는 하나다. 세션을 쿠키에 저장하므로 서버와 공유된다.
서버 클라이언트
섹션 제목: “서버 클라이언트”import { createServerClient } from '@supabase/ssr'import { cookies } from 'next/headers'import type { Database } from '@/lib/database.types'
export async function createClient() { const cookieStore = await cookies() return createServerClient<Database>( process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!, { cookies: { getAll: () => cookieStore.getAll(), setAll(list) { try { list.forEach(({ name, value, options }) => cookieStore.set(name, value, options)) } catch { // Server Component에서는 쿠키를 쓸 수 없다. // 갱신은 미들웨어가 담당하므로 무시해도 안전하다. } }, }, }, )}쿠키 처리 규칙
섹션 제목: “쿠키 처리 규칙”세 가지를 기억한다.
- 반드시
getAll/setAll만 쓴다. 개별get/set/remove를 구현하면 세션이 깨질 수 있다 — 토큰이 여러 청크 쿠키로 나뉘어 저장되기 때문이다 - 요청마다 새 클라이언트를 만든다. 서버에서는 요청마다 쿠키가 다르므로 인스턴스를 재사용하면 안 된다
- Server Component는 쿠키를 쓸 수 없다. 그래서
setAll을try/catch로 감싼다
sb-<project_ref>-auth-token ← 기본 쿠키 이름sb-<project_ref>-auth-token.0 ← 토큰이 크면 청크로 분할된다sb-<project_ref>-auth-token.1관리자 클라이언트
섹션 제목: “관리자 클라이언트”import 'server-only' // ← 클라이언트 import 시 빌드 에러import { createClient } from '@supabase/supabase-js'import type { Database } from '@/lib/database.types'
export const supabaseAdmin = createClient<Database>( process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.SUPABASE_SECRET_KEY!, { auth: { autoRefreshToken: false, persistSession: false } },)미들웨어
섹션 제목: “미들웨어”세션 갱신
섹션 제목: “세션 갱신”import { createServerClient } from '@supabase/ssr'import { NextResponse, type NextRequest } from 'next/server'
export async function updateSession(request: NextRequest) { let response = NextResponse.next({ request }) const supabase = createServerClient( process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!, { cookies: { getAll: () => request.cookies.getAll(), setAll(list) { list.forEach(({ name, value }) => request.cookies.set(name, value)) response = NextResponse.next({ request }) list.forEach(({ name, value, options }) => response.cookies.set(name, value, options)) }, }, }, ) // 이 호출이 만료된 토큰을 갱신한다. 절대 생략하지 말 것 const { data } = await supabase.auth.getClaims() return { response, claims: data?.claims ?? null }}라우트 보호
섹션 제목: “라우트 보호”// middleware.ts (프로젝트 루트)import { type NextRequest, NextResponse } from 'next/server'import { updateSession } from '@/lib/supabase/middleware'
const PROTECTED = ['/dashboard', '/settings', '/api/private']
export async function middleware(request: NextRequest) { const { response, claims } = await updateSession(request) const { pathname } = request.nextUrl
if (!claims && PROTECTED.some(p => pathname.startsWith(p))) { const url = request.nextUrl.clone() url.pathname = '/login' url.searchParams.set('next', pathname) return NextResponse.redirect(url) } return response}
export const config = { matcher: ['/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|webp)$).*)'],}주의사항
섹션 제목: “주의사항”- 반드시
response객체를 그대로 반환한다. 새NextResponse를 만들어 반환하면 갱신된 쿠키가 유실되고, 사용자가 무작위로 로그아웃된다 getClaims()호출과return사이에 로직을 최소화한다. 그 사이에 리다이렉트하면 쿠키가 안 실릴 수 있다getSession()을 권한 판단에 쓰지 않는다. 쿠키는 위조 가능하다- 미들웨어는 방어선이 아니라 UX다. 진짜 방어선은 RLS다 — 미들웨어를 우회해도 데이터는 안전해야 한다
- matcher를 좁게 잡는다. 정적 자산까지 미들웨어를 태우면 응답 지연과 함수 비용이 늘어난다
서버에서 조회하고 변경하기
섹션 제목: “서버에서 조회하고 변경하기”Server Component에서 조회
섹션 제목: “Server Component에서 조회”import { createClient } from '@/lib/supabase/server'
export default async function PostsPage() { const supabase = await createClient()
// 쿠키의 JWT가 자동으로 실린다 → RLS가 이 사용자 기준으로 동작한다 const { data: posts, error } = await supabase .from('posts') .select('id, title, created_at, profiles ( username )') .order('created_at', { ascending: false }) .limit(20)
if (error) throw new Error(error.message)
return <ul>{posts?.map(p => <li key={p.id}>{p.title}</li>)}</ul>}라우트 보호 패턴
섹션 제목: “라우트 보호 패턴”import { redirect } from 'next/navigation'import { createClient } from '@/lib/supabase/server'
export default async function DashboardLayout({ children,}: { children: React.ReactNode }) { const supabase = await createClient() const { data, error } = await supabase.auth.getClaims()
if (error || !data?.claims) redirect('/login')
return <section>{children}</section>}레이아웃에서 한 번 검사하면 하위 페이지 전체가 보호된다. 미들웨어와 중복되지만, 미들웨어를 우회하는 경로가 있을 수 있으므로 둘 다 두는 게 안전하다.
Server Action
섹션 제목: “Server Action”'use server'
import { revalidatePath } from 'next/cache'import { redirect } from 'next/navigation'import { createClient } from '@/lib/supabase/server'
export async function login(formData: FormData) { const supabase = await createClient()
const { error } = await supabase.auth.signInWithPassword({ email: String(formData.get('email')), password: String(formData.get('password')), })
if (error) return { error: '이메일 또는 비밀번호가 올바르지 않습니다.' }
revalidatePath('/', 'layout') // 캐시된 페이지에 로그인 상태 반영 redirect('/dashboard')}'use server'
export async function createPost(formData: FormData) { const supabase = await createClient()
const { data, error } = await supabase .from('posts') .insert({ title: String(formData.get('title')) }) .select() .single()
// RLS에 막히면 error.code === '42501' if (error) return { error: error.message }
revalidatePath('/posts') return { data }}Server Action은 공개 엔드포인트다. 누구나 호출할 수 있다고 가정하고 검증한다. 다만 RLS가 걸려 있으면 권한 검사를 한 번 더 안 해도 데이터는 안전하다 — 이게 Supabase의 이점이다.
로그아웃은 POST로 처리한다. GET이면 링크 프리페치나 크롤러가 로그아웃시킬 수 있다.
export async function POST(request: Request) { const supabase = await createClient() await supabase.auth.signOut() return NextResponse.redirect(new URL('/login', request.url), { status: 302 })}signOut()이 쿠키를 지우려면 쿠키를 쓸 수 있는 컨텍스트(Route Handler / Server Action)여야 한다.
Route Handler
섹션 제목: “Route Handler”import { createClient } from '@/lib/supabase/server'
export async function GET() { const supabase = await createClient() const { data: claims } = await supabase.auth.getClaims() if (!claims?.claims) return new Response('Unauthorized', { status: 401 })
const { data, error } = await supabase.from('posts').select('*').csv() if (error) return new Response(error.message, { status: 500 })
return new Response(data, { headers: { 'Content-Type': 'text/csv', 'Content-Disposition': 'attachment; filename="posts.csv"', }, })}Route Handler는 OAuth 콜백, 웹훅 수신, 파일 다운로드, 외부 API 프록시에 쓴다. 단순 데이터 조회는 Server Component가 더 간단하고, 웹훅이 프론트 배포와 독립적으로 살아 있어야 하면 Edge Function 쪽이 낫다 (12장).
클라이언트에서 조회와 구독
섹션 제목: “클라이언트에서 조회와 구독”'use client'
import { useEffect, useState } from 'react'import { createClient } from '@/lib/supabase/client'
export function CommentList({ postId }: { postId: number }) { const [comments, setComments] = useState<Comment[]>([]) const supabase = createClient()
useEffect(() => { let cancelled = false supabase .from('comments') .select('id, body, profiles ( username )') .eq('post_id', postId) .order('created_at') .then(({ data }) => { if (!cancelled) setComments(data ?? []) })
return () => { cancelled = true } }, [postId])
return <ul>{comments.map(c => <li key={c.id}>{c.body}</li>)}</ul>}이 요청은 Vercel을 거치지 않는다. 브라우저에서 Supabase로 직접 간다 — 함수 비용이 0이다.
'use client'
export function LiveComments({ postId, initial }: Props) { const [comments, setComments] = useState(initial) // 서버에서 받은 초기 데이터
useEffect(() => { const supabase = createClient() const channel = supabase .channel(`comments:${postId}`) .on('postgres_changes', { event: 'INSERT', schema: 'public', table: 'comments', filter: `post_id=eq.${postId}`, }, ({ new: row }) => setComments(prev => [...prev, row])) .subscribe()
return () => { supabase.removeChannel(channel) } // 정리 필수 }, [postId])
return <ul>{comments.map(c => <li key={c.id}>{c.body}</li>)}</ul>}캐싱과 동적 렌더링
섹션 제목: “캐싱과 동적 렌더링”인증이 걸린 페이지에서 가장 위험한 실수는 사용자별 데이터가 캐시되는 것이다.
// 방법 1: 라우트 전체를 동적으로export const dynamic = 'force-dynamic'
// 방법 2: 특정 조회만 캐시 제외import { unstable_noStore as noStore } from 'next/cache'export default async function Page() { noStore() // ...}cookies()를 읽으면 Next.js가 자동으로 동적 렌더링으로 전환한다 →createClient()를 쓰는 Server Component는 대체로 자동으로 동적이 된다- 하지만 명시하는 편이 안전하다. 리팩터링 중 조용히 정적으로 바뀔 수 있다
- 공개 데이터(로그인 불필요)는 오히려 적극적으로 캐시하자
타입과 디렉터리 구조
섹션 제목: “타입과 디렉터리 구조”// lib/database.types.ts ← supabase gen types 결과 (직접 수정하지 않는다)export type Database = { /* 자동 생성 */ }
// lib/types.ts ← 사람이 쓰는 별칭import type { Database } from './database.types'
export type Tables<T extends keyof Database['public']['Tables']> = Database['public']['Tables'][T]['Row']export type Inserts<T extends keyof Database['public']['Tables']> = Database['public']['Tables'][T]['Insert']
export type Post = Tables<'posts'>export type Profile = Tables<'profiles'>디렉터리app/
디렉터리(auth)/login/ page.tsx, actions.ts
- …
- auth/callback/route.ts OAuth code → session 교환
디렉터리(app)/dashboard/
- layout.tsx 인증 가드
- posts/page.tsx
- api/stripe/webhook/route.ts 또는 Edge Function
- logout/route.ts
디렉터리lib/
디렉터리supabase/
- client.ts 브라우저
- server.ts 서버 (RLS 적용)
- middleware.ts 세션 갱신
- admin.ts secret key — server-only
- database.types.ts 자동 생성
- types.ts
- middleware.ts
디렉터리supabase/ 마이그레이션, 함수, config
- …
함정 모음
섹션 제목: “함정 모음”- 미들웨어에서
response를 반환하지 않음 → 무작위 로그아웃 getSession()으로 권한 판단 → 위조 가능.getClaims()를 쓴다- 쿠키 핸들러를
get/set으로 구현 → 청크 쿠키가 깨진다 - 서버 클라이언트를 모듈 최상단에서 생성 → 요청 간 세션 혼선
- secret key를 클라이언트 컴포넌트에서 import →
server-only로 막는다 - 사용자별 페이지가 캐시됨 → 다른 사람의 데이터가 보인다
revalidatePath누락 → 변경했는데 화면이 그대로- Realtime 채널 정리 누락 → StrictMode에서 이벤트 중복
@supabase/auth-helpers-nextjs사용 → 구버전- OAuth 콜백 라우트 누락 → 소셜 로그인이 완료되지 않는다
13장 요약
섹션 제목: “13장 요약”@supabase/ssr이 세션을 쿠키로 옮겨 서버에서도 읽게 해준다- 클라이언트는 4종: 브라우저 / 서버 / 미들웨어 / 관리자(server-only)
- 쿠키는 반드시
getAll/setAll, 서버 클라이언트는 요청마다 생성 - 미들웨어는 세션 갱신 + UX 리다이렉트. 진짜 방어선은 RLS
- 초기 데이터는 Server Component, 실시간 갱신은 클라이언트 구독
- 인증 페이지의 캐싱을 항상 의심한다