콘텐츠로 이동

13. Next.js 실전 통합

미들웨어는 방어선이 아니라 UX다. 진짜 방어선은 RLS다

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

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

  • 세션을 쿠키에 저장한다 → 요청과 함께 서버로 전달된다
  • 서버(미들웨어, Server Component, Route Handler)에서 세션을 읽을 수 있다
  • 토큰 갱신 결과를 쿠키에 다시 써 준다
Terminal window
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을 커밋해서 어떤 변수가 필요한지 팀에 알린다.

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만 우회한다.

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에서는 쿠키를 쓸 수 없다.
// 갱신은 미들웨어가 담당하므로 무시해도 안전하다.
}
},
},
},
)
}

세 가지를 기억한다.

  1. 반드시 getAll / setAll만 쓴다. 개별 get/set/remove를 구현하면 세션이 깨질 수 있다 — 토큰이 여러 청크 쿠키로 나뉘어 저장되기 때문이다
  2. 요청마다 새 클라이언트를 만든다. 서버에서는 요청마다 쿠키가 다르므로 인스턴스를 재사용하면 안 된다
  3. Server Component는 쿠키를 쓸 수 없다. 그래서 setAlltry/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 } },
)
lib/supabase/middleware.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 }
}
// 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)$).*)'],
}
  1. 반드시 response 객체를 그대로 반환한다.NextResponse를 만들어 반환하면 갱신된 쿠키가 유실되고, 사용자가 무작위로 로그아웃된다
  2. getClaims() 호출과 return 사이에 로직을 최소화한다. 그 사이에 리다이렉트하면 쿠키가 안 실릴 수 있다
  3. getSession()을 권한 판단에 쓰지 않는다. 쿠키는 위조 가능하다
  4. 미들웨어는 방어선이 아니라 UX다. 진짜 방어선은 RLS다 — 미들웨어를 우회해도 데이터는 안전해야 한다
  5. matcher를 좁게 잡는다. 정적 자산까지 미들웨어를 태우면 응답 지연과 함수 비용이 늘어난다
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>
}
app/dashboard/layout.tsx
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>
}

레이아웃에서 한 번 검사하면 하위 페이지 전체가 보호된다. 미들웨어와 중복되지만, 미들웨어를 우회하는 경로가 있을 수 있으므로 둘 다 두는 게 안전하다.

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가 걸려 있으면 권한 검사를 한 번 더 안 해도 데이터는 안전하다 — 이게 Supabase의 이점이다.

로그아웃은 POST로 처리한다. GET이면 링크 프리페치나 크롤러가 로그아웃시킬 수 있다.

app/logout/route.ts
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>
}

인증이 걸린 페이지에서 가장 위험한 실수는 사용자별 데이터가 캐시되는 것이다.

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