테마 전환

Next.js 라우트 보호와 권한 제어: Middleware와 다층 방어 완벽 가이드

Easton editorial illustration: cache waterfall instrument

지난달 고객사의 보안 검토를 진행할 때였습니다. 제가 작성한 권한 제어 코드를 보던 검토자가 미간을 찌푸리며 물었습니다. “이게 전부인가요? Middleware에서 한 번 막은 것뿐인가요?”

당시에는 꽤 억울했습니다. Middleware가 원래 이런 일을 하라고 있는 것 아닌가요? 로그인하지 않은 사용자는 로그인 페이지로 바로 리디렉션하고, 로그인한 사용자는 통과시켰습니다. 겉보기에는 빈틈이 없어 보였습니다.

검토자는 브라우저 개발자 도구를 열고 바로 시연해 보였습니다. 프론트엔드 라우트를 우회해 Postman으로 백엔드 API를 직접 호출하자, 원래 보호되어야 할 데이터를 손쉽게 가져올 수 있었습니다.

그 순간 머리가 멍해졌습니다.

이후 여러 자료를 살펴보고 나서야 이해했습니다. Next.js 권한 제어는 결코 Middleware 하나만으로 해결할 수 없습니다. 완전한 다층 방어 아키텍처가 필요합니다.

이 글에서는 Middleware를 어떻게 사용해야 하는지, getServerSession과는 어떤 관계인지, 관리자 페이지의 RBAC 권한 체계를 어떻게 설계해야 하는지 살펴봅니다. 바로 가져다 쓸 수 있는 코드 템플릿도 함께 소개합니다.

Next.js에서 권한 제어를 구현하고 있거나 Middleware와 getServerSession을 어떻게 함께 써야 할지 헷갈린다면 이 글이 도움이 될 것입니다.

권한 제어를 Middleware에만 의존하면 안 되는 이유

Middleware는 정확히 무엇을 하나요?

먼저 Middleware의 역할부터 명확히 하겠습니다.

Middleware는 Edge Runtime에서 실행되며 사용자 요청이 들어온 뒤 가장 먼저 만나는 계층입니다. 여기에서는 사용자가 로그인했는지, 관리자인지 일반 사용자인지, 요청을 통과시킬지 리디렉션할지 같은 1차 선별 작업을 할 수 있습니다.

빠르고 이른 시점에 실행됩니다. 권한 제어에 적합해 보이지 않나요?

실제로 적합합니다. 다만 이것만으로는 충분하지 않습니다.

실제 취약점 사례

올해 1월 보안 연구자들은 CVE-2025-29927 취약점을 공개했습니다. 간단히 말하면 공격자가 요청 헤더에 특수한 x-middleware-subrequest 필드를 추가해 Middleware 검사를 직접 우회할 수 있는 취약점입니다.

이런 공격을 받으면 Middleware에 작성한 모든 로직이 무용지물이 됩니다.

예외적인 사례도 아닙니다. Middleware는 가장 바깥쪽 방어선이므로 그 자체가 우회되거나 잘못 설정될 수 있고, 엣지 환경의 제약 때문에 복잡한 권한 판단을 수행하지 못할 수도 있습니다.

Next.js 공식 문서도 이 점을 특히 강조합니다. “While Middleware can be useful for initial checks, it should not be your only line of defense.”

즉, 모든 보안을 Middleware 하나에 걸지 말라는 뜻입니다.

프론트엔드는 막았지만 백엔드는요?

예전에 관리자 페이지 프로젝트를 만든 적이 있습니다. Middleware에 로그인 검사를 작성해 로그인하지 않은 사용자가 /admin 라우트에 접근하면 로그인 페이지로 리디렉션되도록 했습니다.

안전해 보였습니다. 사용자가 메뉴를 클릭해 라우트를 이동할 때마다 Middleware 검사를 거쳤습니다.

문제는 어디에 있었을까요? API가 보호되지 않았습니다.

호기심 많은 테스트 담당자가 Network 패널을 살펴보다가 사용자 삭제 인터페이스가 POST /api/users/delete라는 것을 발견했습니다. 그는 curl로 이 인터페이스를 직접 호출하고 임의의 매개변수를 넣었습니다.

사용자가 실제로 삭제됐습니다.

API Route에 권한 검사가 전혀 없었기 때문입니다. 프론트엔드 라우트만 Middleware로 막았고 누군가 인터페이스를 직접 호출할 수 있다는 점을 완전히 놓쳤습니다.

이것이 Middleware에만 의존할 때 생기는 문제입니다. 프론트엔드의 문은 지킬 수 있지만 백엔드의 창문까지 지킬 수는 없습니다.

Next.js가 공식적으로 권장하는 방법

공식 문서에는 “Proximity Principle”이라는 원칙이 나옵니다. 권한 검사를 데이터와 가장 가까운 곳에 두라는 뜻입니다.

무슨 의미일까요?

데이터는 데이터베이스에 있습니다. 데이터를 보호하려면 데이터베이스에 접근하기 전에 권한을 검사해야 합니다. 라우트 계층이나 페이지 계층이 아니라 데이터 계층에서 검사해야 합니다.

