icon
4장 : 인증과 권한 부여

역할 기반 접근 제어 (RBAC) 구현


안녕하세요! 지난 절에서 JWT 기반의 인증 시스템을 구축하여 사용자의 신원을 확인하는 방법을 알아보았습니다. 이제 인증된 사용자가 특정 리소스나 기능에 접근할 수 있는지 여부를 결정하는 중요한 과정인 권한 부여(Authorization), 그중에서도 가장 널리 사용되는 방식인 역할 기반 접근 제어(Role-Based Access Control, RBAC) 를 NestJS에 구현하는 방법을 살펴보겠습니다.

RBAC는 사용자에게 직접 권한을 부여하는 대신, 특정 역할(Role) 을 부여하고 그 역할에 미리 정의된 권한들을 할당하는 방식입니다. 예를 들어, "관리자" 역할은 "게시물 생성, 조회, 수정, 삭제" 권한을 가질 수 있고, "일반 사용자" 역할은 "게시물 조회, 생성" 권한만 가질 수 있습니다. 이렇게 하면 개별 사용자마다 일일이 권한을 설정할 필요 없이, 역할만 부여함으로써 권한 관리가 훨씬 효율적이고 유연해집니다.


역할 기반 접근 제어(RBAC)란?

RBAC의 핵심 개념은 다음과 같습니다.

  • 사용자(User): 시스템을 이용하는 개체입니다. (예: John, Jane)
  • 역할(Role): 특정 직무 또는 직책에 해당하는 권한들의 집합입니다. (예: Admin, Editor, Viewer)
  • 권한(Permission): 특정 리소스에 대한 특정 행위를 허용하거나 금지하는 가장 기본적인 단위입니다. (예: create:post, read:post, update:post, delete:post)

RBAC는 User -> Role -> Permission 구조를 가 가집니다. 사용자는 하나 이상의 역할을 가질 수 있고, 각 역할은 하나 이상의 권한을 가집니다.

RBAC의 장점

  • 관리의 용이성: 사용자 수가 많아져도 권한 관리가 용이합니다. 사용자별 권한을 일일이 설정하는 대신 역할만 부여하면 됩니다.
  • 유연성: 새로운 역할을 추가하거나 기존 역할의 권한을 변경하기 쉽습니다.
  • 보안성: 권한이 명확하게 정의되고 분리되어 보안 취약점을 줄일 수 있습니다.
  • 가독성: 코드 상에서 어떤 역할이 어떤 작업을 수행할 수 있는지 쉽게 파악할 수 있습니다.

NestJS에서 RBAC 구현하기

NestJS에서 RBAC를 구현하는 가장 효율적인 방법은 가드(Guards)커스텀 데코레이터(Custom Decorators) 를 조합하는 것입니다.

  • 커스텀 데코레이터 (@Roles()): 라우트 핸들러(컨트롤러 메서드)나 컨트롤러 클래스에 필요한 역할을 메타데이터로 첨부합니다.
  • 가드 (RolesGuard): 이 메타데이터를 읽어서 현재 인증된 사용자가 해당 역할을 가지고 있는지 확인합니다.

단계 1: ConfigModuleJwtModule 설정 확인

이전 절에서 AuthModuleConfigModule을 임포트하고 JwtModule.registerAsync에서 ConfigService를 사용하여 JWT_SECRET을 가져왔습니다. RBAC를 위해서는 사용자의 roles 정보가 JWT 페이로드에 포함되어야 합니다.

auth.service.ts에서 JWT 페이로드에 roles 추가 확인

src/auth/auth.service.ts (변경 없음, 이전 절에서 이미 포함)
import { Injectable } from '@nestjs/common';
import { UsersService } from '../users/users.service';
import { JwtService } from '@nestjs/jwt';

@Injectable()
export class AuthService {
  constructor(
    private usersService: UsersService,
    private jwtService: JwtService,
  ) {}

  async validateUser(username: string, pass: string): Promise<any> {
    const user = await this.usersService.findOne(username);
    if (user && user.password === pass) {
      const { password, ...result } = user;
      return result;
    }
    return null;
  }

  async login(user: any) {
    // JWT 페이로드에 'roles' 포함
    const payload = { username: user.username, sub: user.userId, roles: user.roles };
    return {
      access_token: this.jwtService.sign(payload),
    };
  }
}

jwt.strategy.ts에서 페이로드에서 roles 추출 확인

src/auth/strategies/jwt.strategy.ts (변경 없음, 이전 절에서 이미 포함)
import { ExtractJwt, Strategy } from 'passport-jwt';
import { PassportStrategy } from '@nestjs/passport';
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';

@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
  constructor(private configService: ConfigService) {
    super({
      jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
      ignoreExpiration: false,
      secretOrKey: configService.get<string>('JWT_SECRET'),
    });
  }

  async validate(payload: any) {
    // 반환되는 객체는 req.user에 주입됩니다. roles가 포함되어야 합니다.
    return { userId: payload.sub, username: payload.username, roles: payload.roles };
  }
}

