13. Next.js 실전 통합
Proxy는 방어선이 아니라 UX다. 진짜 방어선은 RLS다
왜 @supabase/ssr인가
섹션 제목: “왜 @supabase/ssr인가”브라우저 전용 supabase-js의 기본 동작은 세션을 localStorage 에 저장하는 것이다.
서버는 localStorage를 읽을 수 없으니 SSR에서 사용자를 알 수 없다.
@supabase/ssr이 하는 일은 이것을 쿠키로 옮기는 것이다.
- 세션을 쿠키에 저장한다 → 요청과 함께 서버로 전달된다
- 서버(Proxy, 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종”앞의 셋은 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에서는 쿠키를 쓸 수 없다. // 갱신은 Proxy가 담당하므로 무시해도 안전하다. } }, }, }, )}쿠키 처리 규칙
섹션 제목: “쿠키 처리 규칙”세 가지를 기억한다.
- 반드시
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 } },)Proxy
섹션 제목: “Proxy”Next.js 16부터 middleware.ts는 proxy.ts로 이름이 바뀌었다.
기능은 같지만, 전체 권한 검사를 맡는 계층이 아니라 요청 앞의 네트워크 경계라는 뜻을 분명히 한 이름이다.
세션 갱신
섹션 제목: “세션 갱신”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 }}라우트 보호
섹션 제목: “라우트 보호”// proxy.ts (프로젝트 루트)import { type NextRequest, NextResponse } from 'next/server'import { updateSession } from '@/lib/supabase/proxy'
const PROTECTED = ['/dashboard', '/settings', '/api/private']
export async function proxy(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) const redirectResponse = NextResponse.redirect(url) response.cookies.getAll().forEach(cookie => redirectResponse.cookies.set(cookie)) return redirectResponse } return response}
export const config = { matcher: ['/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|webp)$).*)'],}주의사항
섹션 제목: “주의사항”- 일반 경로에서는
response객체를 그대로 반환한다. 리다이렉트처럼 새NextResponse가 필요하면 위 예제처럼 갱신된 쿠키를 복사한다. 빼먹으면 사용자가 무작위로 로그아웃된다 getClaims()호출과return사이에 로직을 최소화한다. 그 사이에 리다이렉트하면 쿠키가 안 실릴 수 있다getSession()을 권한 판단에 쓰지 않는다. 쿠키는 위조 가능하다- Proxy는 방어선이 아니라 UX다. 진짜 방어선은 RLS다 — Proxy를 우회해도 데이터는 안전해야 한다
- matcher를 좁게 잡는다. 정적 자산까지 Proxy를 태우면 응답 지연과 함수 비용이 늘어난다
서버에서 조회하고 변경하기
섹션 제목: “서버에서 조회하고 변경하기”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 'server-only'import { cache } from 'react'import { redirect } from 'next/navigation'import { createClient } from '@/lib/supabase/server'
export const requireUser = cache(async () => { const supabase = await createClient() const { data, error } = await supabase.auth.getClaims()
if (error || !data?.claims) redirect('/login') return data.claims})import { requireUser } from '@/lib/dal'
export default async function DashboardPage() { const user = await requireUser() return <h1>{user.email}의 대시보드</h1>}가드는 실제 데이터 접근점 가까이에서 호출한다. 레이아웃은 클라이언트 탐색 때마다 다시 렌더되지 않을 수 있고,
Server Action·Route Handler는 별도 진입점이므로 레이아웃 검사 하나로 하위 경로 전체가 보호되지는 않는다.
cache()는 한 번의 서버 렌더 안에서 중복 검증만 합친다. Proxy의 리다이렉트와 겹쳐도 역할이 다르다.
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는 검사를 생략할 이유가 아니라, 애플리케이션 검사가 빠졌거나 다른 경로가 생겼을 때도 허용되지 않은 행 변경을 막는 최종 방어선이다.
로그아웃은 POST로 처리한다. GET이면 링크 프리페치나 크롤러가 로그아웃시킬 수 있다.
import { NextResponse } from 'next/server'import { createClient } from '@/lib/supabase/server'
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>}캐싱과 동적 렌더링
섹션 제목: “캐싱과 동적 렌더링”인증이 걸린 페이지에서 가장 위험한 실수는 사용자별 데이터가 캐시되는 것이다.
export default async function Page() { const supabase = await createClient() // 내부에서 cookies()를 읽는다 const { data } = await supabase.from('profiles').select().single() return <Profile data={data} />}cookies()는 Dynamic API다. 페이지나 레이아웃에서 읽으면 해당 라우트는 동적 렌더링으로 전환된다- 사용자별 조회를
'use cache'함수나 공유 CDN 캐시에 넣지 않는다. 캐시해야 한다면 사용자 ID 등 권한 경계를 캐시 키에 포함하고, 응답의Set-Cookie가 공유 캐시되지 않는지 확인한다 - 공개 데이터(로그인 불필요)는
'use cache',cacheLife, 태그 무효화를 사용해 적극적으로 캐시한다
타입과 디렉터리 구조
섹션 제목: “타입과 디렉터리 구조”// 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/
- page.tsx
requireUser()로 데이터 접근점에서 인증 가드 - posts/page.tsx
- page.tsx
- api/stripe/webhook/route.ts 또는 Edge Function
- logout/route.ts
디렉터리lib/
디렉터리supabase/
- client.ts 브라우저
- server.ts 서버 (RLS 적용)
- proxy.ts 세션 갱신
- admin.ts secret key — server-only
- database.types.ts 자동 생성
- types.ts
- proxy.ts
디렉터리supabase/ 마이그레이션, 함수, config
- …
함정 모음
섹션 제목: “함정 모음”- Proxy에서
response를 반환하지 않음 → 무작위 로그아웃 getSession()으로 권한 판단 → 위조 가능.getClaims()를 쓴다- 쿠키 핸들러를
get/set으로 구현 → 청크 쿠키가 깨진다 - 서버 클라이언트를 모듈 최상단에서 생성 → 요청 간 세션 혼선
- secret key를 클라이언트 컴포넌트에서 import →
server-only로 막는다 - 사용자별 페이지가 캐시됨 → 다른 사람의 데이터가 보인다
revalidatePath누락 → 변경했는데 화면이 그대로- Realtime 채널 정리 누락 → StrictMode에서 이벤트 중복
@supabase/auth-helpers-nextjs사용 → 구버전- OAuth 콜백 라우트 누락 → 소셜 로그인이 완료되지 않는다
13장 요약
섹션 제목: “13장 요약”@supabase/ssr이 세션을 쿠키로 옮겨 서버에서도 읽게 해준다- 클라이언트는 4종: 브라우저 / 서버 / Proxy / 관리자(server-only)
- 쿠키는 반드시
getAll/setAll, 서버 클라이언트는 요청마다 생성 - Proxy는 세션 갱신 + UX 리다이렉트. 진짜 방어선은 RLS
- 초기 데이터는 Server Component, 실시간 갱신은 클라이언트 구독
- 인증 페이지의 캐싱을 항상 의심한다
참고 자료
섹션 제목: “참고 자료”- Supabase Next.js SSR 구성 — 두 클라이언트와
proxy.ts세션 갱신 - Supabase SSR 고급 가이드 — 요청별 클라이언트와 CDN 캐시 주의
- Next.js Proxy — Next.js 16 이름 변경과 권장 용도
- Next.js 인증 가이드 — DAL, Server Action·Route Handler의 독립 권한 검사