물론 Middleware가 쓸모없다는 뜻은 아닙니다. Middleware는 첫 번째 차단 계층으로 사용할 수 있습니다. 로그인하지 않은 사용자를 리디렉션하고 권한이 명백히 없는 역할을 즉시 거부할 수 있습니다. 하지만 이 계층만으로는 부족하며 다음 계층도 필요합니다.

  • Server Component의 검사: 페이지를 렌더링하기 전에 사용자가 해당 페이지에 접근할 권한이 있는지 검증합니다.
  • API Route와 Server Action의 검사: 각 데이터 작업 전에 권한을 다시 검증합니다.
  • 데이터베이스 쿼리 계층의 검사: Row-Level Security 또는 쿼리 필터링으로 사용자가 권한이 있는 데이터에만 접근하게 합니다.

이것이 다층 방어입니다. 한 계층이 우회되어도 다음 계층이 남아 있습니다.

솔직히 처음에는 저도 너무 번거롭다고 생각했습니다. 하지만 직접 문제를 겪고 나니 알게 됐습니다. 보안에서 번거롭게 느껴지는 지점이 바로 공격자가 관심을 갖는 지점입니다.

Middleware와 getServerSession을 올바르게 함께 사용하는 방법

Middleware에서 getServerSession을 사용할 수 없는 이유

NextAuth를 처음 사용할 때 저도 이 문제로 곤란을 겪었습니다.

문서를 보면 Server Component에서 session을 가져올 때 getServerSession(authOptions)를 사용합니다. 그래서 자연스럽게 Middleware에서도 같은 방식을 써 보았습니다.

결과는 오류였습니다.

한참 조사하고 나서야 이유를 알았습니다. Middleware는 Edge Runtime에서 실행되지만 getServerSession에는 Node.js Runtime이 필요합니다. 두 환경은 호환되지 않습니다.

Edge Runtime은 Vercel이 만든 경량 실행 환경입니다. Node.js의 전체 API를 제공하지 않는 대신 빠르고 전 세계에 분산되어 있습니다. Middleware는 성능을 위해 Edge Runtime을 선택했지만, 그 대가로 모든 Node.js 기능을 사용할 수는 없습니다.

그렇다면 Middleware에서는 어떻게 session을 가져와야 할까요?

올바른 방법: getToken 또는 withAuth 사용

NextAuth는 Middleware 전용 API 두 가지를 제공합니다.

방법 1: getToken 사용

// middleware.ts
import { getToken } from "next-auth/jwt"
import { NextResponse } from "next/server"

export async function middleware(req) {
  const token = await getToken({ req, secret: process.env.NEXTAUTH_SECRET })

  if (!token) {
    return NextResponse.redirect(new URL('/login', req.url))
  }

  // 역할 확인
  if (req.nextUrl.pathname.startsWith('/admin') && token.role !== 'admin') {
    return NextResponse.redirect(new URL('/403', req.url))
  }

  return NextResponse.next()
}

export const config = {
  matcher: ['/admin/:path*', '/dashboard/:path*']
}

getToken은 요청 cookie에서 JWT token을 파싱해 사용자 정보를 가져옵니다. JWT session 전략만 지원하므로 database session을 사용한다면 이 방법은 쓸 수 없습니다.

방법 2: withAuth 고차 함수 사용

// middleware.ts
import { withAuth } from "next-auth/middleware"

export default withAuth({
  callbacks: {
    authorized: ({ token, req }) => {
      // 비로그인 상태
      if (!token) return false

      // admin 라우트에는 admin 역할만 접근 가능
      if (req.nextUrl.pathname.startsWith('/admin')) {
        return token.role === 'admin'
      }

      return true
    }
  }
})

export const config = {
  matcher: ['/admin/:path*', '/dashboard/:path*']
}

withAuth는 리디렉션 로직을 대신 처리하는 래퍼 함수입니다. authorized 콜백에서 true 또는 false만 반환하면 자동으로 로그인 페이지로 리디렉션합니다.

개인적으로는 코드가 더 간결한 withAuth를 선호합니다.

그렇다면 getServerSession은 어디에서 사용하나요?

getServerSession은 Server Component, API Route, Server Action에서 사용합니다.

Server Component에서:

// app/admin/page.tsx
import { getServerSession } from "next-auth/next"
import { authOptions } from "@/lib/auth"
import { redirect } from "next/navigation"

export default async function AdminPage() {
  const session = await getServerSession(authOptions)

  if (!session) {
    redirect('/login')
  }

  if (session.user.role !== 'admin') {
    redirect('/403')
  }

  // 페이지 렌더링
  return <UsersList />
}

이 계층은 사용자에게 해당 페이지에 접근할 권한이 있는지 검사합니다. 권한이 없으면 페이지를 보여 주지 않습니다.

API Route에서:

// app/api/users/route.ts
import { getServerSession } from "next-auth/next"
import { authOptions } from "@/lib/auth"
import { NextResponse } from "next/server"

