콘텐츠로 이동
Study NoteSupabase

13. Next.js 실전 통합

Proxy는 방어선이 아니라 UX다. 진짜 방어선은 RLS다

브라우저 전용 supabase-js의 기본 동작은 세션을 localStorage 에 저장하는 것이다. 서버는 localStorage를 읽을 수 없으니 SSR에서 사용자를 알 수 없다.

@supabase/ssr이 하는 일은 이것을 쿠키로 옮기는 것이다.

  • 세션을 쿠키에 저장한다 → 요청과 함께 서버로 전달된다
  • 서버(Proxy, Server Component, Route Handler)에서 세션을 읽을 수 있다
  • 토큰 갱신 결과를 쿠키에 다시 써 준다
터미널 창
npm install @supabase/supabase-js @supabase/ssr
.env.local
NEXT_PUBLIC_SUPABASE_URL=https://<ref>.supabase.co
NEXT_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을 커밋해서 어떤 변수가 필요한지 팀에 알린다.

브라우저·서버 컴포넌트·Proxy·관리자 네 종류의 Supabase 클라이언트가 각각 다른 파일에서 만들어진다

앞의 셋은 RLS가 적용되는 경로이고, admin.ts만 우회한다.

lib/supabase/client.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!,
)
}

내부적으로 싱글턴이라 여러 번 호출해도 인스턴스는 하나다. 세션을 쿠키에 저장하므로 서버와 공유된다.

lib/supabase/server.ts
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가 담당하므로 무시해도 안전하다.
}
},
},
},
)
}

세 가지를 기억한다.

  1. 반드시 getAll / setAll만 쓴다. 개별 get/set/remove를 구현하면 세션이 깨질 수 있다 — 토큰이 여러 청크 쿠키로 나뉘어 저장되기 때문이다
  2. 요청마다 새 클라이언트를 만든다. 서버에서는 요청마다 쿠키가 다르므로 인스턴스를 재사용하면 안 된다
  3. Server Component는 쿠키를 쓸 수 없다. 그래서 setAll을 try/catch로 감싼다
sb-<project_ref>-auth-token ← 기본 쿠키 이름
sb-<project_ref>-auth-token.0 ← 토큰이 크면 청크로 분할된다
sb-<project_ref>-auth-token.1
lib/supabase/admin.ts
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 } },
)

Next.js 16부터 middleware.ts는 proxy.ts로 이름이 바뀌었다. 기능은 같지만, 전체 권한 검사를 맡는 계층이 아니라 요청 앞의 네트워크 경계라는 뜻을 분명히 한 이름이다.

lib/supabase/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)$).*)'],
}
  1. 일반 경로에서는 response 객체를 그대로 반환한다. 리다이렉트처럼 새 NextResponse가 필요하면 위 예제처럼 갱신된 쿠키를 복사한다. 빼먹으면 사용자가 무작위로 로그아웃된다
  2. getClaims() 호출과 return 사이에 로직을 최소화한다. 그 사이에 리다이렉트하면 쿠키가 안 실릴 수 있다
  3. getSession()을 권한 판단에 쓰지 않는다. 쿠키는 위조 가능하다
  4. Proxy는 방어선이 아니라 UX다. 진짜 방어선은 RLS다 — Proxy를 우회해도 데이터는 안전해야 한다
  5. matcher를 좁게 잡는다. 정적 자산까지 Proxy를 태우면 응답 지연과 함수 비용이 늘어난다
app/posts/page.tsx
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>
}
lib/dal.ts
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
})
app/dashboard/page.tsx
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의 리다이렉트와 겹쳐도 역할이 다르다.

app/login/actions.ts
'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이면 링크 프리페치나 크롤러가 로그아웃시킬 수 있다.

app/logout/route.ts
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)여야 한다.

app/api/export/route.ts
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
    • 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
    • …
  1. Proxy에서 response를 반환하지 않음 → 무작위 로그아웃
  2. getSession()으로 권한 판단 → 위조 가능. getClaims()를 쓴다
  3. 쿠키 핸들러를 get/set으로 구현 → 청크 쿠키가 깨진다
  4. 서버 클라이언트를 모듈 최상단에서 생성 → 요청 간 세션 혼선
  5. secret key를 클라이언트 컴포넌트에서 import → server-only로 막는다
  6. 사용자별 페이지가 캐시됨 → 다른 사람의 데이터가 보인다
  7. revalidatePath 누락 → 변경했는데 화면이 그대로
  8. Realtime 채널 정리 누락 → StrictMode에서 이벤트 중복
  9. @supabase/auth-helpers-nextjs 사용 → 구버전
  10. OAuth 콜백 라우트 누락 → 소셜 로그인이 완료되지 않는다
  • @supabase/ssr이 세션을 쿠키로 옮겨 서버에서도 읽게 해준다
  • 클라이언트는 4종: 브라우저 / 서버 / Proxy / 관리자(server-only)
  • 쿠키는 반드시 getAll / setAll, 서버 클라이언트는 요청마다 생성
  • Proxy는 세션 갱신 + UX 리다이렉트. 진짜 방어선은 RLS
  • 초기 데이터는 Server Component, 실시간 갱신은 클라이언트 구독
  • 인증 페이지의 캐싱을 항상 의심한다