Next.js로 Supabase 인증을 붙이다 보면 결국 middleware.ts를 만나게 돼요. 처음에 “이게 뭐지?” 했는데, 알고 보니 모든 요청이 페이지에 도달하기 전에 거치는 필터예요. 이 글에서는 Next.js middleware.ts로 Supabase 인증을 처리하는 방법을 실전 코드와 함께 처음부터 설명합니다.
🤔 middleware.ts가 뭔가요?
쉽게 말하면 모든 요청이 페이지에 도달하기 전에 먼저 거치는 관문이에요.
예를 들어 유저가 /dashboard에 접속하려고 하면, 브라우저 → middleware → 페이지 순서로 요청이 흘러가요. middleware에서 “이 유저 로그인 안 했네?”를 감지하면 페이지에 도달하기 전에 /login으로 보내버릴 수 있어요.
🏗️ 전체 구조 이해하기
Supabase + Next.js에서 middleware를 쓰면 파일 구조가 이렇게 돼요.
my-app/
├── middleware.ts ← 여기서 인증 체크
├── utils/
│ └── supabase/
│ ├── client.ts ← 클라이언트 컴포넌트용
│ ├── server.ts ← 서버 컴포넌트용
│ └── middleware.ts ← middleware 전용 Supabase 클라이언트
└── app/
├── login/
│ └── page.tsx
└── dashboard/
└── page.tsx ← 보호가 필요한 페이지
middleware 파일이 두 개인 게 헷갈릴 수 있는데요, 역할이 달라요.
middleware.ts(루트) — Next.js가 실행하는 진짜 미들웨어. 요청을 가로채서 처리해요utils/supabase/middleware.ts— Supabase 세션 갱신 로직을 담은 헬퍼 함수예요
⚙️ 실전 코드: 단계별로 만들어보기
1단계: Supabase 미들웨어 헬퍼 만들기
먼저 utils/supabase/middleware.ts를 만들어요. 이 파일은 매 요청마다 Supabase 세션을 갱신하고 쿠키를 최신 상태로 유지하는 역할을 해요.
// utils/supabase/middleware.ts
import { createServerClient } from '@supabase/ssr'
import { NextResponse, type NextRequest } from 'next/server'
export async function updateSession(request: NextRequest) {
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()
},
setAll(cookiesToSet) {
// 요청 쿠키 갱신
cookiesToSet.forEach(({ name, value }) =>
request.cookies.set(name, value)
)
supabaseResponse = NextResponse.next({
request,
})
// 응답 쿠키 갱신 (브라우저로 전달)
cookiesToSet.forEach(({ name, value, options }) =>
supabaseResponse.cookies.set(name, value, options)
)
},
},
}
)
// ⚠️ 중요: getUser()로 세션 검증 (getSession 사용 금지!)
const {
data: { user },
} = await supabase.auth.getUser()
return { supabaseResponse, user }
}
⚠️ 왜 getUser()를 써야 하나요?
Supabase 공식 문서가 middleware에서 getSession() 사용을 명시적으로 금지해요. getSession()은 토큰 재검증이 보장되지 않아서 보안 취약점이 생길 수 있거든요. 자세한 내용은 이전 글 참고해 주세요!
2단계: 루트 middleware.ts 만들기
이제 프로젝트 루트에 middleware.ts를 만들어요. 여기서 실제 인증 체크와 리다이렉트를 처리해요.
// middleware.ts (프로젝트 루트)
import { type NextRequest, NextResponse } from 'next/server'
import { updateSession } from '@/utils/supabase/middleware'
export async function middleware(request: NextRequest) {
const { supabaseResponse, user } = await updateSession(request)
const { pathname } = request.nextUrl
// 로그인/회원가입 페이지
const isAuthPage = pathname.startsWith('/login') || pathname.startsWith('/signup')
// 보호가 필요한 페이지 (로그인 필수)
const isProtectedPage = pathname.startsWith('/dashboard') || pathname.startsWith('/my')
// 🔒 비로그인 유저가 보호 페이지 접근 시 → 로그인 페이지로 이동
if (!user && isProtectedPage) {
const url = request.nextUrl.clone()
url.pathname = '/login'
return NextResponse.redirect(url)
}
// 🔓 이미 로그인한 유저가 로그인 페이지 접근 시 → 대시보드로 이동
if (user && isAuthPage) {
const url = request.nextUrl.clone()
url.pathname = '/dashboard'
return NextResponse.redirect(url)
}
return supabaseResponse
}
// middleware가 실행될 경로 설정
export const config = {
matcher: [
/*
* 아래 경로를 제외하고 모든 요청에 middleware 실행:
* - _next/static (정적 파일)
* - _next/image (이미지 최적화)
* - favicon.ico, 이미지 파일들
*/
'/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)',
],
}
🔀 요청 흐름 이해하기
코드가 어떻게 동작하는지 흐름을 따라가볼게요.
유저가 /dashboard 접속
↓
middleware.ts 실행
↓
updateSession() → getUser()로 Supabase 서버에 토큰 검증
↓
user 있음?
├── YES → /dashboard 정상 진입 ✅
└── NO → /login으로 리다이렉트 🔒
로그인한 유저가 /login 접속
↓
middleware.ts 실행
↓
user 있음 + isAuthPage?
└── YES → /dashboard로 리다이렉트 🔓
🔥 내가 겪은 실수들
실수 1: middleware 없이 페이지에서만 막으려 했음
// ❌ 이렇게 각 페이지에서 개별 처리하면 안 돼요
export default async function Dashboard() {
const supabase = await createClient()
const { data } = await supabase.auth.getUser()
if (!data.user) redirect('/login')
// ...
}
// ❌ 보호할 페이지가 10개면 10개 다 이 코드를 써야 해요
// 누락되는 페이지가 생기고, 유지보수도 힘들어요
middleware를 쓰면 한 곳에서 모든 페이지를 관리할 수 있어요. 훨씬 효율적이고 빠뜨릴 위험도 없어요.
실수 2: matcher 설정을 안 해서 정적 파일도 middleware 거침
// ❌ matcher 없으면 이미지, CSS, 폰트 요청도 middleware 거쳐요
// → 불필요한 Supabase API 호출 발생, 성능 저하
// ✅ matcher로 필요한 경로만 지정하세요
export const config = {
matcher: [
'/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)',
],
}
실수 3: 리다이렉트 루프
// ❌ 이렇게 하면 /login 자체도 보호 대상이 돼서
// 비로그인 → /login 리다이렉트 → /login도 보호됨 → 또 리다이렉트 → 무한루프!
const isProtectedPage = !pathname.startsWith('/login') // 이건 잘못된 방식
// ✅ 보호할 경로를 명확하게 화이트리스트로 지정하세요
const isProtectedPage = pathname.startsWith('/dashboard') || pathname.startsWith('/my')
🛡️ 관리자 페이지 추가 보호하기
역할(role) 기반 접근 제어도 middleware에서 할 수 있어요. 예를 들어 /admin 페이지는 관리자만 들어갈 수 있게 하는 방법이에요.
// middleware.ts에 추가
export async function middleware(request: NextRequest) {
const { supabaseResponse, user } = await updateSession(request)
const { pathname } = request.nextUrl
// 비로그인 → 로그인 페이지로
if (!user && pathname.startsWith('/dashboard')) {
const url = request.nextUrl.clone()
url.pathname = '/login'
return NextResponse.redirect(url)
}
// 관리자 페이지 접근 제한
if (pathname.startsWith('/admin')) {
if (!user) {
// 비로그인 → 로그인으로
const url = request.nextUrl.clone()
url.pathname = '/login'
return NextResponse.redirect(url)
}
// role 확인 (user_metadata에 role 저장한 경우)
const userRole = user.user_metadata?.role
if (userRole !== 'admin') {
// 관리자가 아님 → 403 or 홈으로
const url = request.nextUrl.clone()
url.pathname = '/'
return NextResponse.redirect(url)
}
}
return supabaseResponse
}
✅ 최종 구조 정리
이전 글을 통해 정리한 Supabase + Next.js 인증의 최적 구조예요.
| 역할 | 어디서 | 함수/파일 |
|---|---|---|
| 세션 갱신 + 요청 필터링 | middleware.ts | updateSession() + getUser() |
| 서버에서 유저 정보 가져오기 | Server Component | getUser() |
| 클라이언트 UI 상태 표시 | Client Component | getSession() |
| 역할/권한 빠른 확인 | 어디서든 | getClaims() |
| 브라우저용 Supabase 클라이언트 | utils/supabase/client.ts | createBrowserClient() |
| 서버용 Supabase 클라이언트 | utils/supabase/server.ts | createServerClient() |
🎯 핵심 한 줄 요약
인증 필터링은 middleware, 서버 검증은 getUser(), UI 상태는 getSession() — 이 조합이 제일 깔끔해요!
📚 시리즈 전체 보기
- 1편: Supabase auth-helpers 말고 @supabase/ssr 써야 하는 이유
- 2편: Next.js + Supabase getSession vs getUser 완전 정리