export async function DELETE(req: Request) {
  const session = await getServerSession(authOptions)

  if (!session || session.user.role !== 'admin') {
    return NextResponse.json({ error: 'Unauthorized' }, { status: 403 })
  }

  // 삭제 작업 실행
  // ...

  return NextResponse.json({ success: true })
}

Server Action에서:

// app/actions.ts
'use server'

import { getServerSession } from "next-auth/next"
import { authOptions } from "@/lib/auth"

export async function deleteUser(userId: string) {
  const session = await getServerSession(authOptions)

  if (!session || session.user.role !== 'admin') {
    throw new Error('Unauthorized')
  }

  // 삭제 실행
  // ...
}

두 기능의 역할 분담

간단히 정리하면 다음과 같습니다.

  • Middleware(getToken 사용): 비로그인 사용자 일괄 리디렉션이나 기본 역할 검사처럼 거친 단위로 라우트를 차단합니다.
  • getServerSession: 실제로 데이터를 처리하기 전에 최종 검증을 수행하는 세분화된 권한 제어에 사용합니다.

비유하자면 Middleware는 주거 단지 입구의 경비원입니다. 들어와서는 안 될 것이 명백한 사람을 막습니다. getServerSession은 집 현관의 자물쇠입니다. 경비원이 통과시켰더라도 열쇠가 없으면 들어올 수 없습니다.

이것이 다층 방어입니다.

관리자 페이지를 위한 완전한 RBAC 설계

지금까지 Middleware와 getServerSession을 함께 쓰는 방법을 살펴봤지만 아직 요소들이 조금 흩어져 있습니다. 실제 관리자 페이지를 만들려면 완전한 RBAC(Role-Based Access Control, 역할 기반 접근 제어) 체계가 필요합니다.

먼저 RBAC 모델 이해하기

RBAC의 핵심 개념은 사용자 → 역할 → 권한입니다.

  • 사용자(User): 홍길동, 김철수
  • 역할(Role): 관리자, 편집자, 조회자
  • 권한(Permission): 사용자 목록 보기, 게시글 편집, 댓글 삭제

사용자는 여러 역할을 가질 수 있고 한 역할에는 여러 권한이 포함될 수 있습니다. 예를 들어 홍길동이 관리자라면 모든 권한을 갖고, 김철수가 편집자라면 게시글 편집과 사용자 목록 조회만 할 수 있습니다.

권한의 범위는 세 가지로 나눌 수 있습니다.

  • 페이지 수준: 특정 페이지(예: /admin/users)에 접근할 수 있는가?
  • 기능 수준: 특정 버튼(예: ‘삭제’ 버튼)을 누를 수 있는가?
  • 데이터 수준: 특정 데이터(예: 자신이 작성한 게시글만)에 접근할 수 있는가?

데이터베이스 설계는 대략 다음과 같습니다(Prisma Schema).

model User {
  id    String @id @default(cuid())
  email String @unique
  roles Role[]
}

model Role {
  id          String       @id @default(cuid())
  name        String       @unique
  permissions Permission[]
  users       User[]
}

model Permission {
  id    String @id @default(cuid())
  name  String @unique // 예: "user:view", "user:edit"
  roles Role[]
}

실제 프로젝트에서는 이렇게 복잡한 데이터베이스 설계가 필요하지 않을 수도 있습니다. 역할이 관리자, 편집자, 조회자 정도로 많지 않다면 코드에 직접 정의해도 됩니다.

4단계 방어 아키텍처

이제 각 계층에서 권한 검사를 어떻게 적용하는지 살펴보겠습니다.

1단계: Middleware - 거친 단위의 차단

// middleware.ts
import { withAuth } from "next-auth/middleware"

export default withAuth({
  callbacks: {
    authorized: ({ token, req }) => {
      if (!token) return false

      const path = req.nextUrl.pathname

      // admin 경로에는 admin 역할만 접근 가능
      if (path.startsWith('/admin')) {
        return token.role === 'admin'
      }

      // dashboard 경로는 로그인만 하면 접근 가능
      if (path.startsWith('/dashboard')) {
        return true
      }

      return false
    }
  }
})

export const config = {
  matcher: ['/admin/:path*', '/dashboard/:path*']
}

이 계층에서는 구체적인 기능 권한을 다루지 않고 기본 역할만 판단합니다.

2단계: Server Component - 페이지 수준 권한 검사

// app/admin/users/page.tsx
import { getServerSession } from "next-auth/next"
import { authOptions } from "@/lib/auth"
import { checkPermission } from "@/lib/permissions"
import { redirect } from "next/navigation"

export default async function UsersPage() {
  const session = await getServerSession(authOptions)

  if (!session) {
    redirect('/login')
  }

  // 사용자 목록을 볼 권한이 있는지 검사
  const hasPermission = await checkPermission(session.user.id, 'user:view')

  if (!hasPermission) {
    redirect('/403')
  }

  // 페이지 렌더링
  return <UsersList />
}

이 계층은 사용자에게 해당 페이지에 접근할 권한이 있는지 검사합니다. 권한이 없으면 페이지를 보여 주지 않습니다.

