NestJS Guard 인가 설계 심화

NestJS Guard란?

NestJS Guard는 CanActivate 인터페이스를 구현해 요청의 인가(Authorization)를 제어하는 핵심 컴포넌트다. Express의 미들웨어와 달리, Guard는 ExecutionContext에 접근할 수 있어 어떤 핸들러가 실행될지 정확히 알고 있다. 이 차이가 Guard를 미들웨어보다 강력하게 만든다.

Guard는 NestJS 요청 라이프사이클에서 미들웨어 → Guard → Interceptor(before) → Pipe → Handler → Interceptor(after) → Exception Filter 순서로 실행된다. 즉, Pipe나 Handler에 도달하기 전에 요청을 차단할 수 있다.

CanActivate 기본 구현

Guard의 핵심은 canActivate() 메서드다. true를 반환하면 요청이 계속 진행되고, false를 반환하면 ForbiddenException이 발생한다.

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();
    const token = request.headers.authorization?.replace('Bearer ', '');
    
    if (!token) return false;
    
    // 토큰 검증 로직
    return this.validateToken(token);
  }

  private async validateToken(token: string): Promise<boolean> {
    // JWT 검증 등
    return true;
  }
}

반환 타입이 boolean | Promise<boolean> | Observable<boolean>이므로 동기·비동기 모두 지원한다. DB 조회나 외부 API 호출이 필요한 인가 로직도 자연스럽게 처리할 수 있다.

ExecutionContext 활용

ExecutionContextArgumentsHost를 확장한 클래스로, 현재 실행 중인 클래스와 핸들러 정보를 제공한다. 이를 통해 메타데이터 기반 인가가 가능하다.

// ExecutionContext 핵심 메서드
const controller = context.getClass();    // 컨트롤러 클래스
const handler = context.getHandler();      // 핸들러 메서드
const type = context.getType();            // 'http' | 'ws' | 'rpc'

// HTTP 요청 객체 접근
const request = context.switchToHttp().getRequest();
const response = context.switchToHttp().getResponse();

// WebSocket 컨텍스트
const client = context.switchToWs().getClient();
const data = context.switchToWs().getData();

getClass()getHandler()를 Reflector와 함께 사용하면, 커스텀 데코레이터로 설정한 메타데이터를 읽어 유연한 인가 로직을 구현할 수 있다.

역할 기반 접근 제어 (RBAC)

실무에서 가장 흔한 패턴인 Role-Based Access Control을 Guard로 구현해보자. 커스텀 데코레이터와 Reflector를 조합한다.

1단계: 역할 데코레이터 정의

import { SetMetadata } from '@nestjs/common';

export enum Role {
  USER = 'user',
  ADMIN = 'admin',
  SUPER_ADMIN = 'super_admin',
}

export const ROLES_KEY = 'roles';
export const Roles = (...roles: Role[]) => SetMetadata(ROLES_KEY, roles);

2단계: RolesGuard 구현

import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { Role, ROLES_KEY } from './roles.decorator';

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

  canActivate(context: ExecutionContext): boolean {
    // 핸들러와 클래스 양쪽에서 메타데이터 병합
    const requiredRoles = this.reflector.getAllAndOverride<Role[]>(
      ROLES_KEY,
      [context.getHandler(), context.getClass()],
    );

    // 역할 메타데이터가 없으면 공개 엔드포인트
    if (!requiredRoles) return true;

    const { user } = context.switchToHttp().getRequest();
    return requiredRoles.some((role) => user.roles?.includes(role));
  }
}

3단계: 컨트롤러에 적용

@Controller('users')
@UseGuards(AuthGuard, RolesGuard)
export class UsersController {
  @Get()
  @Roles(Role.ADMIN)
  findAll() {
    return this.usersService.findAll();
  }

  @Get(':id')
  @Roles(Role.USER, Role.ADMIN)
  findOne(@Param('id') id: string) {
    return this.usersService.findOne(id);
  }

  @Delete(':id')
  @Roles(Role.SUPER_ADMIN)
  remove(@Param('id') id: string) {
    return this.usersService.remove(id);
  }
}

