Next.js middleware.ts로 Supabase 인증 처리하는 방법 (실전 코드 완전 정리)

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.tsupdateSession() + getUser()
서버에서 유저 정보 가져오기Server ComponentgetUser()
클라이언트 UI 상태 표시Client ComponentgetSession()
역할/권한 빠른 확인어디서든getClaims()
브라우저용 Supabase 클라이언트utils/supabase/client.tscreateBrowserClient()
서버용 Supabase 클라이언트utils/supabase/server.tscreateServerClient()

🎯 핵심 한 줄 요약
인증 필터링은 middleware, 서버 검증은 getUser(), UI 상태는 getSession() — 이 조합이 제일 깔끔해요!


📚 시리즈 전체 보기


댓글 달기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다

위로 스크롤