3단계: UI 조건부 렌더링 - 기능 수준 권한

// components/UsersList.tsx
'use client'

import { useSession } from "next-auth/react"
import { hasPermission } from "@/lib/permissions-client"

export function UsersList() {
  const { data: session } = useSession()

  const canEdit = hasPermission(session, 'user:edit')
  const canDelete = hasPermission(session, 'user:delete')

  return (
    <div>
      {users.map(user => (
        <div key={user.id}>
          <span>{user.name}</span>
          {canEdit && <button>편집</button>}
          {canDelete && <button>삭제</button>}
        </div>
      ))}
    </div>
  )
}

이 계층에서는 권한에 따라 버튼을 숨기거나 표시합니다. 사용자가 버튼을 볼 수 없으면 자연스럽게 클릭할 수도 없습니다.

하지만 이는 UX 개선일 뿐 보안 조치는 아닙니다. 기술을 조금 아는 사람이라면 브라우저 콘솔에서 버튼을 표시할 수 있습니다. 실제 보안 검사는 다음 계층에서 수행합니다.

4단계: Server Action / API - 데이터 작업 전 검증

// app/actions.ts
'use server'

import { getServerSession } from "next-auth/next"
import { authOptions } from "@/lib/auth"
import { checkPermission } from "@/lib/permissions"

export async function deleteUser(userId: string) {
  const session = await getServerSession(authOptions)

  if (!session) {
    throw new Error('Unauthorized')
  }

  // 삭제 권한 필수
  const hasPermission = await checkPermission(session.user.id, 'user:delete')

  if (!hasPermission) {
    throw new Error('Forbidden')
  }

  // 삭제 실행
  await db.user.delete({ where: { id: userId } })

  return { success: true }
}

이것이 마지막 방어선입니다. 앞 계층에서 검사했는지 여부와 관계없이 데이터를 처리하는 이 단계에서는 반드시 권한을 다시 검증해야 합니다.

권한 설정을 한곳에서 관리하기

모든 곳에서 권한 검사를 다시 작성하는 것은 번거롭습니다. 저는 권한 정의와 검사 로직을 한 파일에서 관리합니다.

// lib/permissions.ts
export const PERMISSIONS = {
  USER_VIEW: 'user:view',
  USER_EDIT: 'user:edit',
  USER_DELETE: 'user:delete',
  POST_VIEW: 'post:view',
  POST_EDIT: 'post:edit',
  POST_DELETE: 'post:delete',
} as const

export const ROLES = {
  ADMIN: {
    name: 'admin',
    permissions: Object.values(PERMISSIONS) // 관리자는 모든 권한 보유
  },
  EDITOR: {
    name: 'editor',
    permissions: [
      PERMISSIONS.USER_VIEW,
      PERMISSIONS.POST_VIEW,
      PERMISSIONS.POST_EDIT,
    ]
  },
  VIEWER: {
    name: 'viewer',
    permissions: [
      PERMISSIONS.USER_VIEW,
      PERMISSIONS.POST_VIEW,
    ]
  }
} as const

// 서버 측 권한 검사
export async function checkPermission(userId: string, permission: string) {
  const user = await db.user.findUnique({
    where: { id: userId },
    include: { roles: true }
  })

  if (!user) return false

  // 사용자 역할에 필요한 권한이 포함되는지 검사
  const userRole = ROLES[user.role as keyof typeof ROLES]
  return userRole?.permissions.includes(permission) ?? false
}
// lib/permissions-client.ts (클라이언트 버전)
import { Session } from "next-auth"

export function hasPermission(session: Session | null, permission: string) {
  if (!session?.user) return false

  const userRole = ROLES[session.user.role as keyof typeof ROLES]
  return userRole?.permissions.includes(permission) ?? false
}

이제 권한을 추가하거나 삭제하고 수정할 때 한 파일만 고치면 됩니다. 코드를 찾아다닐 필요가 없습니다.

이 아키텍처의 장점

여러 계층에 코드를 더 작성할 가치가 있을까요?

그렇습니다.

  • 보안성: 한 계층이 우회되어도 다른 계층이 보호합니다.
  • 유지보수성: 권한 설정을 한곳에서 관리하므로 수정하기 편합니다.
  • 사용자 경험: 사용할 수 없는 버튼을 미리 숨기므로 사용자가 클릭한 뒤에야 권한이 없다는 사실을 알게 되지 않습니다.
  • 감사 대응성: 각 계층에 명확한 권한 검사가 있어 보안 검토를 통과하기 쉽습니다.

솔직히 처음 이 아키텍처를 구성할 때는 시간이 꽤 듭니다. 하지만 새 기능을 추가하거나 권한 규칙을 바꿀 때는 훨씬 많은 시간을 아낄 수 있습니다.

실전 사례와 코드 템플릿

앞에서 원리와 아키텍처를 살펴봤으니 이제 바로 사용할 수 있는 코드 템플릿과 자주 발생하는 문제의 해결 방법을 소개하겠습니다.

완전한 Middleware 설정 템플릿