단계 2: 커스텀 데코레이터 @Roles() 생성

라우트 핸들러에 필요한 역할을 메타데이터로 첨부하기 위한 데코레이터를 만듭니다.

src/common/decorators/roles.decorator.ts
import { SetMetadata } from '@nestjs/common';

export const ROLES_KEY = 'roles'; // 메타데이터 키를 상수로 정의하여 오타 방지 및 재사용성 향상
export const Roles = (...roles: string[]) => SetMetadata(ROLES_KEY, roles);
  • SetMetadata(key, value): NestJS에서 메타데이터를 클래스나 메서드에 첨부할 때 사용하는 함수입니다. ROLES_KEY는 메타데이터를 저장할 키이며, roles 배열은 해당 키에 저장될 값입니다.

단계 3: 권한 부여 가드 (RolesGuard) 생성

RolesGuardROLES_KEY로 첨부된 메타데이터를 읽어 현재 사용자의 역할과 비교하여 접근을 허용하거나 거부합니다.

src/auth/guards/roles.guard.ts
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
import { Reflector } from '@nestjs/core'; // 메타데이터를 읽기 위한 Reflector 임포트

@Injectable()
export class RolesGuard implements CanActivate {
  constructor(private reflector: Reflector) {} // Reflector 주입

  canActivate(context: ExecutionContext): boolean {
    // 1. @Roles() 데코레이터로부터 필요한 역할(roles) 메타데이터를 가져옵니다.
    // getAllAndOverride는 현재 핸들러(메서드)에 붙은 메타데이터가 있으면 그것을, 없으면 클래스에 붙은 메타데이터를 가져옵니다.
    const requiredRoles = this.reflector.getAllAndOverride<string[]>(ROLES_KEY, [
      context.getHandler(), // 메서드 레벨
      context.getClass(),   // 클래스 레벨
    ]);

    // 2. 만약 @Roles() 데코레이터가 붙어있지 않다면 (requiredRoles가 undefined),
    // 해당 라우트는 역할 제한이 없으므로 접근을 허용합니다.
    if (!requiredRoles) {
      return true;
    }

    // 3. 현재 요청의 사용자 객체를 가져옵니다.
    // 이 user 객체는 JwtStrategy.validate()에서 반환되어 req.user에 주입된 것입니다.
    const { user } = context.switchToHttp().getRequest();

    // 4. 사용자의 역할(user.roles)이 필요한 역할(requiredRoles) 중 하나라도 포함하는지 확인합니다.
    // 예를 들어, user.roles = ['user', 'admin'], requiredRoles = ['admin'] 이면 true
    // user.roles = ['user'], requiredRoles = ['admin'] 이면 false
    return requiredRoles.some((role) => user.roles.includes(role));
  }
}
  • Reflector: NestJS의 코어 유틸리티로, @SetMetadata()@Roles()와 같은 데코레이터를 통해 저장된 메타데이터를 읽을 때 사용합니다.
  • getAllAndOverride(): 메서드 레벨과 클래스 레벨에 동일한 메타데이터 키가 있을 때, 메서드 레벨의 메타데이터가 우선하도록 합니다.
  • user.roles.includes(role): 실제 사용자 객체에 roles 배열이 있다고 가정하고, 필요한 역할 중 하나라도 사용자가 가지고 있는지 확인합니다.

단계 4: AuthModuleRolesGuard 등록 (옵션)

RolesGuard는 특정 컨트롤러나 메서드에 @UseGuards(RolesGuard) 데코레이터로 적용할 수 있습니다. 하지만 모든 라우트에 항상 가드를 적용해야 한다면 전역 가드로 등록할 수도 있습니다. 일반적으로는 필요한 곳에만 명시적으로 적용하는 것이 권장됩니다.

src/auth/auth.module.ts (AuthModule 업데이트)
import { Module } from '@nestjs/common';
import { AuthService } from './auth.service';
import { LocalStrategy } from './strategies/local.strategy';
import { JwtStrategy } from './strategies/jwt.strategy';
import { UsersModule } from '../users/users.module';
import { PassportModule } from '@nestjs/passport';
import { JwtModule } from '@nestjs/jwt';
import { AuthController } from './auth.controller';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { APP_GUARD } from '@nestjs/core'; // 전역 가드 등록을 위해 필요
import { RolesGuard } from './guards/roles.guard'; // RolesGuard 임포트