Reflector.getAllAndOverride()는 핸들러 → 클래스 순서로 메타데이터를 찾아 첫 번째 값을 반환한다. 반면 getAllAndMerge()는 양쪽 메타데이터를 배열로 합친다. 상황에 따라 적절한 메서드를 선택하자.

Guard 적용 범위: 메서드 → 컨트롤러 → 글로벌

Guard는 세 가지 범위로 적용할 수 있다.

// 1. 메서드 레벨
@UseGuards(AuthGuard)
@Get('profile')
getProfile() {}

// 2. 컨트롤러 레벨 — 모든 핸들러에 적용
@UseGuards(AuthGuard)
@Controller('users')
export class UsersController {}

// 3. 글로벌 레벨 — 모든 라우트에 적용
// main.ts
app.useGlobalGuards(new AuthGuard());

// 또는 모듈에서 (DI 지원)
@Module({
  providers: [
    { provide: APP_GUARD, useClass: AuthGuard },
    { provide: APP_GUARD, useClass: RolesGuard },
  ],
})
export class AppModule {}

주의: app.useGlobalGuards()는 DI 컨테이너 외부에서 등록되므로 의존성 주입이 불가능하다. APP_GUARD 토큰으로 모듈에 등록하면 Reflector 등 다른 서비스를 주입받을 수 있다.

Public 엔드포인트 처리 패턴

글로벌 Guard를 사용할 때 특정 라우트를 인증 없이 공개하려면 @Public() 데코레이터 패턴을 사용한다.

// public.decorator.ts
import { SetMetadata } from '@nestjs/common';
export const IS_PUBLIC_KEY = 'isPublic';
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);

// auth.guard.ts
@Injectable()
export class AuthGuard implements CanActivate {
  constructor(private reflector: Reflector) {}

  canActivate(context: ExecutionContext): boolean {
    const isPublic = this.reflector.getAllAndOverride<boolean>(
      IS_PUBLIC_KEY,
      [context.getHandler(), context.getClass()],
    );
    if (isPublic) return true;

    // 인증 로직 실행
    return this.validateRequest(context);
  }
}

// controller
@Public()
@Get('health')
healthCheck() { return { status: 'ok' }; }

이 패턴은 NestJS 공식 문서에서도 권장하는 방식이며, Pipe 검증과 함께 사용하면 인증·인가·검증을 계층적으로 분리할 수 있다.

다중 Guard 실행 순서와 조합

여러 Guard를 동시에 적용하면 배열 순서대로 실행되며, 하나라도 false를 반환하면 즉시 중단된다.

// 실행 순서: AuthGuard → RolesGuard → ThrottleGuard
@UseGuards(AuthGuard, RolesGuard, ThrottleGuard)
@Controller('admin')
export class AdminController {}

// 글로벌 + 컨트롤러 + 메서드 조합 시
// 글로벌 Guard → 컨트롤러 Guard → 메서드 Guard 순서

이 특성을 활용하면 인증 → 인가 → 속도 제한 같은 파이프라인을 자연스럽게 구성할 수 있다.

커스텀 예외 던지기

Guard에서 false를 반환하면 기본적으로 ForbiddenException이 발생한다. 더 구체적인 에러를 반환하려면 직접 예외를 던진다.

@Injectable()
export class AuthGuard implements CanActivate {
  canActivate(context: ExecutionContext): boolean {
    const request = context.switchToHttp().getRequest();
    const token = request.headers.authorization;

    if (!token) {
      throw new UnauthorizedException('토큰이 필요합니다');
    }

    try {
      const payload = this.jwtService.verify(token.replace('Bearer ', ''));
      request.user = payload;
      return true;
    } catch (error) {
      if (error.name === 'TokenExpiredError') {
        throw new UnauthorizedException('토큰이 만료되었습니다');
      }
      throw new UnauthorizedException('유효하지 않은 토큰입니다');
    }
  }
}

WebSocket·GraphQL Guard

Guard는 HTTP뿐 아니라 WebSocket과 GraphQL에서도 동일하게 작동한다. ExecutionContext.getType()으로 프로토콜을 구분한다.