다음은 여러 역할과 동적 라우트 매칭을 지원하는 완전한 Middleware 설정입니다.

// middleware.ts
import { withAuth } from "next-auth/middleware"
import { NextResponse } from "next/server"

export default withAuth(
  function middleware(req) {
    const token = req.nextauth.token
    const path = req.nextUrl.pathname

    // 경로와 역할에 따라 세분화하여 제어
    if (path.startsWith('/admin') && token?.role !== 'admin') {
      return NextResponse.redirect(new URL('/403', req.url))
    }

    if (path.startsWith('/editor') && !['admin', 'editor'].includes(token?.role as string)) {
      return NextResponse.redirect(new URL('/403', req.url))
    }

    return NextResponse.next()
  },
  {
    callbacks: {
      authorized: ({ token }) => !!token
    }
  }
)

export const config = {
  matcher: [
    '/admin/:path*',
    '/editor/:path*',
    '/dashboard/:path*',
    '/api/admin/:path*',
    '/api/editor/:path*'
  ]
}

핵심 포인트:

  1. matcher는 보호할 경로를 정의하며 와일드카드 :path*를 지원합니다.
  2. authorized 콜백은 가장 기본적인 로그인 검사를 수행합니다.
  3. middleware 함수에서는 역할을 더 세밀하게 판단합니다.

동적 메뉴 렌더링

사용자 권한에 따라 메뉴를 동적으로 생성하는 기능은 관리자 페이지에서 흔히 필요합니다. 다음은 간단하고 실용적인 구현입니다.

// components/Sidebar.tsx
'use client'

import { useSession } from "next-auth/react"
import Link from "next/link"
import { hasPermission } from "@/lib/permissions-client"

const menuItems = [
  {
    label: '사용자 관리',
    href: '/admin/users',
    permission: 'user:view'
  },
  {
    label: '게시글 관리',
    href: '/admin/posts',
    permission: 'post:view'
  },
  {
    label: '시스템 설정',
    href: '/admin/settings',
    permission: 'setting:manage'
  }
]

export function Sidebar() {
  const { data: session } = useSession()

  // 권한에 따라 메뉴 항목 필터링
  const visibleItems = menuItems.filter(item =>
    hasPermission(session, item.permission)
  )

  return (
    <nav>
      {visibleItems.map(item => (
        <Link key={item.href} href={item.href}>
          {item.label}
        </Link>
      ))}
    </nav>
  )
}

메뉴 설정을 권한과 연결해 두므로 한눈에 이해할 수 있습니다. 새 메뉴 항목을 추가할 때는 menuItems 배열에 추가하기만 하면 됩니다.

재사용 가능한 권한 검사 Hook

클라이언트 컴포넌트에서 더 편리하게 사용할 수 있도록 React Hook으로 감싸 보겠습니다.

// hooks/usePermission.ts
import { useSession } from "next-auth/react"
import { hasPermission } from "@/lib/permissions-client"

export function usePermission(permission: string) {
  const { data: session, status } = useSession()

  const isLoading = status === 'loading'
  const isAllowed = hasPermission(session, permission)

  return { isAllowed, isLoading }
}

사용법은 매우 간단합니다.

// components/DeleteButton.tsx
'use client'

import { usePermission } from "@/hooks/usePermission"

export function DeleteButton({ userId }: { userId: string }) {
  const { isAllowed, isLoading } = usePermission('user:delete')

  if (isLoading) return <div>Loading...</div>
  if (!isAllowed) return null

  return (
    <button onClick={() => deleteUser(userId)}>
      삭제
    </button>
  )
}

자주 발생하는 문제 해결

문제 1: Middleware 무한 리디렉션

증상: 페이지에 접근하면 브라우저에 “리디렉션 횟수가 너무 많습니다”라는 오류가 표시됩니다.

원인: 로그인 페이지까지 Middleware가 차단해 리디렉션이 반복됩니다.

해결: matcher에서 로그인 페이지와 공개 페이지를 제외합니다.

export const config = {
  matcher: [
    /*
     * 다음 경로를 제외한 모든 경로와 매칭:
     * - /login (로그인 페이지)
     * - /api/auth (NextAuth API)
     * - /_next (Next.js 내부)
     * - /favicon.ico, /robots.txt (정적 파일)
     */
    '/((?!login|api/auth|_next|favicon.ico|robots.txt).*)',
  ]
}

문제 2: getServerSession이 null 반환

증상: 분명 로그인했는데도 getServerSession이 계속 null을 반환합니다.

원인:

  1. authOptions 설정이 잘못되었거나 전달하지 않았습니다.
  2. Cookie 설정에 문제가 있습니다(예: 교차 도메인, HTTPS).
  3. Middleware에서 getServerSession을 잘못 사용했습니다.

해결:

  • authOptions를 올바르게 import했는지 확인합니다.
  • Server Component 또는 API Route에서 사용하고 Middleware에서는 사용하지 않습니다.
  • 개발 환경에서 NEXTAUTH_URL 환경 변수가 올바른지 확인합니다.
