안동민 개발노트

안동민 개발노트

Auth.js 설정로그인과 로그아웃보호된 라우트역할 기반 접근 제어
본문 시작
  1. 홈
  2. 문서
  3. Next.js
  4. 10장 : 인증 및 권한 관리
  5. 역할 기반 접근 제어
  1. Next.js
  2. 역할 기반 접근 제어

역할 기반 접근 제어

Auth.js 토큰과 세션에 역할을 전달하고 서버에서 관리자·회원 권한을 일관되게 검사합니다.

로그인 여부만으로 모든 기능의 사용 권한이 결정되지는 않습니다.

회원은 자신의 게시글을 수정할 수 있고 관리자는 신고된 게시글을 숨길 수 있다고 가정해 보겠습니다.

이처럼 역할에 따라 허용 작업을 나누는 방식을 역할 기반 접근 제어(Role-Based Access Control, RBAC)라고 합니다.

역할 정보는 화면 장식이 아니라 서버의 데이터 변경 규칙에 사용해야 합니다.


역할 모델 정하기

먼저 애플리케이션에서 사용할 역할을 좁게 정의합니다.

src/auth-types.ts
export type UserRole = 'member' | 'admin';

역할 이름은 구현 기술보다 업무 의미를 드러내야 합니다.

권한이 늘어나면 역할마다 할 수 있는 작업을 별도 함수로 정리합니다.

src/permissions.ts
import type { UserRole } from './auth-types';

export function canModeratePosts(role: UserRole) {
  return role === 'admin';
}

여러 컴포넌트에 role === 'admin' 조건을 반복하는 것보다 정책 함수를 공유하는 편이 변경에 안전합니다.


세션 타입 확장

TypeScript가 사용자 역할을 알 수 있도록 Auth.js 모듈 타입을 확장합니다.

src/types/next-auth.d.ts
import type { DefaultSession } from 'next-auth';
import type { UserRole } from '@/auth-types';

declare module 'next-auth' {
  interface Session {
    user: {
      id: string;
      role: UserRole;
    } & DefaultSession['user'];
  }

  interface User {
    role: UserRole;
  }
}

declare module 'next-auth/jwt' {
  interface JWT {
    id: string;
    role: UserRole;
  }
}

타입 선언은 실제 값을 만들지 않습니다.

다음 콜백에서 토큰과 세션에 같은 값을 넣어야 합니다.


토큰에서 세션으로 역할 전달

JWT 세션 전략에서는 로그인 시 사용자 정보를 토큰에 저장하고 세션 생성 시 필요한 값만 노출합니다.

src/auth.ts
import NextAuth from 'next-auth';
import GitHub from 'next-auth/providers/github';

export const { handlers, auth, signIn, signOut } = NextAuth({
  providers: [GitHub],
  session: { strategy: 'jwt' },
  callbacks: {
    jwt({ token, user }) {
      if (user) {
        token.id = user.id ?? token.sub!;
        token.role = user.role ?? 'member';
      }

      return token;
    },
    session({ session, token }) {
      session.user.id = token.id;
      session.user.role = token.role;
      return session;
    },
  },
});

기본 GitHub 공급자는 이 앱의 관리자 역할을 부여하지 않습니다. 위 예제에서 역할이 없으면 member가 되며, 관리자 분기를 시험하려면 서버의 검증된 역할 부여 경로가 먼저 필요합니다.

실제 서비스에서는 이메일 문자열만 보고 관리자 역할을 부여하지 않습니다.

검증된 데이터베이스 레코드에서 역할을 읽고, 역할 변경 시 기존 토큰을 언제 갱신할지도 정해야 합니다. 현재 코드는 로그인 때 받은 역할을 유지하며 매 요청마다 데이터베이스의 최신 역할을 다시 조회하지 않습니다.


서버 컴포넌트에서 관리자 페이지 보호

관리자 페이지는 서버에서 역할을 확인합니다. /login과 /forbidden 페이지는 애플리케이션에서 별도로 구현한 경로를 전제로 합니다.

src/app/admin/page.tsx
import { auth } from '@/auth';
import { redirect } from 'next/navigation';

export default async function AdminPage() {
  const session = await auth();

  if (!session?.user) {
    redirect('/login');
  }

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

  return <h1>관리자 화면</h1>;
}

로그인하지 않은 상태와 권한이 부족한 상태를 구분합니다.

관리자 링크를 숨기는 클라이언트 코드가 있어도 이 서버 검사는 유지합니다.


API에서 역할과 소유권 검사

게시글 수정 권한은 역할만으로 충분하지 않을 수 있습니다.

아래와 같은 게시글 수정 정책을 가정합니다.

게시글 수정 정책의 역할과 소유권 조합

게시글 수정 정책의 역할과 소유권 조합

게시글 수정 정책
로그인한 요청자본인 게시글다른 사람 게시글
회원권한 검사 통과403 · 수정 거부
관리자권한 검사 통과권한 검사 통과
로그인한 요청자: 회원
본인 게시글: 권한 검사 통과
다른 사람 게시글: 403 · 수정 거부
로그인한 요청자: 관리자
본인 게시글: 권한 검사 통과
다른 사람 게시글: 권한 검사 통과

