본문 바로가기

바이브코딩

12. 인증과 권한 체크 구현 - Next.js 미들웨어 페이지 보호

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)를 더 자세히 다룹니다.

💬 미들웨어 관련 질문 있으면 댓글로 남겨주세요!