역할 기반 접근 제어
Auth.js 토큰과 세션에 역할을 전달하고 서버에서 관리자·회원 권한을 일관되게 검사합니다.
로그인했는지만으로 모든 기능의 사용 권한이 정해지지는 않습니다. 회원은 자신의 게시글을 수정할 수 있고, 관리자는 신고된 게시글을 숨길 수 있다고 가정해 보겠습니다. 이처럼 역할에 따라 허용할 작업을 나누는 방식을 역할 기반 접근 제어(Role-Based Access Control, RBAC)라고 합니다. 역할 정보는 화면을 꾸미는 값이 아니라, 서버의 데이터 변경 규칙에 써야 합니다.
RBAC와 리소스 소유권은 구분해야 합니다. admin처럼 역할로 허용하는 규칙과 "이 게시글의 작성자인가"처럼 사용자와 리소스를 비교하는 규칙은 서로 다르며, 이 글은 둘을 조합합니다. canModeratePosts()는 관리자만 조정 작업을 하는 예이고, 뒤의 PATCH 예제는 작성자나 관리자에게 수정 권한을 주는 별도의 정책입니다. 관리자에게 모든 작업을 무조건 허가한다는 일반 규칙은 아닙니다.
역할 모델 정하기
먼저 애플리케이션에서 쓸 역할을 좁게 정의합니다. 역할을 아무 문자열이나 받는 string으로 두면 'Admin'이나 'adimn' 같은 오타도 통과합니다. 아래처럼 허용할 값만 나열한 문자열 리터럴 유니언 타입으로 정의하면, 목록에 없는 값을 쓰는 순간 TypeScript가 컴파일 오류로 알려 줍니다.
export type UserRole = 'member' | 'admin';역할 이름은 구현 기술보다 업무의 의미를 드러내야 합니다. 권한이 늘어나면 역할마다 할 수 있는 작업을 별도의 함수로 정리합니다.
import type { UserRole } from './auth-types';
export function canModeratePosts(role: UserRole) {
return role === 'admin';
}여러 컴포넌트에 role === 'admin' 조건을 반복하는 것보다, 정책 함수를 함께 쓰는 편이 바꿀 때 안전합니다.
세션 타입 확장
TypeScript가 사용자 역할을 알 수 있도록 Auth.js의 모듈 타입을 확장합니다. Auth.js의 기본 Session 타입에서 user는 선택 속성인 id와 이름, 이메일, 이미지만 가지므로 session.user.role을 쓰면 타입 오류가 납니다. 아래 코드의 declare module은 라이브러리의 인터페이스에 필드를 덧붙이는 선언 병합이며, & DefaultSession['user']로 기존 필드를 그대로 남긴 채 id를 반드시 있는 문자열로 정하고 role을 더합니다. 토큰의 타입은 next-auth/jwt 모듈에 따로 있으므로 그쪽도 같은 방식으로 확장합니다.
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 · 저장소 원자성: 생략된 구현 · 운영 저장 증명: 없음 | 검증된 입력이 변경 호출의 경계를 넘습니다. |
위 그림은 게시글 수정 정책에서 역할과 소유권을 어떻게 조합하는지, 그리고 권한을 통과한 뒤에도 본문 검증을 거쳐야 저장된다는 점을 보여 줍니다. 아래쪽 표는 로그인한 사용자와 대상 게시글이 있는 경우의 canEdit = post.authorId === user.id || user.role === "admin" 판정입니다. 회원은 본인 게시글이면 권한 검사를 통과하고 다른 사람의 게시글이면 403으로 거부되며, 관리자는 두 경우 모두 통과합니다. 세션이 없으면 401, 게시글이 없으면 404로 그보다 먼저 끝납니다.
위쪽 흐름은 세션, 게시글 존재, canEdit 검사를 통과한 뒤의 PATCH 처리입니다. application/json이 아니면 본문을 읽기 전에 415로 끝나고, request.json()에서 예외가 나면 400으로 끝납니다. 정상 JSON이라도 parseUpdatePostInput의 허용 필드와 trim, 타입, 길이 검사를 통과하지 못하면 같은 400으로 끝나며, 검증을 통과한 input만 updatePost에 넘깁니다. 그림의 단계도 편집 자격 확인, 미디어 타입과 파싱, 허용 필드 검사, updatePost 호출의 순서입니다. 관리자도 이 DTO 검사를 건너뛰지 않으며, 생략된 저장소의 실제 원자성이나 운영 환경의 저장 성공까지 그림이 보장하지는 않습니다.
먼저 요청 본문에서 수정할 수 있는 필드만 골라내는 데이터 전송 객체(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의 허용 목록을 넓혀 주지는 않습니다.
Proxy의 역할 제한
Proxy에서도 request.auth?.user.role을 확인할 수 있습니다. 하지만 복잡한 게시글 소유권은 데이터베이스 조회가 필요한 업무 규칙입니다. Proxy에는 관리자 경로의 빠른 진입 차단처럼 단순한 규칙만 두고, 최종 권한 검사는 데이터에 가장 가까운 Route Handler나 서버 액션에서 합니다.
현재 역할을 요구하는 업무인지도 먼저 정해야 합니다. 예를 들어 09:00에 admin으로 로그인한 사용자를 09:10에 DB에서 member로 바꿔도, 위 jwt 콜백은 user가 없는 이후 호출에서 토큰의 admin을 그대로 돌려줍니다. useSession()으로 화면 상태를 다시 읽는 것만으로 DB 역할의 재조회가 더해지지는 않습니다. 역할 강등을 다음 요청부터 반영해야 한다면, 서버에서 최신 사용자 권한을 조회하거나 세션 버전과 폐기 정책을 검사한 뒤 변경을 허가해야 합니다. 이 JWT 스냅숏 예제에는 그런 기능이 없습니다. Auth.js RBAC 안내의 역할 전달 방식과 서비스의 갱신 정책은 구분해서 봅니다.
권한 설계 점검
클라이언트가 보낸 역할 값은 믿지 않습니다. 세션의 역할은 검증된 데이터에서 만들고 필요한 최소한의 정보만 내보냅니다. 관리자 여부만 검사하지 말고 게시글 소유권 같은 업무 규칙도 함께 확인하며, 권한 부족은 403, 인증 필요는 401로 구분합니다. 중요한 작업은 누가 어떤 리소스를 바꿨는지 감사 로그를 남깁니다.
정리
- RBAC는 역할에 따라 허용할 작업을 나누며, 리소스 소유권 검사와는 서로 다른 규칙이므로 필요하면 함께 조합합니다.
- 세션 타입 확장은 타입만 알려 줄 뿐이고, 실제 역할은
jwt콜백에서 토큰에 넣고session콜백에서 세션으로 내보냅니다. - 역할은 서버의 사용자 저장소에서 확인한 값으로 만들며, 요청이나 클라이언트가 보낸 역할 값은 권한 판단에 쓰지 않습니다.
- 수정 API는 인증, 리소스 존재, 소유권이나 역할, 미디어 타입, 허용 필드 순서로 검사하며, 관리자도 DTO 검증을 건너뛰지 않습니다.
- JWT에 담긴 역할은 로그인 시점의 값이므로, 역할 강등을 바로 반영해야 하는 업무라면 서버에서 최신 권한을 다시 확인합니다.
연습 문제
1. 저장 직전의 DTO와 역할 갱신 시점
사용자는 로그인했고 대상 게시글이 있으며 본문은 정상 JSON이라고 가정합니다. 본인 게시글에 { "title": " 새 제목 " }을 보내면 저장소에 어떤 값이 전달될까요? 관리자가 { "title": "새 제목", "authorId": "other" }를 보내거나 본인이 { "content": " " }를 보내면 updatePost가 호출될까요? 마지막으로 로그인할 때 토큰의 역할이 admin이었던 사용자를 데이터베이스에서 member로 바꾼 뒤, jwt 콜백에 user 없이 같은 token을 넘기면 역할이 저절로 갱신되는지도 판단해 보세요.
해설 보기
첫 입력은 trim한 뒤 { title: '새 제목' }만 넘깁니다. authorId가 섞인 입력은 허용 목록을 벗어나고, 공백뿐인 content는 trim한 뒤 길이가 0이므로 둘 다 400이며 updatePost를 호출하지 않습니다.
jwt 콜백은 user가 있을 때만 역할을 넣으므로, user 없는 이후 호출에서는 기존의 admin이 유지됩니다. 데이터베이스의 변경을 곧바로 반영하려면 별도의 재조회와 세션 갱신 정책이 필요합니다. Auth.js 역할 안내의 JWT 방식도 로그인 이후의 역할 갱신 조건을 따로 다룹니다.
다음 문제들은 게시글 p1의 작성자가 u1이고, u1과 u2는 member, a1은 토큰에 admin 역할을 가진 사용자라고 가정합니다. ID가 있는 정상 세션과 기존 게시글을 전제로 하며, 저장소의 동시 변경은 없습니다.
2. 권한이 없는 사용자의 잘못된 요청
u2가 p1에 text/plain으로 잘못된 JSON을 보내면 응답은 무엇일까요? JSON 읽기와 updatePost()는 실행될까요?
해설 보기
403입니다. u2는 작성자도 관리자도 아니므로, 미디어 타입과 JSON 검사를 하기 전에 끝납니다. JSON을 읽지 않고 저장소 변경도 호출하지 않습니다.
3. 작성자의 정상 요청
u1이 application/json; charset=utf-8로 { "title": " 새 제목 " }을 보내면 저장소에 어떤 객체가 전달될까요?
해설 보기
권한과 미디어 타입 검사를 통과하고, trim한 { title: '새 제목' }만 updatePost('p1', input)에 넘깁니다. charset 매개변수는 미디어 타입을 비교하기 전에 잘라 내므로 문제가 되지 않습니다. 저장소 구현이 정상적으로 성공하면 그 결과를 JSON으로 응답합니다.
4. 관리자가 role 필드를 보낼 때
a1이 application/json으로 { "content": "내용", "role": "admin" }을 보내면 관리자이므로 저장할 수 있을까요? 정상 JSON 대신 문법이 깨진 JSON을 보내는 경우도 구분해 보세요.
해설 보기
관리자도 DTO의 허용 목록을 통과해야 합니다. role은 허용되지 않으므로 400이고 저장하지 않습니다. JSON 문법 오류는 그보다 앞의 JSON 읽기 단계에서 400이 되므로, 이 경우에도 저장하지 않습니다.
5. 없는 게시글과 익명 요청
p-missing이라는 게시글은 존재하지 않습니다. 로그인한 u2가 그 ID를 요청할 때와, 익명 사용자가 같은 ID를 요청할 때의 첫 응답을 비교해 보세요.
해설 보기
로그인한 요청은 게시글 조회에서 404로 끝나고, 익명 요청은 게시글을 조회하기 전에 401로 끝납니다. 이 예제는 로그인한 사용자에게 리소스가 없는 경우와 권한이 부족한 경우를 구분해 알려 주는 정책입니다.
6. 강등된 관리자
a1을 DB에서 member로 강등했지만, 기존 토큰은 유효하고 user 없는 JWT 콜백을 거쳤습니다. 위 핸들러만으로 p1 수정이 곧바로 거부될까요? "강등한 다음 요청부터 차단"하는 정책에 필요한 추가 검사를 설명해 보세요.
해설 보기
곧바로 거부된다고 보장하지 않습니다. 토큰에는 여전히 admin이 들어 있으므로 지금 코드의 관리자 조건을 통과합니다. 업무가 최신 역할을 요구한다면, 서버가 현재 DB의 역할이나 권한 버전을 확인한 결과로 정책을 평가해야 합니다. 토큰이 유효한지 확인하는 것과 역할이 최신인지 확인하는 것은 별개입니다.
이것으로 10장을 마칩니다. 10장에서는 Auth.js를 설정하고, 로그인과 로그아웃을 만들고, 서버에서 세션과 역할, 소유권을 검사해 라우트와 데이터를 보호하는 방법을 정리했습니다. 다음 장의 Route Handler 생성부터는 App Router의 Route Handler로 API를 직접 만드는 방법을 알아봅니다.