로그인은 구현했는데, "이 API는 관리자만 접근 가능"을 어떻게 처리하나요? NestJS는 Passport + Guard 조합으로 인증(누구인가) 과 인가(뭘 할 수 있는가) 를 분리해서 처리합니다. Spring Security의 Filter Chain과 비슷한 구조인데, 훨씬 간결합니다.


인증 vs 인가

인증 (Authentication)인가 (Authorization)
질문"누구인가?""이 작업을 할 수 있는가?"
NestJS 담당AuthGuard + Passport StrategyRolesGuard + 커스텀 데코레이터
Spring 대응AuthenticationFilter@PreAuthorize, AccessDecisionManager
실패 시401 Unauthorized403 Forbidden

JWT 인증 구현

1단계: 패키지 설치

BASH
npm install @nestjs/passport @nestjs/jwt passport passport-jwt
npm install -D @types/passport-jwt

2단계: JWT Strategy 정의

Passport의 Strategy는 토큰을 검증하고 사용자 정보를 추출 하는 역할입니다.

TYPESCRIPT
// jwt.strategy.ts
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
  constructor(private configService: ConfigService) {
    super({
      jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
      ignoreExpiration: false,
      secretOrKey: configService.get('JWT_SECRET'),
    });
  }

  // 토큰이 유효하면 이 메서드가 호출됨
  // 반환값이 req.user에 할당됨
  async validate(payload: any) {
    return {
      id: payload.sub,
      email: payload.email,
      roles: payload.roles,
    };
  }
}

validate() 메서드의 반환값이 요청 객체의 req.user에 자동으로 할당 됩니다. 이 부분이 Spring Security의 SecurityContextHolder.getContext().getAuthentication()에 대응합니다.

3단계: Auth Module 구성

TYPESCRIPT
@Module({
  imports: [
    PassportModule,
    JwtModule.registerAsync({
      inject: [ConfigService],
      useFactory: (config: ConfigService) => ({
        secret: config.get('JWT_SECRET'),
        signOptions: { expiresIn: '1h' },
      }),
    }),
    UserModule,
  ],
  providers: [AuthService, JwtStrategy],
  exports: [AuthService],
})
export class AuthModule {}

4단계: AuthService — 로그인 처리

TYPESCRIPT
@Injectable()
export class AuthService {
  constructor(
    private readonly userService: UserService,
    private readonly jwtService: JwtService,
  ) {}

  async validateUser(email: string, password: string): Promise<User | null> {
    const user = await this.userService.findByEmail(email);
    if (user && await bcrypt.compare(password, user.password)) {
      return user;
    }
    return null;
  }

  async login(user: User) {
    const payload = {
      sub: user.id,
      email: user.email,
      roles: user.roles,
    };
    return {
      accessToken: this.jwtService.sign(payload),
    };
  }
}

5단계: Guard 적용

TYPESCRIPT
@Controller('users')
export class UserController {
  @UseGuards(AuthGuard('jwt'))     // JWT 인증 필요
  @Get('profile')
  getProfile(@Req() req) {
    return req.user;               // JwtStrategy.validate()의 반환값
  }

  @Post('login')
  async login(@Body() loginDto: LoginDto) {
    const user = await this.authService.validateUser(
      loginDto.email, loginDto.password
    );
    if (!user) throw new UnauthorizedException();
    return this.authService.login(user);
  }
}

인가 — RolesGuard

인증이 "누구인가"를 확인하는 것이라면, 인가는 "이 사람이 이 작업을 할 수 있는가"를 판단합니다.

역할(Role) 데코레이터 만들기

TYPESCRIPT
// roles.decorator.ts
export const Roles = (...roles: string[]) => SetMetadata('roles', roles);

RolesGuard 구현

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

  canActivate(context: ExecutionContext): boolean {
    // 메서드에 설정된 roles 메타데이터 읽기
    const requiredRoles = this.reflector.getAllAndOverride<string[]>(
      'roles',
      [context.getHandler(), context.getClass()],
    );

    // roles 데코레이터가 없으면 접근 허용
    if (!requiredRoles) return true;

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

사용

TYPESCRIPT
@UseGuards(AuthGuard('jwt'), RolesGuard)  // 인증 → 인가 순서
@Roles('admin')
@Delete(':id')
remove(@Param('id') id: string) {
  return this.userService.remove(id);
}

Guard 순서가 중요합니다. AuthGuard가 먼저 실행되어 req.user를 세팅해야 RolesGuard에서 역할을 확인할 수 있습니다.


전역 Guard 설정

모든 API에 인증을 기본으로 적용하고, 특정 엔드포인트만 공개하는 패턴이 실무에서 더 많이 쓰입니다.

TYPESCRIPT
// 공개 API 표시용 데코레이터
export const Public = () => SetMetadata('isPublic', true);

// 전역 AuthGuard
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {
  constructor(private reflector: Reflector) {
    super();
  }

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

// app.module.ts
@Module({
  providers: [
    { provide: APP_GUARD, useClass: JwtAuthGuard },
  ],
})
export class AppModule {}
TYPESCRIPT
// 사용 — 기본은 인증 필요, @Public()만 예외
@Public()
@Post('login')
login() {}

@Get('profile')    // 인증 필요 (기본)
getProfile() {}

Spring Security의 http.authorizeRequests().anyRequest().authenticated().permitAll() 예외를 주는 것과 같은 패턴입니다.


주의할 점

JWT Secret을 코드에 하드코딩하지 마라

TYPESCRIPT
// 절대 하지 말 것
secret: 'my-super-secret-key',

// ConfigService나 환경 변수 사용
secret: configService.get('JWT_SECRET'),

코드에 시크릿을 넣으면 Git 이력에 남고, 환경별 키 분리도 불가능합니다.

Refresh Token 없이 Access Token만 쓰면

Access Token 만료 시간을 길게 설정하면 탈취 시 위험하고, 짧게 설정하면 사용자가 자주 로그아웃됩니다. 실무에서는 Access Token(짧은 만료) + Refresh Token(긴 만료) 조합을 사용합니다.

PLAINTEXT
Access Token: 15분~1시간
Refresh Token: 7~30일 (DB에 저장, 로그아웃 시 무효화)

Guard에서 예외를 직접 던지지 않으면

canActivate()에서 false를 반환하면 NestJS가 기본 ForbiddenException(403)을 던집니다. 인증 실패를 401로 보내려면 직접 예외를 던져야 합니다.

TYPESCRIPT
if (!token) {
  throw new UnauthorizedException('토큰이 없습니다');
}

정리

포인트내용
인증Passport + JwtStrategy → req.user에 사용자 정보 세팅
인가RolesGuard + @Roles 데코레이터 → 역할 기반 접근 제어
전역 패턴기본 인증 + @Public() 예외가 실무 표준
Guard 순서AuthGuard 먼저, RolesGuard 나중에
핵심 함정JWT Secret 하드코딩 금지, Refresh Token 전략 필수
댓글 로딩 중...