
Next.js 미들웨어 페이지 보호 - 12편. 인증과 권한 체크 구현
Next.js 미들웨어로 페이지 접근을 제어합니다.
이 글에서는 미들웨어 개념, 인증 체크, 관리자 권한 체크, 리다이렉트 처리를 다룹니다.
🚨 문제 발견
로그인 기능을 만들고 테스트하다가 문제를 발견했습니다.
주소창에 직접 /mypage를 입력하면 로그인 안 해도 들어갑니다.
물론 데이터는 안 보입니다. RLS가 막으니까요. 그런데 페이지 자체는 보입니다. 빈 화면이 뜹니다.
사용자 입장에서 이상합니다. "뭐지? 버그인가?"
페이지 자체를 못 들어오게 해야 했습니다.
Claude에게 물었습니다.
로그인 안 한 사람이 /mypage에 접근하면
로그인 페이지로 보내고 싶어.미들웨어를 쓰면 된다고 했습니다.
🔧 미들웨어란?
요청이 들어오면 페이지보다 먼저 실행되는 코드입니다.
사용자 요청 → [미들웨어] → 페이지 렌더링
↓
여기서 권한 체크!마치 클럽의 보안요원 같습니다. 입장 전에 신분증 확인하는 역할.
Next.js에서는 middleware.ts 파일 하나로 관리합니다.
📝 기본 구조
Claude에게 기본 구조를 물었습니다.
// middleware.ts
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
export function middleware(request: NextRequest) {
// 여기서 뭔가 처리
// 다음으로 진행
return NextResponse.next()
}
// 미들웨어가 실행될 경로 설정
export const config = {
matcher: [
'/((?!_next/static|_next/image|favicon.ico).*)',
],
}
프로젝트 루트에 middleware.ts를 만들면 됩니다.
🔐 인증 체크 미들웨어
전체 코드
프로젝트에서 사용한 전체 코드입니다.
// middleware.ts
import { createServerClient } from '@supabase/ssr'
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
import createIntlMiddleware from 'next-intl/middleware'
import { locales, defaultLocale } from './i18n/config'
// next-intl 미들웨어
const intlMiddleware = createIntlMiddleware({
locales,
defaultLocale,
localePrefix: 'as-needed'
})
// 보호된 경로
const protectedPaths = ['/mypage']
const adminPaths = ['/admin']
const authPaths = ['/login', '/signup']
export async function middleware(request: NextRequest) {
// 1. Supabase 클라이언트 생성
let response = NextResponse.next({
request: { headers: request.headers },
})
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, options }) => {
response.cookies.set(name, value, options)
})
},
},
}
)
// 2. 세션 가져오기
const { data: { session } } = await supabase.auth.getSession()
const pathname = request.nextUrl.pathname
// 3. 언어 접두사 제거 (ko, ja)
const pathWithoutLocale = pathname.replace(/^\/(ko|ja)/, '') || '/'
// 4. 보호된 페이지 체크
const isProtectedPath = protectedPaths.some(path =>
pathWithoutLocale.startsWith(path)
)
const isAdminPath = adminPaths.some(path =>
pathWithoutLocale.startsWith(path)
)
const isAuthPath = authPaths.some(path =>
pathWithoutLocale.startsWith(path)
)
// 5. 로그인 안 한 사용자가 보호된 페이지 접근
if ((isProtectedPath || isAdminPath) && !session) {
const loginUrl = new URL('/login', request.url)
loginUrl.searchParams.set('redirect', pathname)
return NextResponse.redirect(loginUrl)
}
// 6. 관리자 페이지 권한 체크
if (isAdminPath && session) {
// users 테이블에서 role 확인
const { data: user } = await supabase
.from('users')
.select('role')
.eq('id', session.user.id)
.single()
if (user?.role !== 'admin') {
return NextResponse.redirect(new URL('/', request.url))
}
}
// 7. 로그인한 사용자가 로그인 페이지 접근
if (isAuthPath && session) {
return NextResponse.redirect(new URL('/mypage', request.url))
}
// 8. next-intl 미들웨어 실행
return intlMiddleware(request)
}
export const config = {
matcher: ['/((?!api|_next|.*\\..*).*)'],
}
코드가 길어 보이지만, Claude가 한 번에 만들어줬습니다.
🎯 각 부분 설명
보호된 경로 정의
const protectedPaths = ['/mypage'] // 로그인 필요
const adminPaths = ['/admin'] // 관리자만
const authPaths = ['/login', '/signup'] // 로그인 안 한 사람만
세 종류로 나눴습니다.
언어 접두사 처리
// /ko/mypage → /mypage로 변환
const pathWithoutLocale = pathname.replace(/^\/(ko|ja)/, '') || '/'
다국어 URL도 제대로 체크하기 위해 필요합니다. /ja/mypage도 보호해야 하니까요.
리다이렉트 파라미터
loginUrl.searchParams.set('redirect', pathname)
// /login?redirect=/mypage/orders
로그인 후 원래 가려던 페이지로 돌아가기 위해 추가했습니다. 좋은 UX입니다.
🔄 로그인 후 리다이렉트
로그인 페이지에서 redirect 파라미터를 읽어야 합니다.
// app/[locale]/(auth)/login/page.tsx
import { useSearchParams } from 'next/navigation'
export default function LoginPage() {
const searchParams = useSearchParams()
const redirectTo = searchParams.get('redirect') || '/mypage'
const onSubmit = async (data: any) => {
const result = await login(formData)
if (result.success) {
router.push(redirectTo) // 원래 가려던 곳으로!
}
}
}
마이페이지에서 주문 내역을 보려다가 로그인하러 갔다면, 로그인 후 주문 내역으로 바로 갑니다.
👨💼 관리자 권한 체크
데이터베이스 설정
users 테이블에 role 컬럼이 필요합니다.
-- users 테이블에 role 컬럼
alter table users add column role text default 'customer';
-- 관리자로 설정
update users set role = 'admin' where email = 'admin@example.com';
미들웨어에서 체크
if (isAdminPath && session) {
const { data: user } = await supabase
.from('users')
.select('role')
.eq('id', session.user.id)
.single()
if (user?.role !== 'admin') {
// 관리자가 아니면 홈으로
return NextResponse.redirect(new URL('/', request.url))
}
}
관리자가 아닌 사람이 /admin에 접근하면 홈으로 보냅니다.
📱 컴포넌트에서 권한 체크
미들웨어는 페이지 단위입니다.
컴포넌트 안에서 버튼을 숨기거나 할 때는 훅을 만들었습니다.
// hooks/useAuth.ts
'use client'
import { createClient } from '@/lib/supabase/client'
import { useEffect, useState } from 'react'
export function useAuth() {
const [user, setUser] = useState(null)
const [isAdmin, setIsAdmin] = useState(false)
const [isLoading, setIsLoading] = useState(true)
useEffect(() => {
const supabase = createClient()
async function getUser() {
const { data: { user } } = await supabase.auth.getUser()
if (user) {
setUser(user)
// role 체크
const { data } = await supabase
.from('users')
.select('role')
.eq('id', user.id)
.single()
setIsAdmin(data?.role === 'admin')
}
setIsLoading(false)
}
getUser()
}, [])
return { user, isAdmin, isLoading }
}
사용 예시
function AdminButton() {
const { isAdmin, isLoading } = useAuth()
if (isLoading) return null
if (!isAdmin) return null
return <button>관리자 기능</button>
}
관리자만 버튼이 보입니다.
⚠️ 삽질했던 것들
무한 리다이렉트
로그인 페이지로 리다이렉트하는데, 그 페이지도 미들웨어가 체크해서 또 리다이렉트.
무한 루프였습니다.
matcher 설정에서 /login을 제외해야 했습니다.
// 리다이렉트 URL은 제외
const isAuthPath = authPaths.some(path =>
pathWithoutLocale === path ||
pathWithoutLocale.startsWith(path + '/')
)
API 라우트까지 체크
/api/ 경로도 미들웨어가 체크해서 API가 막혔습니다.
export const config = {
matcher: ['/((?!api|_next|.*\\..*).*)'],
// api, _next, 정적 파일 제외
}
matcher에서 api를 제외했습니다.
세션이 있는데 없다고 함
분명 로그인했는데 미들웨어에서 세션이 없다고 했습니다.
쿠키 설정이 문제였습니다.
setAll(cookiesToSet) {
cookiesToSet.forEach(({ name, value, options }) => {
response.cookies.set(name, value, options) // Next.js 15: response만 사용
})
}
response.cookies.set 줄이 빠져있었습니다.
💡 미들웨어 디버깅
문제가 생기면 로그를 찍었습니다.
export async function middleware(request: NextRequest) {
console.log('Path:', request.nextUrl.pathname)
console.log('Session:', session ? 'Yes' : 'No')
// ...
}
Vercel에서는 Functions 로그에서 확인할 수 있습니다.
// 개발 환경에서만 로그
if (process.env.NODE_ENV === 'development') {
console.log('Debug:', pathname)
}
배포 후에는 불필요한 로그를 끕니다.
📋 이번 편 요약
| 항목 | 내용 |
|---|---|
| 다룬 기능 | 미들웨어 페이지 보호, 권한 체크 |
| 핵심 파일 | middleware.ts |
| 보호 유형 | 로그인 필수, 관리자 전용, 게스트 전용 |
| 주의사항 | matcher 설정, 쿠키 동기화, 다국어 URL 처리 |
다음 편에서는 역할 기반 접근 제어(RBAC)를 더 자세히 다룹니다.
💬 미들웨어 관련 질문 있으면 댓글로 남겨주세요!
'바이브코딩' 카테고리의 다른 글
| 14. 스팸 방지 구현 - Cloudflare Turnstile CAPTCHA (0) | 2026.01.21 |
|---|---|
| 13. 고객과 관리자 권한 분리 - Supabase RBAC 역할 관리 (0) | 2026.01.20 |
| 11. 회원가입과 비밀번호 찾기 - Supabase 이메일 인증 구현 (0) | 2026.01.18 |
| 10. 회원가입부터 로그아웃까지 - Supabase Auth 로그인 구현 (0) | 2026.01.17 |
| 16. 제품 선택부터 이메일 발송까지 - React Hook Form 문의 폼 구현 (0) | 2026.01.17 |