// 올바른 사용법
import { getServerSession } from "next-auth/next"
import { authOptions } from "@/app/api/auth/[...nextauth]/route"

const session = await getServerSession(authOptions)

문제 3: 권한 검사 성능 문제

증상: 권한 검증을 위해 매번 데이터베이스를 조회하므로 페이지 로딩이 느립니다.

원인: 권한 검사를 캐시하지 않아 요청마다 데이터베이스를 조회합니다.

해결:

  1. 사용자 역할과 권한을 JWT token에 넣어 데이터베이스 조회를 줄입니다.
// app/api/auth/[...nextauth]/route.ts
export const authOptions: NextAuthOptions = {
  callbacks: {
    async jwt({ token, user }) {
      if (user) {
        token.role = user.role
        token.permissions = user.permissions
      }
      return token
    },
    async session({ session, token }) {
      session.user.role = token.role
      session.user.permissions = token.permissions
      return session
    }
  }
}
  1. React Query 또는 SWR을 사용해 클라이언트에서 권한 조회 결과를 캐시합니다.
// hooks/usePermissions.ts
import useSWR from 'swr'

export function usePermissions() {
  const { data: permissions } = useSWR('/api/me/permissions', {
    revalidateOnFocus: false,
    dedupingInterval: 60000 // 1분 안에는 중복 요청하지 않음
  })

  return permissions
}

빠른 점검 목록

출시 전에 다음 목록으로 권한 제어를 확인해 보세요.

  • Middleware가 거친 단위의 검사만 수행하나요?
  • 모든 Server Component 페이지에 권한 검증이 있나요?
  • 모든 API Route에 권한 검증이 있나요?
  • 모든 Server Action에 권한 검증이 있나요?
  • 민감한 버튼을 권한에 따라 숨기나요?
  • 권한 설정을 한곳에서 관리하나요?
  • JWT token에 역할 정보가 포함되어 있나요?
  • 로그인 페이지와 공개 페이지를 Middleware 대상에서 제외했나요?

모두 체크했다면 권한 제어의 기본은 제대로 갖춘 것입니다.

결론

처음의 질문으로 돌아가 보겠습니다. Next.js 권한 제어는 어떻게 해야 할까요?

답은 단 하나의 방법에 의존하지 않는 것입니다.

Middleware는 중요합니다. 첫 번째 방어선으로서 승인되지 않은 접근 대부분을 막을 수 있습니다. 하지만 그것이 전부는 아닙니다. Server Component에서 페이지 권한을 검사하고, Server Action과 API Route에서 작업 권한을 검증하며, UI 계층에서 사용자 경험도 개선해야 합니다.

이 다층 방어 아키텍처는 얼핏 보면 번거로워 보입니다. 하지만 보안에는 만능 해결책이 없습니다. 한 계층이 우회되어도 다른 계층이 보호하는 구조가 믿을 수 있는 방식입니다.

Middleware만 사용 vs 다층 방어

비교 항목Middleware만 사용다층 방어
보안성낮음, 우회 가능높음, 여러 계층이 보호
개발 비용낮음, 한곳에서 설정보통, 여러 곳에 검사 추가 필요
유지보수성나쁨, 권한 로직이 분산됨좋음, 설정을 한곳에서 관리
사용자 경험보통, 클릭한 뒤에야 권한 없음을 알게 됨좋음, 사용할 수 없는 기능을 미리 숨김
감사 대응성나쁨, 검사 지점이 하나뿐좋음, 각 계층에 기록이 있음

다층 방어가 조금 더 번거롭더라도 이점은 분명합니다.

지금 바로 확인할 것

Next.js 프로젝트를 진행하고 있다면 지금 다음 항목을 확인해 보세요.

  1. 프로젝트에서 Middleware에만 권한 제어를 구현했나요?
  2. API Route와 Server Action에 독립적인 권한 검증이 있나요?
  3. 권한 설정이 여러 파일에 흩어져 있나요?

어느 항목이든 문제가 있다면 가능한 한 빨리 다층 방어 아키텍처로 리팩터링하기를 권합니다. 한 번에 모두 바꿀 필요는 없습니다. 가장 중요한 데이터 작업 계층에 권한 검사를 먼저 추가한 뒤 다른 계층을 차례로 보완하면 됩니다.

보안 문제는 늦게 고치는 것보다 일찍 고치는 편이 낫습니다.

다른 질문이 있나요?

이 글에서는 Next.js 권한 제어의 핵심 아키텍처와 실전 방안을 다뤘지만 모든 상황을 설명할 수는 없습니다. 실제 프로젝트에서 다음과 같은 다른 문제를 만났다면 댓글로 남겨 주세요.

  • 데이터 수준 권한을 구현하는 방법(자신이 만든 데이터만 볼 수 있게 하기)
  • Prisma의 Row-Level Security와 결합하는 방법
  • 멀티테넌트 시스템에서 권한을 격리하는 방법

함께 이야기해 보겠습니다.

권한 제어는 언제나 중요한 주제입니다. 이 글이 시행착오를 줄이는 데 도움이 되기를 바랍니다.