@Injectable()
export class WsAuthGuard implements CanActivate {
  canActivate(context: ExecutionContext): boolean {
    if (context.getType() === 'ws') {
      const client = context.switchToWs().getClient();
      const token = client.handshake?.auth?.token;
      return this.validateToken(token);
    }
    
    if (context.getType() === 'http') {
      const request = context.switchToHttp().getRequest();
      return this.validateHttpRequest(request);
    }

    // GraphQL
    const gqlContext = GqlExecutionContext.create(context);
    const { req } = gqlContext.getContext();
    return this.validateHttpRequest(req);
  }
}

실전 패턴: 소유권 검증 Guard

RBAC만으로는 부족한 경우가 많다. “자신의 리소스만 수정 가능”한 소유권 검증은 별도 Guard로 분리한다.

@Injectable()
export class OwnershipGuard implements CanActivate {
  constructor(
    private reflector: Reflector,
    private moduleRef: ModuleRef,
  ) {}

  async canActivate(context: ExecutionContext): Promise<boolean> {
    const ownerCheck = this.reflector.get<OwnerCheckConfig>(
      'ownerCheck',
      context.getHandler(),
    );
    if (!ownerCheck) return true;

    const request = context.switchToHttp().getRequest();
    const userId = request.user.id;
    const resourceId = request.params[ownerCheck.paramKey || 'id'];

    // 동적으로 서비스 가져오기
    const service = this.moduleRef.get(ownerCheck.service, { strict: false });
    const resource = await service.findOne(resourceId);

    if (!resource) throw new NotFoundException();
    if (resource[ownerCheck.ownerField || 'userId'] !== userId) {
      throw new ForbiddenException('소유자만 접근할 수 있습니다');
    }

    // 핸들러에서 재조회 방지
    request.resource = resource;
    return true;
  }
}

// 사용
@OwnerCheck({ service: PostsService, ownerField: 'authorId' })
@UseGuards(AuthGuard, OwnershipGuard)
@Put(':id')
update(@Req() req, @Body() dto: UpdatePostDto) {
  return this.postsService.update(req.resource, dto);
}

Guard 테스트 전략

Guard는 ExecutionContext를 모킹하여 단위 테스트할 수 있다.

describe('RolesGuard', () => {
  let guard: RolesGuard;
  let reflector: Reflector;

  beforeEach(async () => {
    const module = await Test.createTestingModule({
      providers: [RolesGuard, Reflector],
    }).compile();

    guard = module.get(RolesGuard);
    reflector = module.get(Reflector);
  });

  it('역할 메타데이터가 없으면 통과', () => {
    jest.spyOn(reflector, 'getAllAndOverride').mockReturnValue(undefined);
    
    const context = createMockExecutionContext({ user: { roles: [] } });
    expect(guard.canActivate(context)).toBe(true);
  });

  it('필요한 역할이 없으면 거부', () => {
    jest.spyOn(reflector, 'getAllAndOverride').mockReturnValue([Role.ADMIN]);
    
    const context = createMockExecutionContext({
      user: { roles: [Role.USER] },
    });
    expect(guard.canActivate(context)).toBe(false);
  });
});

function createMockExecutionContext(request: any): ExecutionContext {
  return {
    switchToHttp: () => ({ getRequest: () => request }),
    getHandler: () => jest.fn(),
    getClass: () => jest.fn(),
  } as unknown as ExecutionContext;
}

정리

패턴 용도 핵심 포인트
AuthGuard 인증 확인 JWT 검증, request.user 주입
RolesGuard 역할 기반 인가 Reflector + SetMetadata 조합
@Public() 공개 엔드포인트 글로벌 Guard 예외 처리
OwnershipGuard 소유권 검증 ModuleRef로 동적 서비스 조회
APP_GUARD 글로벌 등록 DI 지원, 다중 Guard 순서 보장

Guard는 NestJS에서 인증과 인가를 비즈니스 로직과 완전히 분리하는 핵심 도구다. ExecutionContextReflector를 활용한 메타데이터 기반 설계를 익히면, 선언적이고 재사용 가능한 인가 시스템을 구축할 수 있다.

위로 스크롤
WordPress Appliance - Powered by TurnKey Linux