@Module({
  imports: [
    UsersModule,
    PassportModule,
    ConfigModule,
    JwtModule.registerAsync({
      imports: [ConfigModule],
      useFactory: async (configService: ConfigService) => ({
        secret: configService.get<string>('JWT_SECRET'),
        signOptions: { expiresIn: '600s' }, // 테스트를 위해 10분으로 늘림
      }),
      inject: [ConfigService],
    }),
  ],
  providers: [
    AuthService,
    LocalStrategy,
    JwtStrategy,
    // { // 이 부분을 주석 처리하면 개별 라우트에 @UseGuards로 명시적으로 적용해야 합니다.
    //   provide: APP_GUARD, // 전역 가드로 등록 (필요에 따라 주석 해제)
    //   useClass: RolesGuard,
    // },
  ],
  controllers: [AuthController],
  exports: [AuthService],
})
export class AuthModule {}

단계 5: 컨트롤러에서 RolesGuard@Roles() 데코레이터 사용

src/app.controller.ts (업데이트)
import { Controller, Get, UseGuards, Request } from '@nestjs/common';
import { AppService } from './app.service';
import { AuthGuard } from '@nestjs/passport'; // JWT 인증 가드
import { RolesGuard } from './auth/guards/roles.guard'; // 권한 부여 가드
import { Roles } from './common/decorators/roles.decorator'; // 커스텀 데코레이터

@Controller()
export class AppController {
  constructor(private readonly appService: AppService) {}

  @Get()
  getHello(): string {
    return this.appService.getHello();
  }

  // JWT 인증만 필요한 라우트 (누구나 로그인하면 접근 가능)
  @UseGuards(AuthGuard('jwt'))
  @Get('profile')
  getProfile(@Request() req) {
    return req.user; // JwtStrategy에서 반환된 사용자 정보 (roles 포함)
  }

  // 'admin' 역할만 접근 가능한 라우트
  @UseGuards(AuthGuard('jwt'), RolesGuard) // JWT 인증 후 RolesGuard로 권한 확인
  @Roles('admin') // 이 라우트에 접근하려면 'admin' 역할을 가지고 있어야 합니다.
  @Get('admin-dashboard')
  getAdminDashboard(@Request() req) {
    return `Welcome, ${req.user.username}! You are an admin and can access this dashboard.`;
  }

  // 'user' 또는 'editor' 역할만 접근 가능한 라우트
  @UseGuards(AuthGuard('jwt'), RolesGuard)
  @Roles('user', 'editor') // 이 라우트에 접근하려면 'user' 또는 'editor' 역할 중 하나를 가지고 있어야 합니다.
  @Get('user-content')
  getUserContent(@Request() req) {
    return `Hello, ${req.user.username}! You can view user content.`;
  }
}
  • @UseGuards(AuthGuard('jwt'), RolesGuard): 여러 가드를 사용할 경우, 배열로 전달하면 순서대로 실행됩니다. 먼저 AuthGuard('jwt')가 JWT를 검증하고 req.user에 사용자 정보를 주입한 후, RolesGuard가 실행되어 req.user.roles를 기반으로 권한을 확인합니다.

RBAC 시스템 테스트해보기

애플리케이션을 실행합니다. npm run start:dev

testuser (role: user)로 로그인하여 JWT 획득

  • POST http://localhost:3000/auth/login
  • Body: {"username": "testuser", "password": "password123"}
  • 응답에서 access_token을 복사합니다.

testuseradmin-dashboard 접근 시도

  • GET http://localhost:3000/admin-dashboard
  • Headers: Authorization: Bearer <testuser의 access_token>
  • 결과: 403 Forbidden 응답 (RolesGuard가 접근을 거부함).

testuseruser-content 접근 시도

  • GET http://localhost:3000/user-content
  • Headers: Authorization: Bearer <testuser의 access_token>
  • 결과: 200 OK 응답 (testuseruser 역할을 가지고 있으므로 접근 허용됨).

admin (role: admin)으로 로그인하여 JWT 획득

  • POST http://localhost:3000/auth/login
  • Body: {"username": "admin", "password": "adminpassword"}
  • 응답에서 access_token을 복사합니다.

admin으로 admin-dashboard 접근 시도

  • GET http://localhost:3000/admin-dashboard
  • Headers: Authorization: Bearer <admin의 access_token>
  • 결과: 200 OK 응답 (adminadmin 역할을 가지고 있으므로 접근 허용됨).

admin으로 user-content 접근 시도

  • GET http://localhost:3000/user-content
  • Headers: Authorization: Bearer <admin의 access_token>
  • 결과: 200 OK 응답 (adminadmin 역할을 가지고 있지만, 예시 RolesGuardsome을 사용하므로 usereditor 역할이 아니더라도 admin 역할이므로 접근이 허용됩니다. 만약 roles가 정확히 일치해야 한다면 every나 다른 로직을 사용해야 합니다.)

NestJS의 가드와 커스텀 데코레이터를 활용하면 역할 기반 접근 제어(RBAC)를 매우 깔끔하고 효율적으로 구현할 수 있습니다. 이는 복잡한 권한 관리 로직을 비즈니스 로직과 분리하여 코드의 가독성과 유지보수성을 크게 향상시킵니다.