Next.js 다층 권한 방어 전체 설정 절차

Middleware 기본 검사부터 getServerSession 상세 검증과 데이터베이스 권한 검사까지의 전체 단계

⏱️ Estimated time: 3 hr

  1. 1

    Step 1: 1단계: Middleware 기본 검사

    middleware.ts를 생성합니다.
    ```ts
    import { withAuth } from 'next-auth/middleware'

    export default withAuth({
    pages: {
    signIn: '/login'
    }
    })

    export const config = {
    matcher: ['/dashboard/:path*', '/admin/:path*']
    }
    ```

    역할:
    • 사용자 로그인 여부 확인
    • 비로그인 사용자를 로그인 페이지로 리디렉션
    • 빠르고 이른 시점에 실행

    한계:
    • x-middleware-subrequest 취약점으로 우회 가능
    • 1차 선별만 가능
    • 상세 권한 검사를 수행할 수 없음

    핵심: Middleware는 첫 번째 방어선이지만 유일한 방어선은 아닙니다.
  2. 2

    Step 2: 2단계: getServerSession 상세 검증

    페이지에서 다음과 같이 사용합니다.
    ```tsx
    import { getServerSession } from 'next-auth'
    import { authOptions } from '@/app/api/auth/[...nextauth]/route'

    export default async function DashboardPage() {
    const session = await getServerSession(authOptions)

    if (!session) {
    redirect('/login')
    }

    // 사용자 역할 확인
    if (session.user.role !== 'admin') {
    redirect('/unauthorized')
    }

    return <div>Dashboard</div>
    }
    ```

    역할:
    • 사용자 신원 상세 검증
    • 사용자 역할 확인
    • 세분화된 권한 제어

    장점:
    • Middleware보다 안전함
    • 상세 권한 검사 가능
    • 우회할 수 없음

    핵심: getServerSession은 상세 검증을 수행하는 두 번째 방어선입니다.
  3. 3

    Step 3: 3단계: 데이터베이스 권한 검사

    API Route에서 검사합니다.
    ```ts
    import { getServerSession } from 'next-auth'
    import { db } from '@/lib/db'

    export async function GET(request: Request) {
    const session = await getServerSession(authOptions)

    if (!session) {
    return new Response('Unauthorized', { status: 401 })
    }

    // 데이터베이스 권한 검사
    const user = await db.user.findUnique({
    where: { id: session.user.id },
    include: { role: { include: { permissions: true } } }
    })

    const hasPermission = user.role.permissions.some(
    p => p.resource === 'users' && p.action === 'read'
    )

    if (!hasPermission) {
    return new Response('Forbidden', { status: 403 })
    }

    // 데이터 반환
    return Response.json({ users: [...] })
    }
    ```

    역할:
    • 최종 권한 검증
    • 데이터베이스에 저장된 권한 확인
    • 데이터 보안 확보

    핵심: 데이터베이스는 최종 검증을 수행하는 세 번째 방어선입니다.
  4. 4

    Step 4: RBAC 권한 체계 구현

    데이터베이스 Schema:
    ```prisma
    model User {
    id String @id @default(cuid())
    email String @unique
    role Role @relation(fields: [roleId], references: [id])
    roleId String
    }

    model Role {
    id String @id @default(cuid())
    name String @unique
    permissions Permission[]
    users User[]
    }

    model Permission {
    id String @id @default(cuid())
    resource String
    action String
    roles Role[]
    }
    ```

    권한 검사:
    ```ts
    async function checkPermission(
    userId: string,
    resource: string,
    action: string
    ) {
    const user = await db.user.findUnique({
    where: { id: userId },
    include: { role: { include: { permissions: true } } }
    })

    return user.role.permissions.some(
    p => p.resource === resource && p.action === action
    )
    }
    ```

    핵심:
    • 사용자에게 역할이 있음
    • 역할에 권한이 있음
    • 권한이 리소스 접근을 제어함

FAQ

권한 제어를 Middleware에만 의존하면 안 되는 이유는 무엇인가요?
Middleware는 우회될 수 있기 때문입니다.

취약점 사례:
• CVE-2025-29927: 공격자가 요청 헤더에 x-middleware-subrequest 필드를 추가해 Middleware 검사를 직접 우회할 수 있습니다.
• Postman으로 백엔드 API를 직접 호출하면 보호되어야 할 데이터를 쉽게 가져갈 수 있습니다.

Middleware의 역할:
• Edge Runtime에서 실행되며 1차 선별을 수행함
• 사용자 로그인 여부와 관리자 또는 일반 사용자 여부를 확인함
• 빠르고 이른 시점에 실행되지만 이것만으로는 충분하지 않음

해결책: 다층 방어
• 1단계: Middleware(기본 검사)
• 2단계: getServerSession(상세 검증)
• 3단계: 데이터베이스 권한 검사(최종 검증)

핵심: 3단계 방어가 보안을 확보하며 어느 단계도 생략해서는 안 됩니다.
Middleware와 getServerSession의 차이는 무엇인가요?
Middleware:
• Edge Runtime에서 실행됨
• 빠르고 이른 시점에 실행됨
• 로그인 상태와 사용자 역할 같은 1차 선별만 가능함
• x-middleware-subrequest 취약점으로 우회될 수 있음

