역할 기반 접근 제어 (RBAC) 구현
인증으로 만든 현재 사용자와 라우트 역할 메타데이터를 분리하고, 전역 가드의 기본 보호 정책과 명시적 공개 경계를 구현합니다.
앞 글에서는 JWT 액세스 토큰을 검증해 현재 요청의 req.user를 만드는 인증을 구현했습니다. 이 글의 권한 부여는 인증된 사용자가 특정 경로를 실행할 수 있는지를 판단합니다. 인증 실패는 누구인지 확인할 수 없다는 뜻이라 401 Unauthorized이고, 역할 부족은 누구인지는 알지만 이 작업은 허용되지 않는다는 뜻이라 403 Forbidden입니다.
RBAC에서 비교하는 두 역할 집합
역할 기반 접근 제어(Role-Based Access Control, RBAC)는 사용자에게 권한을 하나하나 붙이는 대신 역할을 주고, 역할을 권한의 묶음과 연결하는 모델입니다. 사용자는 인증된 주체이고, 역할은 admin, editor, user처럼 직무를 나타내는 이름이며, 권한(permission)은 create:post, read:post처럼 리소스에 할 수 있는 구체적인 행위입니다.
이 글의 코드는 경로가 요구하는 역할과 사용자의 역할 가운데 하나라도 정확히 일치하는지를 확인하는 기본적인 RBAC입니다. 역할에서 권한으로 넓히는 매핑이나 역할의 계층은 저절로 생기지 않습니다. 그래서 admin이 user를 암묵적으로 포함하지 않으며, 필요하다면 중앙의 정책에서 명시적으로 설계해야 합니다.
Nest RBAC · 세 정책 상태
공개 예외, 인증 전용, 역할 제한을 명시적으로 구분합니다
JWT guard가 기본 인증을, RolesGuard가 추가 역할 제한을 담당합니다. handler 메타데이터가 class를 override하며 역할은 exact any-of입니다. 공개 예외는 두 guard가 같은 규칙으로 먼저 확인합니다.
명시적 공개
@Public()@Roles()보다 우선합니다.기본 인증
JwtAuthGuard401입니다. 인증 성공 뒤에만 req.user가 역할 가드로 전달됩니다.요구 역할 조회
@Roles()getAllAndOverride()는 handler 값을 우선하고 class 값을 fallback으로 사용합니다. 두 배열을 합치지 않습니다.메타데이터 없음
undefined역할 판정
some()403입니다. 암묵적 역할 계층은 없습니다.변경과 신선도
Role 어휘, 사용자 데이터, JWT이 표의 정책은 “기본 인증, 명시적 공개, 필요한 곳의 역할 제한”입니다. 모든 route가 권한 메타데이터를 반드시 선언해야 하는 조직이라면 별도의 policy-required 검사를 추가합니다.
이 표는 경로의 세 가지 정책 상태를 나눕니다. @Public()이 붙은 경로는 두 전역 가드가 공개 메타데이터를 먼저 확인해 익명 접근을 허용하고, 그렇지 않은 경로는 전역 JwtAuthGuard가 인증을 요구해 실패하면 401입니다. 인증 뒤 @Roles()가 없으면 역할 제한 없이 통과하므로 공개가 아니라 인증 전용이고, 있으면 요구 역할 가운데 하나와 정확히 일치할 때만 실행되며 아니면 403입니다. 핸들러의 메타데이터가 클래스의 것을 대체하고, JWT에 담긴 역할은 발급 시점의 스냅샷이라는 점도 함께 정리합니다.
정리하면 이 구현의 공개·보호 규칙은 다음과 같습니다. JwtAuthGuard를 전역으로 등록해 모든 경로를 기본으로 인증이 필요한 상태로 두고, 익명 접근은 @Public()을 붙인 경로에서만 명시적으로 허용합니다. @Roles()가 없다는 것은 공개가 아니라, 인증은 필요하고 추가 역할 제한은 없다는 뜻입니다. @Roles(Role.User, Role.Editor)는 둘 가운데 하나만 가져도 허용하는 규칙입니다.
하나의 역할 어휘 정의
문자열을 여러 파일에서 그대로 반복하지 않도록 역할을 한곳에서 정의합니다. enum의 각 멤버는 코드에서 쓰는 이름(Role.Admin)과 실제로 저장하고 전송하는 문자열 값('admin')을 짝짓습니다. JWT와 데이터베이스에는 문자열 값이 들어가고 코드에서는 이름을 쓰므로, @Roles()에 'admni' 같은 오타를 넘기면 컴파일 단계에서 걸러집니다.
export enum Role {
User = 'user',
Editor = 'editor',
Admin = 'admin',
}사용자 저장소, JWT 클레임 검증, @Roles() 메타데이터, 테스트가 모두 이 어휘를 써야 합니다. 데이터베이스에서 문자열을 읽는다면, 애플리케이션에 들어오는 지점에서 허용된 Role 값인지 검증합니다.
앞 글의 JwtStrategy.validate(payload: unknown)도 roles가 문자열 배열인지만 보지 말고, 지금의 애플리케이션이 아는 역할인지까지 확인하도록 좁힙니다.
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { Role } from '../role.enum';
type JwtPayload = {
sub: string;
username: string;
roles: Role[];
};
const roleValues = new Set<string>(Object.values(Role));
function isJwtPayload(value: unknown): value is JwtPayload {
if (typeof value !== 'object' || value === null) return false;
const payload = value as Record<string, unknown>;
return (
typeof payload.sub === 'string' &&
Number.isSafeInteger(Number(payload.sub)) &&
String(Number(payload.sub)) === payload.sub &&
typeof payload.username === 'string' &&
Array.isArray(payload.roles) &&
payload.roles.every(
(role): role is Role =>
typeof role === 'string' && roleValues.has(role),
)
);
}
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(configService: ConfigService) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
secretOrKey:
configService.getOrThrow<string>('JWT_SECRET'),
algorithms: ['HS256'],
ignoreExpiration: false,
});
}
validate(payload: unknown) {
if (!isJwtPayload(payload)) {
throw new UnauthorizedException('Invalid JWT claims');
}
return {
userId: Number(payload.sub),
username: payload.username,
roles: payload.roles,
};
}
}Passport JWT가 허용 알고리즘, 서명, 만료를 검증한 뒤에만 validate()가 호출되며, 위의 검사는 그다음 단계에서 역할 클레임의 런타임 형태와 어휘를 검증합니다. sub도 앞 글처럼 안전한 정수를 나타내는 십진 문자열인지 확인한 뒤 Number(payload.sub)를 내부 userId로 돌려주므로, 문자열 클레임과 숫자형 사용자 ID를 같은 타입으로 혼동하지 않습니다.
JWT의 역할은 발급 시점의 스냅샷입니다. 관리자가 역할을 바꿔도 기존 토큰에는 만료 전까지 이전 값이 남을 수 있습니다. 즉시 반영해야 한다면 짧은 토큰 수명에만 기대지 말고, 요청 중 사용자 저장소 조회, 토큰 버전, 별도의 폐기 상태 같은 정책을 고릅니다.
역할과 공개 메타데이터
@Roles()는 필요한 역할의 배열을, @Public()은 익명 접근의 예외를 메타데이터로 기록합니다. 두 데코레이터는 모두 SetMetadata()에 키와 값을 넘기는 한 줄짜리 함수입니다. 키를 ROLES_KEY, IS_PUBLIC_KEY 상수로 내보내는 것은 가드가 같은 상수로 메타데이터를 읽게 해서, 문자열 키의 오타 때문에 메타데이터를 찾지 못하는 실수를 막기 위해서입니다.
import { SetMetadata } from '@nestjs/common';
import { Role } from '../../auth/role.enum';
export const ROLES_KEY = 'roles';
export const Roles = (...roles: Role[]) =>
SetMetadata(ROLES_KEY, roles);import { SetMetadata } from '@nestjs/common';
export const IS_PUBLIC_KEY = 'isPublic';
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);SetMetadata()는 정책을 실행하지 않고, 지금의 핸들러와 컨트롤러 클래스에 데이터를 붙이기만 합니다. 실제 판정은 가드가 맡습니다.
@Public()과 @Roles()는 서로 반대되는 정책이므로 같은 대상에 함께 붙이지 않습니다. 다만 @Roles()가 붙은 컨트롤러 안에서 특정 핸들러만 @Public()으로 여는 구성은 유효합니다. 이때 두 전역 가드가 모두 핸들러의 공개 메타데이터를 먼저 읽어야, 클래스의 역할 제한이 익명 요청을 다시 거부하지 않습니다. 반대로 컨트롤러 전체에 @Public()을 붙이면 핸들러의 @Roles()만으로 다시 보호할 수 없으므로, 공개 범위는 가능한 한 핸들러 단위로 좁게 선언합니다.
기본 인증 가드와 명시적 공개
Passport의 JWT 가드를 넓혀, @Public() 메타데이터가 있는 핸들러나 컨트롤러만 인증을 건너뛰게 합니다. 코드에서는 canActivate()를 봅니다. 공개 메타데이터가 있으면 true를 돌려 Passport 전략을 아예 실행하지 않고, 없으면 super.canActivate()로 부모 클래스의 JWT 검증을 그대로 실행합니다.
import { ExecutionContext, Injectable } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
import {
IS_PUBLIC_KEY,
} from '../../common/decorators/public.decorator';
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {
constructor(private readonly reflector: Reflector) {
super();
}
canActivate(context: ExecutionContext) {
const isPublic = this.reflector.getAllAndOverride<boolean>(
IS_PUBLIC_KEY,
[context.getHandler(), context.getClass()],
);
if (isPublic) return true;
return super.canActivate(context);
}
}getAllAndOverride()는 배열의 앞쪽부터 처음 정의된 값을 씁니다. 그래서 핸들러를 먼저, 컨트롤러 클래스를 나중에 넘기면 핸들러의 설정이 클래스의 기본값을 대체합니다.
RolesGuard 구현
RolesGuard는 핸들러가 요구하는 역할을 먼저 찾고, 없으면 컨트롤러 클래스의 기본 역할을 씁니다. 판정은 세 단계입니다. 공개 메타데이터가 있으면 바로 통과하고, 역할 메타데이터가 없으면 인증만 요구하는 경로로 보고 통과하며, 둘 다 아니면 req.user.roles와 요구 역할이 하나라도 겹칠 때만 통과합니다. req.user는 앞서 실행된 JwtAuthGuard가 JwtStrategy.validate()의 반환값으로 채운 것이므로, 이 가드는 토큰을 다시 해석하지 않습니다.
import {
CanActivate,
ExecutionContext,
Injectable,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { Role } from '../role.enum';
import {
IS_PUBLIC_KEY,
} from '../../common/decorators/public.decorator';
import {
ROLES_KEY,
} from '../../common/decorators/roles.decorator';
type AuthenticatedRequest = {
user?: {
userId: number;
username: string;
roles: Role[];
};
};
@Injectable()
export class RolesGuard implements CanActivate {
constructor(private readonly reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const targets = [context.getHandler(), context.getClass()];
const isPublic = this.reflector.getAllAndOverride<boolean>(
IS_PUBLIC_KEY,
targets,
);
if (isPublic) return true;
const requiredRoles =
this.reflector.getAllAndOverride<Role[]>(
ROLES_KEY,
targets,
);
if (requiredRoles === undefined) {
return true;
}
const { user } =
context.switchToHttp().getRequest<AuthenticatedRequest>();
return (
user !== undefined &&
requiredRoles.some((role) => user.roles.includes(role))
);
}
}유효한 @Public() 메타데이터가 있으면 역할을 조회하기 전에 통과하므로, @Roles()가 붙은 컨트롤러 안의 공개 핸들러도 두 전역 가드를 모두 건너뜁니다. 핸들러의 @Roles()는 클래스의 @Roles()를 대체하며, 둘을 합치려면 getAllAndMerge()를 고르고 그 결과를 '하나라도'로 볼지 '모두'로 볼지 따로 정해야 합니다.
메타데이터가 없을 때 RolesGuard가 true를 돌려줘도, 앞선 전역 JwtAuthGuard가 이미 인증을 요구했으므로 이 경로는 공개가 아니라 인증 전용입니다. 필요한 역할이 있는데 겹치는 역할이 없으면 false가 되어 Nest가 기본으로 403을 응답합니다. 인자 없는 @Roles()는 빈 배열을 저장해 어떤 사용자와도 일치하지 않으므로 쓰지 않습니다.
전역 가드 등록
두 가드를 APP_GUARD로 등록하면 모듈의 위치와 관계없이 애플리케이션 전체에 적용됩니다. 가드는 등록한 순서대로 실행되므로, JWT 인증 가드를 역할 가드보다 먼저 둡니다.
import { Module } from '@nestjs/common';
import { APP_GUARD } from '@nestjs/core';
import { AuthService } from './auth.service';
import { JwtAuthGuard } from './guards/jwt-auth.guard';
import { RolesGuard } from './guards/roles.guard';
import { JwtStrategy } from './strategies/jwt.strategy';
import { LocalStrategy } from './strategies/local.strategy';
@Module({
// imports, controllers 생략
providers: [
AuthService,
LocalStrategy,
JwtStrategy,
{
provide: APP_GUARD,
useClass: JwtAuthGuard,
},
{
provide: APP_GUARD,
useClass: RolesGuard,
},
],
})
export class AuthModule {}전역으로 등록하지 않는다면, 보호할 핸들러에 @UseGuards(JwtAuthGuard, RolesGuard)를 이 순서로 붙여야 합니다. 일부 경로에서 역할 가드만 실행되어 req.user가 아직 없는 상태가 생기지 않게 합니다.
첫 로그인 요청 열기
전역 JWT 가드를 켜면, 아직 토큰이 없는 로그인 요청도 기본 보호 대상이 됩니다. 앞 글의 src/auth/auth.controller.ts에서 로그인 메서드만 명시적으로 열어야, 자격 증명을 검사하고 첫 토큰을 발급할 수 있습니다. 먼저 그 파일의 import 부분에 이 글에서 정의한 Public을 추가합니다.
import { Public } from '../common/decorators/public.decorator';같은 파일의 AuthController 안에 있는 login 메서드에 @Public()을 붙입니다. 아래는 메서드 부분만 발췌했으므로, 기존의 import, 생성자, 프로필 메서드는 그대로 둡니다.
@Public()
@UseGuards(LocalAuthGuard)
@Post('login')
@HttpCode(HttpStatus.OK)
async login(@Request() req: { user: unknown }) {
return this.authService.login(req.user);
}@Public()은 이 글의 두 전역 가드에서 JWT와 역할 검사를 건너뛰게 합니다. 경로에 붙은 LocalAuthGuard는 전역 가드 다음에 그대로 실행되어 사용자 이름과 비밀번호를 검사하므로, 잘못된 자격 증명은 여전히 401로 거부됩니다. 토큰 없이 보낸 로그인 요청이 JWT 검사에서 먼저 막힌다면, 이 메서드의 공개 메타데이터와 import 경로부터 확인합니다. AuthController 클래스 전체나 보호된 프로필 메서드에는 @Public()을 붙이지 않습니다.
컨트롤러의 정책 선언
import { Controller, Get, Request } from '@nestjs/common';
import { Role } from './auth/role.enum';
import { Public } from './common/decorators/public.decorator';
import { Roles } from './common/decorators/roles.decorator';
@Controller()
export class AppController {
@Public()
@Get()
getHello() {
return { message: 'public endpoint' };
}
@Get('profile')
getProfile(@Request() req: { user: unknown }) {
return req.user;
}
@Roles(Role.Admin)
@Get('admin-dashboard')
getAdminDashboard() {
return { message: 'admin only' };
}
@Roles(Role.User, Role.Editor)
@Get('user-content')
getUserContent() {
return { message: 'user or editor' };
}
}profile에는 @Roles()가 없지만 전역 JWT 가드 때문에 인증은 필요합니다. user-content는 user나 editor 가운데 하나를 요구하며, 이 예제에는 역할의 계층이 없으므로 admin만 가진 사용자는 저절로 통과하지 않습니다.
Nest RBAC 공개·인증·역할 판정 흐름
public 여부·JWT 인증·handler 우선 roles·exact any-of·handler/401/403에서 마지막 도달 경계가 달라진다.
Nest RBAC · 실제 판정 순서
공개 여부 뒤에 인증과 역할을 순서대로 판정합니다
Public 예외가 없으면 JWT 인증 실패는 401, 인증 뒤 역할 불일치는 403입니다. Roles가 undefined이면 역할 제한만 생략하며 인증은 유지됩니다. admin의 암묵적 역할 상속은 없습니다.
-
@Public()예외를 확인합니다.두 전역 guard가 같은 공개 메타데이터를 먼저 확인하므로, class의 역할 제한이 있어도 공개 handler를 실행합니다.
-
나머지 요청은 JWT로 인증합니다.
token 누락·검증 실패는
401입니다. 성공하면req.user.roles가 준비됩니다. -
Reflector가 역할 메타데이터를 읽습니다.handler 값을 class 기본값보다 먼저 사용합니다.
-
@Roles()가 없으면 인증 전용으로 통과합니다.역할 제한만 생략하며 공개로 바뀌지 않습니다.
-
요구 역할이 있으면 exact any-of로 비교합니다.
하나라도 일치하면 handler를 실행하고, 일치하지 않으면
403입니다.
인증은 현재 사용자를 만들고, 권한 부여는 route 정책과 그 사용자를 비교합니다. admin이 다른 역할을 자동 포함하는 계층은 이 흐름에 없습니다.
이 예의 조건
- 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
- 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
- Public 메타데이터가 있으면 JwtAuthGuard와 RolesGuard가 모두 통과해 handler로 가고, 없으면 JwtAuthGuard가 token을 검증해 실패 시 401로 끝낸다. 인증 성공 뒤 Reflector가 handler 우선으로 Roles 메타데이터를 읽고, 역할 제한이 없으면 인증 전용 handler를 실행하며, 있으면 사용자 역할과 하나라도 정확히 일치할 때 실행하고 아니면 403으로 끝낸다.
- Public이면 두 전역 가드가 공개 예외를 먼저 확인하고 handler로 간다. 나머지는 JWT 인증 실패 시 401로 끝나고 성공 시 Reflector가 handler 우선 역할을 읽는다. 역할 제한 없음은 인증만 필요한 handler로, 역할 있음은 정확한 any-of 비교로 간다. 일치는 handler, 불일치는 403으로 끝난다. 오른쪽 외곽의 두 경로는 공개 예외와 역할 제한 없음이고 왼쪽 외곽의 두 경로는 401과 403 거부다.
관계도의 보충 설명
| 순번 | 원문 표시 |
|---|---|
| 1 | Nest RBAC 공개·인증·역할 판정 흐름 |
| 32 | Public이면 두 전역 가드가 공개 예외를 먼저 확인하고 handler로 간다. 나머지는 JWT 인증 실패 시 401로 끝나고 성공 시 Reflector가 handler 우선 역할을 읽는다. 역할 제한 없음은 인증만 필요한 handler로, 역할 있음은 정확한 any-of 비교로 간다. 일치는 handler, 불일치는 403으로 끝난다. 오른쪽 외곽의 두 경로는 공개 예외와 역할 제한 없음이고 왼쪽 외곽의 두 경로는 401과 403 거부다. |
| 장면 | 관찰 값 | 전환 이유 |
|---|---|---|
| public·JWT 분기 Public이면 두 guard를 통과하고 아니면 JWT를 검증합니다. | Public: 두 guard 통과 · JWT 실패: 401 | 인증 경계의 도달 여부가 정해집니다. |
| handler 우선 역할 Public이 아니고 JWT 인증이 성공한 요청만 Reflector에서 handler 우선 Roles를 읽습니다. | 진입 조건: Public 아님·JWT 인증 성공 · 제한 없음: 역할 비교 생략·인증 전용 handler · 제한 있음: any-of 역할 비교 진행 | 공개 예외와 JWT 실패는 이 역할 조회를 지나지 않으며 인증에 성공한 요청의 정책만 읽습니다. |
| any-of 결과 Public이 아니고 JWT 인증이 성공했으며 Roles 제한이 있는 요청에서만 exact any-of를 판정합니다. | 진입 조건: Public 아님·JWT 성공·Roles 제한 있음 · exact 하나라도 일치: handler · 불일치: 403·handler 미실행 | 역할 제한 없음의 인증 전용 경로와 구별하며 admin의 암묵적 역할 상속은 적용하지 않습니다. |
Nest RBAC 공개·인증·역할 판정 흐름
public 여부·JWT 인증·handler 우선 roles·exact any-of·handler/401/403에서 마지막 도달 경계가 달라진다.
Nest RBAC · 실제 판정 순서
공개 여부 뒤에 인증과 역할을 순서대로 판정합니다
Public 예외가 없으면 JWT 인증 실패는 401, 인증 뒤 역할 불일치는 403입니다. Roles가 undefined이면 역할 제한만 생략하며 인증은 유지됩니다. admin의 암묵적 역할 상속은 없습니다.
-
@Public()예외를 확인합니다.두 전역 guard가 같은 공개 메타데이터를 먼저 확인하므로, class의 역할 제한이 있어도 공개 handler를 실행합니다.
-
나머지 요청은 JWT로 인증합니다.
token 누락·검증 실패는
401입니다. 성공하면req.user.roles가 준비됩니다. -
Reflector가 역할 메타데이터를 읽습니다.handler 값을 class 기본값보다 먼저 사용합니다.
-
@Roles()가 없으면 인증 전용으로 통과합니다.역할 제한만 생략하며 공개로 바뀌지 않습니다.
-
요구 역할이 있으면 exact any-of로 비교합니다.
하나라도 일치하면 handler를 실행하고, 일치하지 않으면
403입니다.
인증은 현재 사용자를 만들고, 권한 부여는 route 정책과 그 사용자를 비교합니다. admin이 다른 역할을 자동 포함하는 계층은 이 흐름에 없습니다.
이 예의 조건
- 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
- 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
- Public 메타데이터가 있으면 JwtAuthGuard와 RolesGuard가 모두 통과해 handler로 가고, 없으면 JwtAuthGuard가 token을 검증해 실패 시 401로 끝낸다. 인증 성공 뒤 Reflector가 handler 우선으로 Roles 메타데이터를 읽고, 역할 제한이 없으면 인증 전용 handler를 실행하며, 있으면 사용자 역할과 하나라도 정확히 일치할 때 실행하고 아니면 403으로 끝낸다.
- Public이면 두 전역 가드가 공개 예외를 먼저 확인하고 handler로 간다. 나머지는 JWT 인증 실패 시 401로 끝나고 성공 시 Reflector가 handler 우선 역할을 읽는다. 역할 제한 없음은 인증만 필요한 handler로, 역할 있음은 정확한 any-of 비교로 간다. 일치는 handler, 불일치는 403으로 끝난다. 오른쪽 외곽의 두 경로는 공개 예외와 역할 제한 없음이고 왼쪽 외곽의 두 경로는 401과 403 거부다.
관계도의 보충 설명
| 순번 | 원문 표시 |
|---|---|
| 1 | Nest RBAC 공개·인증·역할 판정 흐름 |
| 32 | Public이면 두 전역 가드가 공개 예외를 먼저 확인하고 handler로 간다. 나머지는 JWT 인증 실패 시 401로 끝나고 성공 시 Reflector가 handler 우선 역할을 읽는다. 역할 제한 없음은 인증만 필요한 handler로, 역할 있음은 정확한 any-of 비교로 간다. 일치는 handler, 불일치는 403으로 끝난다. 오른쪽 외곽의 두 경로는 공개 예외와 역할 제한 없음이고 왼쪽 외곽의 두 경로는 401과 403 거부다. |
| 장면 | 관찰 값 | 전환 이유 |
|---|---|---|
| public·JWT 분기 Public이면 두 guard를 통과하고 아니면 JWT를 검증합니다. | Public: 두 guard 통과 · JWT 실패: 401 | 인증 경계의 도달 여부가 정해집니다. |
| handler 우선 역할 Public이 아니고 JWT 인증이 성공한 요청만 Reflector에서 handler 우선 Roles를 읽습니다. | 진입 조건: Public 아님·JWT 인증 성공 · 제한 없음: 역할 비교 생략·인증 전용 handler · 제한 있음: any-of 역할 비교 진행 | 공개 예외와 JWT 실패는 이 역할 조회를 지나지 않으며 인증에 성공한 요청의 정책만 읽습니다. |
| any-of 결과 Public이 아니고 JWT 인증이 성공했으며 Roles 제한이 있는 요청에서만 exact any-of를 판정합니다. | 진입 조건: Public 아님·JWT 성공·Roles 제한 있음 · exact 하나라도 일치: handler · 불일치: 403·handler 미실행 | 역할 제한 없음의 인증 전용 경로와 구별하며 admin의 암묵적 역할 상속은 적용하지 않습니다. |
그림은 요청 하나가 판정되는 순서입니다. @Public()이면 두 전역 가드가 공개 예외를 먼저 확인하고 핸들러로 가고, 아니면 JWT 인증에 실패할 때 401로 끝납니다. 인증에 성공하면 Reflector가 핸들러를 우선해 요구 역할을 읽고, 역할 제한이 없으면 인증만 필요한 핸들러를, 있으면 사용자의 역할과 하나라도 정확히 일치할 때 핸들러를 실행하며 아니면 403으로 끝냅니다.
공개·인증·역할의 테스트
같은 경로의 성공 사례만 확인하지 말고, 공개, 인증 실패, 인가 실패를 나눠 검증합니다. 아래 표는 접두사 없이 적었으므로, api 접두사를 유지했다면 /는 /api, /profile은 /api/profile처럼 읽습니다. 이 AppController의 /profile과 앞 글 AuthController의 /auth/profile은 서로 다른 경로입니다.
| 요청 | 사용자 상태 | 예상 결과 |
|---|---|---|
GET / | 토큰 없음 | 200, @Public() |
클래스에 @Roles(Role.Admin), 핸들러에 @Public() | 토큰 없음 | 200, 공개 정책이 물려받은 역할 제한보다 우선 |
GET /profile | 토큰이 없거나 유효하지 않음 | 401 |
GET /profile | 유효한 user 토큰 | 200, 인증 전용 |
GET /admin-dashboard | user 역할 | 403 |
GET /admin-dashboard | admin 역할 | 200 |
GET /user-content | admin 역할만 있음 | 403, 계층 없음 |
역할 이름을 바꿀 때는 enum만 고치고 끝내지 않습니다. 사용자 데이터와 초기 데이터, 관리 화면, 발급 로직, 경로의 메타데이터, 기존 JWT에 담긴 역할을 최신 상태와 맞추는 정책, 허용·401·403 테스트를 같은 변경 단위로 검토합니다. 리소스별 행위나 소유권 조건까지 세밀하게 다뤄야 한다면, 역할 문자열을 계속 늘리기보다 권한 클레임이나 정책 기반 권한 부여로 넓힙니다.
관련 공식 문서는 Execution context(Reflector, getAllAndOverride(), getAllAndMerge()와 역할 메타데이터를 읽는 RolesGuard 예제), Request lifecycle(전역·컨트롤러·경로 가드의 실행 순서), Passport 레시피(Passport JWT 가드의 전역 등록과 @Public())입니다. NestJS 12 문서의 Authentication과 Authorization은 이 글의 Passport 가드와 RolesGuard 대신 @nestjs/authentication의 전역 가드와 @nestjs/authorization의 정책 클래스로 설명합니다.
더 깊이: HTTP 명세가 정한 401과 403
RFC 9110은 401 응답을 보내는 서버가 그 리소스에 쓸 수 있는 인증 방식을 하나 이상 담은 WWW-Authenticate 헤더를 반드시 보내야 한다고 정합니다. Bearer 토큰의 사용법을 정한 RFC 6750도 토큰이 없거나 접근할 수 없는 토큰으로 보호된 리소스를 요청하면, 리소스 서버가 Bearer 방식의 WWW-Authenticate 헤더를 넣어야 한다고 말합니다. 그런데 @nestjs/passport의 기본 handleRequest()는 인증에 실패하면 UnauthorizedException만 던지고, Nest의 기본 예외 처리는 상태 코드와 본문만 보냅니다. 그래서 이 글의 401 응답에는 이 헤더가 없으며, 명세를 따르는 클라이언트와 맞춰야 한다면 예외 필터 등에서 헤더를 직접 더해야 합니다.
403은 서버가 요청을 이해했지만 수행을 거부한다는 뜻입니다. 요청에 인증 정보가 있었다면 서버가 그것으로는 부족하다고 판단한 것이므로, 명세는 클라이언트가 같은 인증 정보로 요청을 자동으로 다시 보내지 않아야 한다고 정합니다. 또 금지된 리소스가 있다는 사실 자체를 숨기고 싶은 서버는 403 대신 404를 보내도 됩니다. 관리자 경로의 존재나 다른 사용자의 리소스 ID가 드러나는 것이 문제라면, RolesGuard가 false를 돌려주는 대신 NotFoundException을 던지는 정책을 고를 수 있습니다.
정리
- 인증 실패는 401, 인증은 됐지만 역할이 부족하면 403이며, 두 판정은 서로 다른 가드가 차례로 맡습니다.
- 전역
JwtAuthGuard로 모든 경로를 기본 보호하고,@Public()으로 명시한 경로만 익명 접근을 허용합니다. @Roles()가 없으면 인증 전용이고, 있으면 요구 역할 가운데 하나와 정확히 일치해야 하며, 역할의 계층은 저절로 생기지 않습니다.getAllAndOverride()는 핸들러의 메타데이터를 클래스보다 먼저 고르고, JWT의 역할은 발급 시점의 스냅샷입니다.
연습 문제
1. 물려받은 정책 계산하기
컨트롤러 클래스에는 @Roles(Role.Admin)이, 특정 핸들러에는 @Roles(Role.User, Role.Editor)만 있습니다. 전역 가드는 본문의 순서로 등록되어 있고, 유효한 editor 토큰을 보냅니다. 이 핸들러를 실행할 수 있나요? 같은 핸들러의 역할 선언을 지우면 결과가 어떻게 바뀌나요? 다른 핸들러에 @Public()만 붙이고 토큰 없이 요청하는 경우도 따로 판단하세요.
해설 보기
첫 핸들러의 요구 배열은 클래스의 배열을 대체하므로, editor가 조건과 일치해 실행됩니다. 핸들러의 선언을 지우면 클래스의 admin이 기본값이 되어, editor만 가진 인증된 사용자는 403으로 거부됩니다.
다른 공개 핸들러는 두 전역 가드가 공개 메타데이터를 먼저 확인하므로 토큰 없이도 통과합니다. 반면 공개 선언이 없는 경로에서 역할 메타데이터를 지우는 것만으로 JWT 인증이 생략되지는 않습니다.
2. 가드의 등록 순서
AuthModule의 providers에서 RolesGuard를 JwtAuthGuard보다 먼저 APP_GUARD로 등록했습니다. 토큰 없이 GET /admin-dashboard를 요청하면 어떤 응답이 나오나요? 원래 순서와 비교해 무엇이 달라지나요?
해설 보기
RolesGuard가 먼저 실행되는데, 아직 인증이 일어나지 않아 req.user가 없으므로 user !== undefined가 거짓이 되어 false를 돌려줍니다. 그래서 응답은 403입니다.
원래 순서였다면 JwtAuthGuard가 토큰이 없다는 이유로 먼저 401을 냈을 것입니다. 토큰이 없는 요청에 403을 주면 클라이언트는 로그인이 필요하다는 사실을 알 수 없으므로, 인증 가드를 역할 가드보다 앞에 둬야 401과 403의 의미가 지켜집니다.
3. 로그인 경로의 공개 선언을 빠뜨리면
전역 가드를 등록한 뒤, login 메서드에 @Public()을 붙이는 것을 잊었습니다. 올바른 아이디와 비밀번호로 토큰 없이 로그인을 요청하면 어떻게 되나요? LocalAuthGuard는 실행되나요?
해설 보기
전역 가드가 경로 가드보다 먼저 실행되므로, JwtAuthGuard가 Bearer 토큰이 없다는 이유로 401을 내고 요청을 멈춥니다. 그래서 LocalAuthGuard는 실행되지 않고, 자격 증명이 맞는지 검사할 기회도 없습니다.
결과적으로 누구도 첫 토큰을 받을 수 없게 됩니다. 기본 보호 정책을 켤 때는 로그인처럼 토큰 없이 들어와야 하는 경로를 먼저 찾아 @Public()을 붙이고, 그 경로에도 별도의 인증 가드가 붙어 있는지 확인합니다.
다음 글 OAuth 2.0과 OIDC 소셜 로그인에서는 비밀번호 대신 외부 제공자의 계정으로 로그인하는 흐름을 연결합니다.