인증과 인가 — Passport, JWT, Guard로 보안 구현하기
로그인은 구현했는데, "이 API는 관리자만 접근 가능"을 어떻게 처리하나요? NestJS는 Passport + Guard 조합으로 인증(누구인가) 과 인가(뭘 할 수 있는가) 를 분리해서 처리합니다. Spring Security의 Filter Chain과 비슷한 구조인데, 훨씬 간결합니다.
인증 vs 인가
| 인증 (Authentication) | 인가 (Authorization) | |
|---|---|---|
| 질문 | "누구인가?" | "이 작업을 할 수 있는가?" |
| NestJS 담당 | AuthGuard + Passport Strategy | RolesGuard + 커스텀 데코레이터 |
| Spring 대응 | AuthenticationFilter | @PreAuthorize, AccessDecisionManager |
| 실패 시 | 401 Unauthorized | 403 Forbidden |
JWT 인증 구현
1단계: 패키지 설치
npm install @nestjs/passport @nestjs/jwt passport passport-jwt
npm install -D @types/passport-jwt
2단계: JWT Strategy 정의
Passport의 Strategy는 토큰을 검증하고 사용자 정보를 추출 하는 역할입니다.
// 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 구성
@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 — 로그인 처리
@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 적용
@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) 데코레이터 만들기
// roles.decorator.ts
export const Roles = (...roles: string[]) => SetMetadata('roles', roles);
RolesGuard 구현
@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));
}
}
사용
@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에 인증을 기본으로 적용하고, 특정 엔드포인트만 공개하는 패턴이 실무에서 더 많이 쓰입니다.
// 공개 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 {}
// 사용 — 기본은 인증 필요, @Public()만 예외
@Public()
@Post('login')
login() {}
@Get('profile') // 인증 필요 (기본)
getProfile() {}
Spring Security의 http.authorizeRequests().anyRequest().authenticated() 후 .permitAll() 예외를 주는 것과 같은 패턴입니다.
주의할 점
JWT Secret을 코드에 하드코딩하지 마라
// 절대 하지 말 것
secret: 'my-super-secret-key',
// ConfigService나 환경 변수 사용
secret: configService.get('JWT_SECRET'),
코드에 시크릿을 넣으면 Git 이력에 남고, 환경별 키 분리도 불가능합니다.
Refresh Token 없이 Access Token만 쓰면
Access Token 만료 시간을 길게 설정하면 탈취 시 위험하고, 짧게 설정하면 사용자가 자주 로그아웃됩니다. 실무에서는 Access Token(짧은 만료) + Refresh Token(긴 만료) 조합을 사용합니다.
Access Token: 15분~1시간
Refresh Token: 7~30일 (DB에 저장, 로그아웃 시 무효화)
Guard에서 예외를 직접 던지지 않으면
canActivate()에서 false를 반환하면 NestJS가 기본 ForbiddenException(403)을 던집니다. 인증 실패를 401로 보내려면 직접 예외를 던져야 합니다.
if (!token) {
throw new UnauthorizedException('토큰이 없습니다');
}
정리
| 포인트 | 내용 |
|---|---|
| 인증 | Passport + JwtStrategy → req.user에 사용자 정보 세팅 |
| 인가 | RolesGuard + @Roles 데코레이터 → 역할 기반 접근 제어 |
| 전역 패턴 | 기본 인증 + @Public() 예외가 실무 표준 |
| Guard 순서 | AuthGuard 먼저, RolesGuard 나중에 |
| 핵심 함정 | JWT Secret 하드코딩 금지, Refresh Token 전략 필수 |