getServerSession:
• Node.js Runtime에서 실행됨
• 상세 검증 가능
• 사용자 역할과 권한 확인
• 우회할 수 없음

함께 사용하는 방법:
• Middleware로 기본 검사(1단계)
• getServerSession으로 상세 검증(2단계)
• 데이터베이스로 최종 권한 검사(3단계)

코드 예제:
```ts
// middleware.ts(1단계)
export default withAuth({
pages: { signIn: '/login' }
})

// page.tsx(2단계)
const session = await getServerSession(authOptions)
if (session.user.role !== 'admin') {
redirect('/unauthorized')
}

// api/route.ts(3단계)
const hasPermission = await checkPermission(userId, 'users', 'read')
if (!hasPermission) {
return new Response('Forbidden', { status: 403 })
}
```
RBAC 권한 체계는 어떻게 구현하나요?
RBAC = Role-Based Access Control(역할 기반 접근 제어)

데이터베이스 Schema:
```prisma
model User {
id String @id
email String @unique
role Role @relation(fields: [roleId], references: [id])
roleId String
}

model Role {
id String @id
name String @unique
permissions Permission[]
users User[]
}

model Permission {
id String @id
resource String // 리소스: users, posts, etc.
action String // 작업: read, write, delete
roles Role[]
}
```

권한 검사:
```ts
async function checkPermission(
userId: string,
resource: string,
action: string
) {
const user = await db.user.findUnique({
where: { id: userId },
include: { role: { include: { permissions: true } } }
})

return user.role.permissions.some(
p => p.resource === resource && p.action === action
)
}
```

핵심:
• 사용자에게 역할이 있음
• 역할에 권한이 있음
• 권한이 리소스 접근을 제어함
다층 방어 아키텍처는 어떻게 구현하나요?
3단계 방어:

1단계: Middleware(기본 검사)
```ts
// middleware.ts
export default withAuth({
pages: { signIn: '/login' }
})
```

2단계: getServerSession(상세 검증)
```tsx
// page.tsx
const session = await getServerSession(authOptions)
if (session.user.role !== 'admin') {
redirect('/unauthorized')
}
```

3단계: 데이터베이스 권한 검사(최종 검증)
```ts
// api/route.ts
const hasPermission = await checkPermission(userId, 'users', 'read')
if (!hasPermission) {
return new Response('Forbidden', { status: 403 })
}
```

핵심:
• 3단계 방어로 보안 확보
• 어느 단계도 생략해서는 안 됨
• Middleware가 1차 선별, getServerSession이 정밀 검증, 데이터베이스가 최종 검증을 수행함
x-middleware-subrequest 취약점은 어떻게 방어하나요?
취약점: 공격자가 요청 헤더에 x-middleware-subrequest 필드를 추가해 Middleware 검사를 직접 우회할 수 있습니다.

방어 방법:
• Middleware에만 의존하지 않기
• API Route에서도 권한 검사하기
• getServerSession으로 검증하기
• 데이터베이스에서 최종 권한 검사하기

코드 예제:
```ts
// api/route.ts
export async function GET(request: Request) {
// Middleware가 우회되더라도 여기에서 검사합니다.
const session = await getServerSession(authOptions)

if (!session) {
return new Response('Unauthorized', { status: 401 })
}

// 데이터베이스 권한 검사
const hasPermission = await checkPermission(session.user.id, 'users', 'read')

if (!hasPermission) {
return new Response('Forbidden', { status: 403 })
}

return Response.json({ users: [...] })
}
```

핵심:
• 다층 방어
• API Route에서도 권한 검사
• 데이터베이스에서 최종 검증

권장 사항: 절대로 Middleware에만 의존하지 말고 API Route에서도 권한을 검사하세요.
권한 제어의 모범 사례는 무엇인가요?
3단계 방어:
• Middleware로 기본 검사(1단계)
• getServerSession으로 상세 검증(2단계)
• 데이터베이스로 최종 권한 검사(3단계)

RBAC 권한 체계:
• 사용자에게 역할이 있음
• 역할에 권한이 있음
• 권한이 리소스 접근을 제어함

코드 구성:
• 권한 검사 유틸리티 함수 생성
• API Route에서 통일해 사용
• 중복 코드 방지

보안 권장 사항:
• 절대로 Middleware에만 의존하지 않기
• API Route에서도 권한 검사하기
• 데이터베이스에서 최종 검증하기
• 권한 설정을 정기적으로 검토하기

핵심:
• 다층 방어로 보안 확보
• 어느 단계도 생략해서는 안 됨
• 권한 체계를 지속해서 개선

권한 제어는 보안의 기초이므로 절대 가볍게 봐서는 안 됩니다.

6분 읽기 · 게시일: 2025년 12월 19일 · 수정일: 2026년 9월 4일

댓글

GitHub로 로그인하여 댓글을 남기세요

Easton BlogEaston Blog