역할 기반 접근 제어
Auth.js 토큰과 세션에 역할을 전달하고 서버에서 관리자·회원 권한을 일관되게 검사합니다.
로그인 여부만으로 모든 기능의 사용 권한이 결정되지는 않습니다.
회원은 자신의 게시글을 수정할 수 있고 관리자는 신고된 게시글을 숨길 수 있다고 가정해 보겠습니다.
이처럼 역할에 따라 허용 작업을 나누는 방식을 역할 기반 접근 제어(Role-Based Access Control, RBAC)라고 합니다.
역할 정보는 화면 장식이 아니라 서버의 데이터 변경 규칙에 사용해야 합니다.
RBAC와 리소스 소유권을 구분합니다. admin처럼 역할로 허용하는 규칙과 “이 게시글의 작성자인가”처럼 사용자와 리소스를 비교하는 규칙은 서로 다릅니다. 이 절은 둘을 조합합니다. canModeratePosts()는 관리자만 조정 작업을 하는 예이고, 뒤의 PATCH 예제는 작성자 또는 관리자에게 수정 권한을 주는 별도 정책입니다. 관리자에게 모든 작업을 무조건 허가한다는 일반 규칙은 아닙니다.
역할 모델 정하기
먼저 애플리케이션에서 사용할 역할을 좁게 정의합니다.
export type UserRole = 'member' | 'admin';역할 이름은 구현 기술보다 업무 의미를 드러내야 합니다.
권한이 늘어나면 역할마다 할 수 있는 작업을 별도 함수로 정리합니다.
import type { UserRole } from './auth-types';
export function canModeratePosts(role: UserRole) {
return role === 'admin';
}여러 컴포넌트에 role === 'admin' 조건을 반복하는 것보다 정책 함수를 공유하는 편이 변경에 안전합니다.
세션 타입 확장
TypeScript가 사용자 역할을 알 수 있도록 Auth.js 모듈 타입을 확장합니다.
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에 추가할 JWT·세션 설정입니다. 앞 절의 callbacks.authorized는 그대로 두고, 같은 callbacks 객체에 jwt와 session을 추가합니다. 직접 만든 로그인·오류 페이지가 있다면 pages 설정도 유지합니다.
NextAuth(...)는 중앙 인증 모듈에서 한 번만 선언합니다. 같은 이름의 콜백을 이미 쓰고 있다면 기존 처리와 아래 역할 전달을 한 함수에 합칩니다.
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;
},
},
});역할 전달을 추가한 뒤에도 로그아웃 상태에서 /account와 /dashboard의 로그인 경계가 유지되는지 확인합니다. 보호한 경로가 그대로 열린다면 callbacks.authorized가 남아 있는지 먼저 확인하고, 직접 만든 /login을 사용한다면 pages.signIn 경로도 확인합니다.
기본 GitHub 공급자는 이 앱의 관리자 역할을 부여하지 않습니다. 위 예제에서 역할이 없으면 member가 되며, 관리자 분기를 시험하려면 서버의 검증된 역할 부여 경로가 먼저 필요합니다.
타입 선언이 신원 검증을 대신하지 않습니다. token.sub!의 !는 TypeScript에 값이 있다고 알릴 뿐 런타임에 ID를 생성하거나 검사하지 않습니다. 이 예제는 로그인 결과에 안정적인 사용자 ID가 있고, post.authorId도 같은 사용자 ID 체계를 사용한다는 전제가 있습니다. 이메일이나 클라이언트가 제출한 ID를 대신 비교하지 않습니다. 인증 설정을 바꾼 뒤 ID·역할이 없는 예전 토큰이 남았다면 다시 로그인하거나 명시적인 마이그레이션·검증 정책으로 처리해야 합니다. ID가 없는 세션을 정상 사용자로 취급해 쓰기 작업을 허용하지 않습니다.
user.role은 앱 서버가 관리하는 사용자 저장소 등에서 확인한 값이어야 합니다. 입력 폼이나 세션 업데이트 요청의 { role: 'admin' }을 그대로 토큰에 복사하지 않습니다. 클라이언트에 노출한 session.user.role은 메뉴 표시에도 쓰지만, 요청이 되돌려 보낸 값을 서버 권한의 근거로 사용하지 않습니다.
실제 서비스에서는 이메일 문자열만 보고 관리자 역할을 부여하지 않습니다.
검증된 데이터베이스 레코드에서 역할을 읽고, 역할 변경 시 기존 토큰을 언제 갱신할지도 정해야 합니다. 현재 코드는 로그인 때 받은 역할을 유지하며 매 요청마다 데이터베이스의 최신 역할을 다시 조회하지 않습니다.
서버 컴포넌트에서 관리자 페이지 보호
관리자 페이지는 서버에서 역할을 확인합니다. /login과 /forbidden 페이지는 애플리케이션에서 별도로 구현한 경로를 전제로 합니다.
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에서 역할과 소유권 검사
게시글 수정 권한은 역할만으로 충분하지 않을 수 있습니다.
아래와 같은 게시글 수정 정책을 가정합니다.
게시글 수정 정책의 역할과 소유권 조합
PATCH에서 세션·게시물·역할 및 소유권을 확인한 뒤 미디어 타입, JSON 파싱, 허용 필드·문자열을 검사합니다. 거부 지점과 실제 update 입력이 달라지는 과정을 보여 줄 수 있으며 관리자에게 DTO 검사를 면제하지 않습니다.
NEXT · 10.4 · 역할과 소유권
본인 게시글 또는 관리자이면 수정 권한 검사를 통과합니다
로그인한 사용자와 대상 게시글이 있는 경우의 canEdit = post.authorId === user.id || user.role === "admin" 판정입니다. 권한 검사 통과와 저장 완료를 구분합니다.
| 로그인한 요청자 | 본인 게시글 | 다른 사람 게시글 |
|---|---|---|
| 회원 | 권한 검사 통과 | 403 · 수정 거부 |
| 관리자 | 권한 검사 통과 | 권한 검사 통과 |
- 로그인한 요청자: 회원
- 본인 게시글: 권한 검사 통과
- 다른 사람 게시글: 403 · 수정 거부
- 로그인한 요청자: 관리자
- 본인 게시글: 권한 검사 통과
- 다른 사람 게시글: 권한 검사 통과
세션이 없으면 401, 게시글이 없으면 404로 먼저 종료합니다. 권한 검사 통과 뒤에도 JSON 형식·허용 필드 검증이 필요합니다. 예제는 저장소 구현을 생략하므로 동시 소유권 변경을 원자적으로 막는 것까지 보장하지 않습니다.
이 예의 조건
- 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
- 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
- 세션, 게시글 존재, canEdit 검사를 통과한 뒤의 원 PATCH 제어 흐름이다. application/json이 아니면 415로 끝난다. request.json에서 예외가 발생하면 400으로 끝난다. 정상 JSON도 parseUpdatePostInput의 허용 필드와 trim/타입/길이 검사를 통과하지 못하면 같은 400으로 합류한다. 통과한 input만 updatePost에 전달한다. 아래 표는 앞 단계의 소유권/역할 비교이며 관리자도 DTO 검사를 생략하지 않는다. 생략된 저장소의 실제 원자성이나 운영 저장 성공은 그림으로 보장하지 않는다.
관계도의 보충 설명
| 순번 | 원문 표시 |
|---|---|
| 1 | 게시글 수정 정책의 역할과 소유권 조합 |
| 장면 | 관찰 값 | 전환 이유 |
|---|---|---|
| 편집 자격 확인 세션·게시물 존재·canEdit을 통과한 요청을 DTO 처리로 보냅니다. | 전제: 세션·post·canEdit 통과 · 관리자: DTO 면제 아님 | 접근 검사를 통과한 요청만 입력 검사를 시작합니다. |
| 미디어 타입과 파싱 application/json이 아니면 415이고 JSON 파싱 예외면 400입니다. | 미디어 실패: 415 · 파싱 실패: 400 | 입력 컨테이너가 유효한 JSON인지 확인합니다. |
| 허용 필드 검사 parseUpdatePostInput이 허용 필드·trim·타입·길이를 확인합니다. | 검증 실패: 400 · 검증 성공: update input | 정상 JSON이 실제 변경 입력으로 정제됩니다. |
| updatePost 호출 통과한 input만 updatePost로 넘깁니다. | 업무: updatePost · 저장소 원자성: 생략된 구현 · 운영 저장 증명: 없음 | 검증된 입력이 변경 호출의 경계를 넘습니다. |
게시글 수정 정책의 역할과 소유권 조합
PATCH에서 세션·게시물·역할 및 소유권을 확인한 뒤 미디어 타입, JSON 파싱, 허용 필드·문자열을 검사합니다. 거부 지점과 실제 update 입력이 달라지는 과정을 보여 줄 수 있으며 관리자에게 DTO 검사를 면제하지 않습니다.
NEXT · 10.4 · 역할과 소유권
본인 게시글 또는 관리자이면 수정 권한 검사를 통과합니다
로그인한 사용자와 대상 게시글이 있는 경우의 canEdit = post.authorId === user.id || user.role === "admin" 판정입니다. 권한 검사 통과와 저장 완료를 구분합니다.
| 로그인한 요청자 | 본인 게시글 | 다른 사람 게시글 |
|---|---|---|
| 회원 | 권한 검사 통과 | 403 · 수정 거부 |
| 관리자 | 권한 검사 통과 | 권한 검사 통과 |
- 로그인한 요청자: 회원
- 본인 게시글: 권한 검사 통과
- 다른 사람 게시글: 403 · 수정 거부
- 로그인한 요청자: 관리자
- 본인 게시글: 권한 검사 통과
- 다른 사람 게시글: 권한 검사 통과
세션이 없으면 401, 게시글이 없으면 404로 먼저 종료합니다. 권한 검사 통과 뒤에도 JSON 형식·허용 필드 검증이 필요합니다. 예제는 저장소 구현을 생략하므로 동시 소유권 변경을 원자적으로 막는 것까지 보장하지 않습니다.
이 예의 조건
- 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
- 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
- 세션, 게시글 존재, canEdit 검사를 통과한 뒤의 원 PATCH 제어 흐름이다. application/json이 아니면 415로 끝난다. request.json에서 예외가 발생하면 400으로 끝난다. 정상 JSON도 parseUpdatePostInput의 허용 필드와 trim/타입/길이 검사를 통과하지 못하면 같은 400으로 합류한다. 통과한 input만 updatePost에 전달한다. 아래 표는 앞 단계의 소유권/역할 비교이며 관리자도 DTO 검사를 생략하지 않는다. 생략된 저장소의 실제 원자성이나 운영 저장 성공은 그림으로 보장하지 않는다.
관계도의 보충 설명
| 순번 | 원문 표시 |
|---|---|
| 1 | 게시글 수정 정책의 역할과 소유권 조합 |
| 장면 | 관찰 값 | 전환 이유 |
|---|---|---|
| 편집 자격 확인 세션·게시물 존재·canEdit을 통과한 요청을 DTO 처리로 보냅니다. | 전제: 세션·post·canEdit 통과 · 관리자: DTO 면제 아님 | 접근 검사를 통과한 요청만 입력 검사를 시작합니다. |
| 미디어 타입과 파싱 application/json이 아니면 415이고 JSON 파싱 예외면 400입니다. | 미디어 실패: 415 · 파싱 실패: 400 | 입력 컨테이너가 유효한 JSON인지 확인합니다. |
| 허용 필드 검사 parseUpdatePostInput이 허용 필드·trim·타입·길이를 확인합니다. | 검증 실패: 400 · 검증 성공: update input | 정상 JSON이 실제 변경 입력으로 정제됩니다. |
| updatePost 호출 통과한 input만 updatePost로 넘깁니다. | 업무: updatePost · 저장소 원자성: 생략된 구현 · 운영 저장 증명: 없음 | 검증된 입력이 변경 호출의 경계를 넘습니다. |
먼저 요청 본문에서 수정 가능한 필드만 골라내는 데이터 전송 객체(Data Transfer Object, DTO) 검증 함수를 만듭니다.
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 타입만 받도록 구현합니다.
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의 저장소 구현은 생략되어 있으므로 동시 소유권 변경까지 원자적으로 막는다고 보장하지 않습니다. 운영 저장소에서는 조회·권한 조건·변경을 일관되게 적용해야 합니다.
조회 뒤에 소유자가 바뀌는 경우도 고려합니다. 회원 u1이 조회할 때는 작성자였지만 저장 직전에 작성자가 u2로 변경되었다면, 앞선 조회 결과만으로 쓰는 코드는 새 상태의 권한을 다시 확인하지 못합니다. 저장소에서 현재 작성자·버전 같은 조건을 변경문에 포함하고 변경된 행 수를 확인하거나, 적절한 잠금·격리 수준을 갖춘 트랜잭션으로 검사와 변경을 묶는 방식이 필요합니다. 단순히 트랜잭션을 열었다는 사실만으로 모든 경쟁 상태가 해결되지는 않습니다.
허용 목록에 없는 authorId나 role 같은 필드는 거부하고, 검증한 title과 content만 저장소에 전달합니다. 문자열 길이는 현재 코드의 JavaScript .length, 즉 UTF-16 코드 단위로 셉니다.
예시
게시글 주인이 아닌 일반 회원이 잘못된 Content-Type까지 함께 보내면 이 코드의 응답은 403입니다. 소유권·역할 검사에서 먼저 끝나므로 뒤의 미디어 타입 검사는 실행하지 않습니다. 반면 같은 게시글을 수정할 권한이 있는 관리자가 JSON에 role 필드를 끼워 보내면 허용 필드 검증에서 400입니다. 관리자 권한도 요청 DTO의 허용 목록을 넓혀 주지는 않습니다.
확인 문제
사용자는 로그인했고 대상 게시글이 있습니다. 본문은 정상 JSON이라고 가정합니다. 본인 게시글에 { "title": " 새 제목 " }을 보내면 저장소에 어떤 값이 전달되나요? 관리자가 { "title": "새 제목", "authorId": "other" }를 보내거나 본인이 { "content": " " }를 보내면 updatePost는 호출되나요? 마지막으로 로그인 때 토큰의 역할이 admin이었던 사용자를 데이터베이스에서 member로 바꾼 뒤, 원 jwt 콜백에 user 없이 같은 token을 전달하면 역할이 자동 갱신되는지도 판단합니다.
저장 직전 DTO와 역할 갱신 시점 해설
첫 입력은 trim 뒤 { title: '새 제목' }만 전달합니다. authorId가 섞인 입력은 허용 목록을 벗어나고, 공백뿐인 content는 trim 뒤 길이 0이므로 둘 다 400이며 updatePost를 호출하지 않습니다. 원 jwt 콜백은 user가 있을 때만 역할을 대입하므로 user 없는 후속 호출은 기존 admin을 유지합니다. 데이터베이스 변경을 즉시 반영하려면 별도의 재조회·세션 갱신 정책이 필요합니다. Auth.js 역할 안내의 JWT 방식도 로그인 이후 역할 갱신 조건을 별도로 다룹니다.
Proxy의 역할 제한
Proxy에서도 request.auth?.user.role을 확인할 수 있습니다.
하지만 복잡한 게시글 소유권은 데이터베이스 조회가 필요한 업무 규칙입니다.
Proxy에는 관리자 경로의 빠른 진입 차단처럼 단순한 규칙만 둡니다.
최종 권한 검사는 데이터에 가장 가까운 라우트 핸들러나 서버 액션에서 수행합니다.
현재 역할을 요구하는 업무인지 먼저 정합니다. 예를 들어 09:00에 admin으로 로그인한 사용자를 09:10에 DB에서 member로 변경해도, 위 jwt 콜백은 user가 없는 후속 호출에서 토큰의 admin을 그대로 반환합니다. useSession()으로 화면 상태를 다시 읽는 것만으로 DB 역할 재조회가 추가되지는 않습니다. 역할 강등을 다음 요청부터 반영해야 한다면 서버에서 최신 사용자 권한을 조회하거나 세션 버전·폐기 정책을 검사한 뒤 변경을 허가해야 합니다. 이 JWT 스냅샷 예제에는 그 기능이 없습니다. Auth.js RBAC 안내의 역할 전달 방식과 서비스의 갱신 정책을 구분합니다.
권한 설계 점검
클라이언트가 보낸 역할 값을 신뢰하지 않습니다.
세션 역할은 검증된 데이터에서 만들고 필요한 최소 정보만 노출합니다.
관리자 여부만 검사하지 말고 게시글 소유권 같은 업무 규칙도 함께 확인합니다.
권한 부족은 403, 인증 필요는 401로 구분합니다.
중요한 작업은 누가 어떤 리소스를 변경했는지 감사 로그를 남깁니다.
연습: PATCH 응답과 저장 부작용 추적하기
게시글 p1의 작성자는 u1이고, u1과 u2는 member, a1은 토큰에 admin 역할을 가진 사용자입니다. ID가 있는 정상 세션과 기존 게시글을 가정하며, 저장소의 동시 변경은 없습니다. 권한이 있는 요청은 지정한 Content-Type과 본문을 보냅니다.
u2가p1에text/plain으로 잘못된 JSON을 보내면 응답은 무엇인가요? JSON 읽기와updatePost()는 실행되나요?u1이application/json; charset=utf-8로{ "title": " 새 제목 " }을 보내면 저장소에 어떤 객체가 전달되나요?a1이application/json으로{ "content": "내용", "role": "admin" }을 보내면 관리자이므로 저장할 수 있나요? 정상 JSON 대신 문법이 깨진 JSON을 보내는 경우도 구분하세요.- 별도의
p-missing은 존재하지 않습니다. 로그인한u2가 그 ID를 요청할 때, 그리고 익명 사용자가 같은 ID를 요청할 때의 첫 응답을 비교하세요. a1을 DB에서member로 강등했지만 기존 토큰은 유효하고user없는 JWT 콜백을 거쳤습니다. 위 핸들러만으로p1수정이 즉시 거부되나요? “강등 다음 요청부터 차단” 정책에 필요한 추가 검사를 설명하세요.
권한·DTO·역할 시점 풀이
403입니다. 작성자도 관리자도 아니므로 미디어 타입과 JSON 검사를 하기 전에 끝납니다. JSON을 읽지 않고 저장소 변경도 호출하지 않습니다.- 권한과 미디어 타입을 통과하며 trim한
{ title: '새 제목' }만updatePost('p1', input)에 전달합니다. 정상 저장소 구현이 성공하면 결과를 JSON으로 응답합니다. - 관리자도 DTO 허용 목록을 통과해야 합니다.
role은 허용되지 않아400이고 저장하지 않습니다. JSON 문법 오류는 그보다 앞의 JSON 읽기 단계에서400이므로 역시 저장하지 않습니다. - 로그인한 요청은 게시글 조회에서
404로 끝납니다. 익명 요청은 게시글 조회 전에401로 끝납니다. 이 예제는 로그인한 사용자에게 존재하지 않는 리소스와 권한 부족을 구분하는 정책입니다. - 즉시 거부한다고 보장하지 않습니다. 토큰에는 여전히
admin이어서 현재 코드의 관리자 조건을 통과합니다. 업무가 최신 역할을 요구한다면 서버가 현재 DB 역할이나 권한 버전을 확인한 결과로 정책을 평가해야 합니다. 토큰의 유효성 확인과 역할의 최신성 확인은 별개입니다.