세션 방식으로 로그인을 구현하기 전에, 클라이언트의 요청이 처음에는 컨트롤러 내에서 Guard를 만나게 된다는 흐름을 파악하려고 다양한 글을 읽었습니다. Guard는 실제 검증 처리를 담당하는 Strategy와 연결되어, 최종적으로 검증을 통과하면 사용자 정보 직렬화가 이루어집니다. 이후 요청이 들어오면 세션 ID를 확인하고, 그에 맞춰 역직렬화하여 사용자 정보를 반환합니다.
Guard에 대한 명확한 이해가 있어야 향후 과제를 잘 수행할 수 있다는 생각이 들었습니다. 예전에는 공식 문서를 읽는 것이 싫었지만, 이제는 공식 문서를 읽는 것이 꽤 즐겁습니다. 비록 어려울 때도 있지만, 공식 문서는 거짓말을 하지 않으므로, 시간이 걸리더라도 가장 빠른 길임을 깨닫고 있습니다.
가드는 @Injectable() 데코레이터가 적용된 클래스이며, CanActivate 인터페이스를 구현합니다.

가드는 웹 애플리케이션에서 특정 요청이 처리될 수 있는지를 결정하는 역할을 하는 클래스입니다. 예를 들어, 사용자가 특정 페이지에 접근하려 할 때, 그 사용자가 권한이 있는지 확인하는 일을 합니다. 가드는 인증된 사용자만 접근할 수 있도록 하거나, 사용자가 특정 조건을 만족할 때만 요청을 허용하는 방식으로 동작합니다.
간단히 말해, 가드는 해당 요청을 허락할지 말지 결정하는 문지기라고 생각하면 됩니다.
코드를 먼저 보겠습니다.
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
import { Observable } from 'rxjs';
@Injectable()
export class AuthGuard implements CanActivate {
canActivate(
context: ExecutionContext,
): boolean | Promise<boolean> | Observable<boolean> {
const request = context.switchToHttp().getRequest();
return validateRequest(request);
}
}
canActivate() 메서드는 요청이 허용될지 여부를 판단하게 됩니다.
현재 요청에 대한 정보가 담긴 ExecutionContext 객체를 전달받는 모습을 확인할 수 있습니다. 요청 객체를 switchToHttp() 메서드를 통해, 현재 요청이 HTTP 요청임을 NestJS에게 알려줍니다. NestJS는 WebSocket, RPC 등 다양한 요청 타입을 지원하기 때문에 명시하는 것입니다. 추가적으로, getRequest() 메서드를 통해 실제 request data를 꺼내옵니다.
validationRequest()라는, 사전에 정의해놓은 함수에 request를 전달하고, validation에 대한 결과를 반환합니다.
canActivate() 함수는 하나의 인자, 즉 ExecutionContext 인스턴스를 받습니다. ExecutionContext는 ArgumentsHost를 상속합니다.
ArgumentsHost는 Exception filters 챕터에서 이미 본 적이 있습니다. 반복적으로 등장하는 개념이니 중요한 개념일 가능성이 높습니다. ArgumentsHost에 관해서는 차후에 더 깊게 다루도록 하겠습니다.
특정한 역할을 가진 사용자만 접근할 수 있도록 허용하는 가드입니다.
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
import { Observable } from 'rxjs';
@Injectable()
export class RolesGuard implements CanActivate {
canActivate(
context: ExecutionContext,
): boolean | Promise<boolean> | Observable<boolean> {
return true;
}
}
특별히 다른 부분은 없습니다. 현재 실행 컨텍스트를 받아서 true를 반환하는 가드입니다. 현재는 모든 요청을 무조건 허용한다는 사실을 파악할 수 있습니다.
파이프(pipes)와 예외 필터(exception filters)처럼, 가드(guards)도 컨트롤러 범위(controller-scoped), 메서드 범위(method-scoped), 또는 글로벌 범위(global-scoped)로 설정할 수 있습니다.
@Controller('cats')
@UseGuards(RolesGuard)
export class CatsController {}
RolesGuard 클래스 자체를 넘기는 모습을 확인할 수 있습니다.
컨트롤러 레벨로 가드가 적용되는데, 이때 NestJS가 RolesGuard를 인스턴스화하기 때문에 생성자에 주입된 의존성(예: 서비스들)을 사용할 수 있습니다.
@Controller('cats')
@UseGuards(new RolesGuard())
export class CatsController {}
위 코드 역시 컨트롤러 레벨로 가드가 적용됩니다. 다만, 인스턴스를 직접 생성한 뒤 넘기는 방식이기에 의존성 주입이 불가능합니다. 간단한, 외부 의존성이 없는 가드일 때만 적합하다고 볼 수 있습니다.
const app = await NestFactory.create(AppModule);
app.useGlobalGuards(new RolesGuard());
위 코드는, useGlobalGuards()를 통해 애플리케이션 전체에 RolesGuard를 적용합니다. 역시, 인스턴스를 직접 생성한 뒤 넘기는 방식이므로 의존성 주입이 불가능합니다.
import { Module } from '@nestjs/common';
import { APP_GUARD } from '@nestjs/core';
@Module({
providers: [
{
provide: APP_GUARD,
useClass: RolesGuard,
},
],
})
export class AppModule {}
위와 같은 구문을 사용하여 가드를 직접 모듈에서 설정할 수도 있습니다. Exception filter에서도 비슷한 구조를 봤던 것으로 기억합니다. 모듈 단위의 가드 설정 방법이라고 이해하면 되겠습니다.
지금은 RolesGuard가 작동하고 있지만, 어떤 경로(route)에 어떤 역할(role)이 필요한 지 알아내는 데 문제가 있습니다.
예를 들어, CatsController라는 경로에서 "관리자만 접근 가능"한 경로가 있을 수 있고, "모든 사용자에게 열려 있는" 경로도 있을 수 있는데, 현재 RolesGuard는 이런 정보를 알지 못하고 있습니다.
이를 해결하려면 메타데이터(metadata)를 사용해야 합니다. 메타데이터는 "이 경로에는 어떤 역할이 필요한지"와 같은 정보를 코드에 추가하는 방법입니다. 예를 들어, @Roles()라는 데코레이터를 사용해서 특정 경로에 "관리자만" 접근할 수 있다는 정보를 추가할 수 있습니다.
NestJS에서는 Reflector.createDecorator라는 메서드를 이용해 이러한 데코레이터를 만들 수 있고, 이 메타데이터를 통해, 가드가 어떤 역할을 가진 사용자가 특정 경로에 접근할 수 있는지 알게 됩니다.
import { Reflector } from '@nestjs/core';
export const Roles = Reflector.createDecorator<string[]>();
여기서 Roles 데코레이터는 string[] 타입의 단일 인수를 받는 함수입니다.
@Post()
@Roles(['admin'])
async create(@Body() createCatDto: CreateCatDto) {
this.catsService.create(createCatDto);
}
create() 메서드에 Roles 데코레이터 메타데이터를 첨부하여, 오직 "admin" 역할을 가진 사용자만 해당 경로에 접근할 수 있도록 표시했습니다.
Reflector.createDecorator 메서드를 사용하는 대신, 내장된 @SetMetadata() 데코레이터를 사용할 수도 있습니다. 자세한 내용은 차후에 알아보도록 하겠습니다.
RolesGuard와 위 내용을 연결해 보겠습니다.
현재 RolesGuard는 모든 경우에 대해 true를 반환하여 모든 요청이 진행되도록 하고 있습니다. 현재 사용자의 역할을 현재 처리 중인 경로에서 요구하는 역할과 비교하여 반환값을 조건부로 만들고자 합니다.
메타데이터에 접근하려면, 다시 한번 Reflector 헬퍼 클래스를 사용해야 합니다.
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { Roles } from './roles.decorator';
@Injectable()
export class RolesGuard implements CanActivate {
constructor(private reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const roles = this.reflector.get(Roles, context.getHandler());
if (!roles) {
return true;
}
const request = context.switchToHttp().getRequest();
const user = request.user;
return matchRoles(roles, user.roles);
}
}
권한이 없는 사용자가 엔드포인트에 대해 요청하면, NestJS는 자동으로 다음과 같은 응답을 반환합니다.
{
"statusCode": 403,
"message": "Forbidden resource",
"error": "Forbidden"
}
가드가 false를 반환할 때, NestJS는 ForbiddenException을 던집니다. 만약 다른 오류 응답을 반환하고 싶다면, 자신만의 특정 exception을 던져야 합니다.
throw new UnauthorizedException();
이 지점에서 다시 exceptions filter 개념과 이어지게 됩니다.
Guard는 이해했으니, 이제 Strategy와 Serialization에 대해 학습을 진행하면 되겠습니다.
어떤 날은 일이 술술 풀리고, 다른 날은 답답하게 느껴지기도 합니다. 운에 의존하는 부분이 크다는 생각이 들기도 합니다. 소위 운빨이라면 한 번 해보는 것이고, 운빨이 아니라면 계속 시도하면 언젠가는 해결될 것입니다. 내가 좋아하는 일을 계속하는 데 논리적으로 그만둘 이유는 없다고 생각합니다. 물론, 타인의 욕망을 욕망한다면 얘기가 다르겠지만요. 갑자기 이런 생각이 들었습니다.