대상 게시글이 존재할 때의 canEdit 판정입니다. 통과 후에도 본문 형식과 허용 필드를 검증해야 실제 저장으로 진행합니다.

먼저 요청 본문에서 수정 가능한 필드만 골라내는 데이터 전송 객체(Data Transfer Object, DTO) 검증 함수를 만듭니다.

src/lib/posts/update-post-input.ts
export type UpdatePostInput = {
  title?: string;
  content?: string;
};

const ALLOWED_FIELDS = new Set(['title', 'content']);

export function parseUpdatePostInput(value: unknown): UpdatePostInput | null {
  if (typeof value !== 'object' || value === null || Array.isArray(value)) {
    return null;
  }

  const body = value as Record<string, unknown>;
  const fields = Object.keys(body);

  if (fields.length === 0 || fields.some((field) => !ALLOWED_FIELDS.has(field))) {
    return null;
  }

  const input: UpdatePostInput = {};

  if ('title' in body) {
    if (typeof body.title !== 'string') return null;

    const title = body.title.trim();
    if (title.length < 1 || title.length > 100) return null;
    input.title = title;
  }

  if ('content' in body) {
    if (typeof body.content !== 'string') return null;

    const content = body.content.trim();
    if (content.length < 1 || content.length > 10_000) return null;
    input.content = content;
  }

  return input;
}

@/repositories/posts는 findPost와 updatePost를 내보내는 애플리케이션의 저장소 모듈입니다.

findPost는 작성자 ID를 포함한 게시글 또는 null을 반환하고, updatePost는 위 UpdatePostInput 타입만 받도록 구현합니다.

src/app/api/posts/[postId]/route.ts
import { auth } from '@/auth';
import { parseUpdatePostInput } from '@/lib/posts/update-post-input';
import { findPost, updatePost } from '@/repositories/posts';

export const PATCH = auth(async (request, context) => {
  const user = request.auth?.user;

  if (!user) {
    return Response.json({ message: '로그인이 필요합니다.' }, { status: 401 });
  }

  const { postId } = await context.params;
  const post = await findPost(postId);

  if (!post) {
    return Response.json({ message: '게시글이 없습니다.' }, { status: 404 });
  }

  const canEdit = post.authorId === user.id || user.role === 'admin';

  if (!canEdit) {
    return Response.json({ message: '수정 권한이 없습니다.' }, { status: 403 });
  }

  const mediaType = request.headers.get('content-type')?.split(';', 1)[0].trim().toLowerCase();

  if (mediaType !== 'application/json') {
    return Response.json({ message: 'JSON 본문이 필요합니다.' }, { status: 415 });
  }

  let body: unknown;

  try {
    body = await request.json();
  } catch {
    return Response.json({ message: '올바른 JSON 형식이 아닙니다.' }, { status: 400 });
  }

  const input = parseUpdatePostInput(body);

  if (!input) {
    return Response.json(
      { message: 'title과 content만 사용할 수 있으며 각각 1~100자, 1~10,000자여야 합니다.' },
      { status: 400 },
    );
  }

  const updated = await updatePost(postId, input);

  return Response.json(updated);
});

이 핸들러는 로그인 여부와 게시글 존재를 먼저 확인하고 수정 권한을 판정합니다. findPost와 updatePost의 저장소 구현은 생략되어 있으므로 동시 소유권 변경까지 원자적으로 막는다고 보장하지 않습니다. 운영 저장소에서는 조회·권한 조건·변경을 일관되게 적용해야 합니다.

허용 목록에 없는 authorId나 role 같은 필드는 거부하고, 검증한 title과 content만 저장소에 전달합니다. 문자열 길이는 현재 코드의 JavaScript .length, 즉 UTF-16 코드 단위로 셉니다.


Proxy의 역할 제한

Proxy에서도 request.auth?.user.role을 확인할 수 있습니다.

하지만 복잡한 게시글 소유권은 데이터베이스 조회가 필요한 업무 규칙입니다.

Proxy에는 관리자 경로의 빠른 진입 차단처럼 단순한 규칙만 둡니다.

최종 권한 검사는 데이터에 가장 가까운 라우트 핸들러나 서버 액션에서 수행합니다.


권한 설계 점검

클라이언트가 보낸 역할 값을 신뢰하지 않습니다.

세션 역할은 검증된 데이터에서 만들고 필요한 최소 정보만 노출합니다.

관리자 여부만 검사하지 말고 게시글 소유권 같은 업무 규칙도 함께 확인합니다.

권한 부족은 403, 인증 필요는 401로 구분합니다.

중요한 작업은 누가 어떤 리소스를 변경했는지 감사 로그를 남깁니다.

보호된 라우트

이전 페이지

Route Handler 생성

다음 페이지

이 페이지의 목차

역할 모델 정하기세션 타입 확장토큰에서 세션으로 역할 전달서버 컴포넌트에서 관리자 페이지 보호API에서 역할과 소유권 검사Proxy의 역할 제한권한 설계 점검