junyeokk
Blog
NestJS·2025. 04. 16

NestJS Passport 통합

NestJS에서 인증을 구현하려면 JWT 토큰 검증, 세션 관리, OAuth 콜백 처리 등 다양한 인증 전략을 다뤄야 한다. 이걸 매번 Guard 안에서 직접 구현하면 어떻게 될까?

typescript
@Injectable()
export class JwtAuthGuard implements CanActivate {
  canActivate(context: ExecutionContext): boolean {
    const request = context.switchToHttp().getRequest();
    const token = request.headers.authorization?.replace('Bearer ', '');
    const payload = jwt.verify(token, SECRET_KEY);
    request.user = payload;
    return true;
  }
}

JWT 하나만 쓸 때는 이걸로 충분하다. 그런데 리프레시 토큰도 검증해야 하고, Google OAuth도 붙이고, GitHub 로그인도 추가해야 한다면? 각각의 Guard마다 토큰 추출 → 검증 → 사용자 정보 부착이라는 동일한 흐름을 반복하게 된다. 차이는 "토큰을 어디서 가져오는지", "어떤 비밀키로 검증하는지"뿐인데 전체 구조를 매번 다시 짜야 한다.

Passport는 이 문제를 Strategy 패턴으로 해결한다. "인증 흐름"이라는 틀은 Passport가 관리하고, "구체적인 인증 방식"만 Strategy로 구현하면 된다. @nestjs/passport는 이 Passport를 NestJS의 Guard/DI 체계에 자연스럽게 녹여주는 통합 라이브러리다.


Passport의 Strategy 패턴

Passport의 핵심 아이디어는 간단하다. 모든 인증 방식은 결국 같은 흐름을 따른다.

요청 → 자격 증명 추출 → 검증 → 사용자 정보 반환 (또는 실패)

JWT든 OAuth든 세션이든, 이 흐름 자체는 동일하다. 다른 건 각 단계의 구현이다.

인증 방식자격 증명 추출검증사용자 정보
JWTAuthorization 헤더에서 Bearer 토큰비밀키로 서명 검증토큰 페이로드
JWT (쿠키)쿠키에서 refresh_token비밀키로 서명 검증토큰 페이로드
Local (ID/PW)body에서 username, passwordDB 조회 + 비밀번호 비교User 엔티티
Google OAuth콜백 URL의 authorization codeGoogle API로 토큰 교환Google 프로필

Passport는 이 흐름을 추상화하고, 각 인증 방식의 구현을 Strategy라는 플러그인으로 분리한다. passport-jwt, passport-local, passport-google-oauth20 같은 npm 패키지가 각각 하나의 Strategy 구현체다.

Strategy의 구조

모든 Passport Strategy는 두 가지를 정의한다.

  1. 옵션(Options): 자격 증명을 어디서 어떻게 추출할지
  2. 검증 함수(verify callback): 추출된 자격 증명으로 사용자를 어떻게 확인할지
Strategy = Options(자격 증명 추출 방법) + Verify(검증 로직)

이 분리 덕분에 JWT Strategy를 만들 때 "토큰을 헤더에서 가져와라, 비밀키는 이거다"는 옵션으로, "이 페이로드가 유효한 사용자인지 확인해라"는 검증 함수로 나눌 수 있다.


@nestjs/passport의 역할

순수 Passport를 NestJS에서 쓰려면 미들웨어로 passport.authenticate('jwt')를 호출해야 한다. 이건 NestJS의 Guard 체계와 맞지 않는다. @nestjs/passport는 이 간극을 메워주는 어댑터 역할을 한다.

bash
요청 → 자격 증명 추출 → 검증 → 사용자 정보 반환 (또는 실패)

@nestjs/passport가 제공하는 핵심 두 가지:

  1. PassportStrategy(Strategy, name) — Passport Strategy를 NestJS Injectable 클래스로 변환
  2. AuthGuard(name) — Passport의 authenticate()를 NestJS Guard로 변환
passport-jwt의 Strategy → PassportStrategy() → NestJS Injectable passport.authenticate() → AuthGuard() → NestJS Guard

이 변환 덕분에 Strategy 클래스에서 NestJS의 DI를 자유롭게 사용할 수 있다. ConfigService로 환경변수를 읽거나, UserService로 DB를 조회하거나, RedisService로 블랙리스트를 확인하는 게 전부 가능해진다.


JWT Strategy 구현

가장 흔한 패턴인 JWT 인증을 구현해보자.

PassportStrategy 클래스

typescript
Strategy = Options(자격 증명 추출 방법) + Verify(검증 로직)

PassportStrategy(Strategy, 'jwt')에서 첫 번째 인자는 passport-jwt의 Strategy 클래스, 두 번째 인자 'jwt'는 이 전략의 이름이다. 나중에 AuthGuard('jwt')로 이 이름을 참조한다.

super() 옵션

super()에 전달하는 객체가 passport-jwt의 옵션이다. 이 옵션이 "자격 증명을 어디서 어떻게 추출할지"를 결정한다.

jwtFromRequest — 토큰 추출 방법을 지정한다. ExtractJwt가 여러 유틸리티를 제공한다.

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

fromExtractors에 배열로 여러 추출기를 넣으면, 첫 번째에서 못 찾으면 두 번째를 시도하는 식으로 동작한다. "헤더에 있으면 헤더에서, 없으면 쿠키에서"처럼 유연하게 설정할 수 있다.

secretOrKey — JWT 서명을 검증할 비밀키. 비대칭 키를 쓴다면 secretOrKeyProvider로 동적으로 공개키를 가져올 수도 있다.

ignoreExpirationfalse(기본값)면 만료된 토큰을 자동으로 거부한다. true로 설정할 일은 거의 없다.

validate 메서드

validate()는 Passport의 verify callback에 해당한다. passport-jwt가 토큰을 추출하고 서명을 검증한 후, 디코딩된 페이로드를 이 메서드에 넘겨준다.

typescript
passport-jwt의 Strategy  →  PassportStrategy()  →  NestJS Injectable
passport.authenticate()  →  AuthGuard()          →  NestJS Guard

validate()가 반환하는 값이 request.user에 자동으로 부착된다. 단순히 페이로드를 그대로 반환할 수도 있고, DB에서 최신 사용자 정보를 조회해서 반환할 수도 있다.

typescript
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';

interface JwtPayload {
  id: number;
  email: string;
  role: string;
}

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

  async validate(payload: JwtPayload) {
    return payload;
  }
}

DB 조회를 하면 매 요청마다 쿼리가 발생하지만, "토큰 발급 후 사용자가 삭제/차단된 경우"를 잡아낼 수 있다. 반면 페이로드만 반환하면 DB 부하는 없지만 토큰 발급 시점의 정보만 사용하게 된다. 트레이드오프를 상황에 맞게 선택하면 된다.

토큰 블랙리스트 검증

JWT는 stateless하다. 한 번 발급하면 만료될 때까지 유효하다. 그런데 사용자가 로그아웃했거나, 비밀번호를 변경해서 기존 토큰을 무효화해야 한다면? Redis에 블랙리스트를 두고 validate()에서 확인하는 패턴이 일반적이다.

typescript
// Authorization: Bearer <token> 헤더에서 추출
ExtractJwt.fromAuthHeaderAsBearerToken()

// 쿠키에서 추출 (커스텀 extractor)
ExtractJwt.fromExtractors([
  (req: Request) => req.cookies['access_token']
])

// 쿼리 파라미터에서 추출
ExtractJwt.fromUrlQueryParameter('token')

// 여러 위치에서 순서대로 시도
ExtractJwt.fromExtractors([
  ExtractJwt.fromAuthHeaderAsBearerToken(),
  (req: Request) => req.cookies['access_token'],
])

passReqToCallback: true 옵션이 핵심이다. 이 옵션을 켜면 validate()의 첫 번째 인자로 Express Request 객체가 전달된다. 디코딩된 페이로드에는 원본 토큰 문자열이 없기 때문에, 블랙리스트 확인을 위해 원본 토큰에 접근하려면 이 옵션이 필요하다.

로그아웃 시 토큰을 블랙리스트에 추가하고, TTL을 토큰의 남은 만료 시간과 동일하게 설정하면 만료된 토큰은 자연스럽게 Redis에서도 제거된다.


Refresh Token Strategy

Access Token은 짧은 만료 시간(보통 15분1시간)을 가지고, Refresh Token은 긴 만료 시간(7일30일)으로 새로운 Access Token을 발급받는 데 사용된다. 두 토큰을 각각 다른 Strategy로 구현한다.

typescript
async validate(payload: JwtPayload) {
  // payload는 이미 서명 검증이 완료된 상태
  // 여기서 추가 검증을 할 수 있다
  return payload;
}

Access Token Strategy와의 차이점:

설정Access TokenRefresh Token
Strategy 이름'jwt''jwt-refresh'
토큰 추출헤더 Authorization: Bearer쿠키 refresh_token
비밀키JWT_ACCESS_SECRETJWT_REFRESH_SECRET
용도API 인증토큰 갱신

리프레시 토큰을 쿠키에 저장하는 이유는 XSS 공격으로부터 보호하기 위해서다. httpOnly 쿠키는 JavaScript로 접근할 수 없기 때문에, 스크립트 삽입 공격이 성공하더라도 리프레시 토큰은 탈취할 수 없다.

두 Strategy를 동시에 사용하면 이렇게 된다:

typescript
async validate(payload: JwtPayload) {
  const user = await this.usersService.findById(payload.id);
  if (!user) {
    throw new UnauthorizedException('존재하지 않는 사용자입니다');
  }
  if (user.isBanned) {
    throw new UnauthorizedException('차단된 사용자입니다');
  }
  return user;
}

AuthGuard

AuthGuard('jwt')@nestjs/passport가 제공하는 팩토리 함수로, Passport의 authenticate() 호출을 NestJS Guard로 감싸준다.

동작 흐름

AuthGuard('jwt') 호출 → passport.authenticate('jwt') 실행 → 'jwt'라는 이름으로 등록된 JwtStrategy 찾기 → Strategy의 옵션대로 토큰 추출 → 토큰 서명 검증 (passport-jwt 내부) → validate() 호출 → 반환값을 request.user에 부착 → canActivate() → true 반환

이 과정에서 토큰이 없거나, 서명이 잘못됐거나, validate()에서 예외를 던지면 canActivate()가 실패하고 요청은 거부된다.

AuthGuard 커스터마이즈

기본 AuthGuard의 에러 처리를 변경하고 싶다면 상속해서 handleRequest()를 오버라이드한다.

typescript
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
  constructor(
    private readonly configService: ConfigService,
    private readonly redisService: RedisService,
  ) {
    super({
      jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
      secretOrKey: configService.get('JWT_ACCESS_SECRET'),
      passReqToCallback: true, // validate()에 request 객체 전달
    });
  }

  async validate(req: Request, payload: JwtPayload) {
    const token = req.headers.authorization?.replace('Bearer ', '');
    
    if (token) {
      const isBlacklisted = await this.redisService.get(
        `user:blacklist:jwt:${token}`
      );
      if (isBlacklisted) {
        throw new UnauthorizedException('만료된 토큰입니다');
      }
    }
    
    return payload;
  }
}

handleRequest()는 Strategy의 validate()가 반환한 값(user)과 발생한 에러(err)를 받는다. 기본 구현은 에러가 있으면 그대로 던지지만, 커스터마이즈하면 에러 메시지를 통일하거나 로깅을 추가할 수 있다.

Optional Guard (인증 선택적)

어떤 엔드포인트는 "로그인한 사용자면 사용자 정보를 사용하고, 아니면 비로그인 상태로 처리"해야 할 때가 있다. 예를 들어 게시글 목록에서 로그인한 사용자에게는 북마크 여부를 표시하고, 비로그인 사용자에게는 그냥 목록만 보여주는 경우다.

typescript
@Injectable()
export class JwtRefreshStrategy extends PassportStrategy(Strategy, 'jwt-refresh') {
  constructor(private readonly configService: ConfigService) {
    super({
      jwtFromRequest: ExtractJwt.fromExtractors([
        (req: Request) => req.cookies['refresh_token'],
      ]),
      secretOrKey: configService.get('JWT_REFRESH_SECRET'),
      passReqToCallback: true,
    });
  }

  async validate(req: Request, payload: JwtPayload) {
    const refreshToken = req.cookies['refresh_token'];
    
    if (!refreshToken) {
      throw new UnauthorizedException('리프레시 토큰이 없습니다');
    }
    
    return payload;
  }
}

기본 AuthGuard는 인증 실패 시 UnauthorizedException을 던지지만, OptionalJwtGuard는 단순히 null을 반환한다. 이렇게 하면 request.usernull일 수 있고, 핸들러에서 이를 확인해서 분기 처리한다.

typescript
@Controller('auth')
export class AuthController {
  @UseGuards(AuthGuard('jwt'))
  @Get('profile')
  getProfile(@Req() req) {
    // Access Token으로 인증된 사용자 정보
    return req.user;
  }

  @UseGuards(AuthGuard('jwt-refresh'))
  @Post('refresh')
  refreshTokens(@Req() req) {
    // Refresh Token으로 인증, 새 토큰 쌍 발급
    return this.authService.refreshTokens(req.user);
  }
}

모듈 구성

Strategy와 Guard를 NestJS 모듈로 조직하는 방법이다.

PassportModule 등록

typescript
AuthGuard('jwt') 호출
  → passport.authenticate('jwt') 실행
  → 'jwt'라는 이름으로 등록된 JwtStrategy 찾기
  → Strategy의 옵션대로 토큰 추출
  → 토큰 서명 검증 (passport-jwt 내부)
  → validate() 호출
  → 반환값을 request.user에 부착
  → canActivate() → true 반환

PassportModule.register()에서 defaultStrategy를 설정하면, AuthGuard()를 인자 없이 호출했을 때 이 기본 Strategy가 사용된다. 여러 Strategy를 쓰는 프로젝트에서는 명시적으로 AuthGuard('jwt'), AuthGuard('jwt-refresh')처럼 이름을 지정하는 게 더 명확하다.

Strategy 등록의 원리

@Injectable() 데코레이터가 붙은 Strategy 클래스를 모듈의 providers에 넣으면, NestJS가 이 클래스를 인스턴스화할 때 PassportStrategy의 생성자가 Passport에 Strategy를 등록한다. 별도의 등록 코드가 필요 없다.

typescript
@Injectable()
export class JwtGuard extends AuthGuard('jwt') {
  canActivate(
    context: ExecutionContext,
  ): boolean | Promise<boolean> | Observable<boolean> {
    return super.canActivate(context);
  }

  handleRequest(err: any, user: any) {
    if (err || !user) {
      throw new UnauthorizedException('인증되지 않은 요청입니다.');
    }
    return user;
  }
}

여러 Strategy 조합

실무 프로젝트에서는 보통 여러 인증 방식이 공존한다. 사용자는 JWT로, 관리자는 세션으로, 외부 연동은 API 키로 인증하는 식이다.

typescript
@Injectable()
export class OptionalJwtGuard extends AuthGuard('jwt') {
  handleRequest(err: any, user: any) {
    // 에러가 있거나 user가 없어도 예외를 던지지 않음
    // user가 있으면 반환, 없으면 null
    return user || null;
  }
}

각 Strategy는 독립적이다. 'jwt''jwt-refresh'는 서로 다른 비밀키, 다른 토큰 추출 방법을 가진다. 하나를 수정해도 다른 Strategy에 영향이 없다. 새로운 인증 방식이 필요하면 Strategy 클래스 하나만 추가하면 된다.


passport-jwt 외의 Strategy

Passport 생태계에는 500개 이상의 Strategy가 있다. @nestjs/passportPassportStrategy()로 어떤 Passport Strategy든 NestJS에 통합할 수 있다.

Local Strategy (ID/비밀번호)

typescript
@UseGuards(OptionalJwtGuard)
@Get('posts')
getPosts(@Req() req) {
  const userId = req.user?.id ?? null;
  return this.postsService.findAll(userId);
  // userId가 있으면 북마크 정보 포함, 없으면 기본 목록
}

passport-local은 request body에서 username과 password를 추출한다. validate()의 인자로 추출된 값이 전달되며, 여기서 DB 조회와 비밀번호 비교를 수행한다.

Google OAuth Strategy

typescript
import { Module } from '@nestjs/common';
import { PassportModule } from '@nestjs/passport';
import { JwtModule } from '@nestjs/jwt';
import { ConfigModule, ConfigService } from '@nestjs/config';

@Module({
  imports: [
    PassportModule.register({ defaultStrategy: 'jwt' }),
    JwtModule.registerAsync({
      imports: [ConfigModule],
      useFactory: (configService: ConfigService) => ({
        secret: configService.get('JWT_ACCESS_SECRET'),
        signOptions: { expiresIn: '1h' },
      }),
      inject: [ConfigService],
    }),
  ],
  providers: [JwtStrategy, JwtRefreshStrategy, AuthService],
  exports: [JwtModule],
})
export class AuthModule {}

OAuth Strategy는 validate()에 accessToken, refreshToken, profile이 전달된다. 이 정보로 로컬 DB에서 사용자를 찾거나 새로 생성하면 된다.

패턴이 동일하다는 걸 알 수 있다: super()에 옵션 → validate()에 검증 로직. Strategy가 달라져도 구조는 같다.


Passport vs 직접 구현

Guard에서 직접 JWT를 검증하는 것과 Passport를 쓰는 것의 차이를 정리하면:

관점직접 구현Passport
단순한 JWT만 쓸 때코드 적고 직관적오버엔지니어링일 수 있음
인증 방식 2개 이상각 Guard마다 중복 코드Strategy만 추가
OAuth 통합직접 구현 복잡passport-google 등 검증된 라이브러리
테스트Guard 전체를 모킹Strategy의 validate()만 테스트
팀 프로젝트구현 방식 제각각Strategy 패턴으로 통일

JWT 하나만 쓰는 소규모 프로젝트라면 직접 구현이 더 간단할 수 있다. 하지만 인증 방식이 늘어날 가능성이 있거나, OAuth를 도입할 계획이 있다면 Passport를 쓰는 게 장기적으로 유리하다. 특히 passport-jwt, passport-google-oauth20 같은 라이브러리는 수년간 검증된 구현이라 보안 관점에서도 직접 구현보다 안전하다.


정리

@nestjs/passport는 Passport의 Strategy 기반 인증 체계를 NestJS의 Guard/DI에 통합하는 어댑터다.

  • PassportStrategy(Strategy, name) — Passport Strategy를 NestJS Injectable로 변환
  • AuthGuard(name) — 해당 Strategy를 사용하는 Guard 생성
  • super() 옵션 — 자격 증명 추출 방법 설정 (헤더, 쿠키, 쿼리 등)
  • validate() 메서드 — 검증 로직 구현, 반환값이 request.user에 부착
  • passReqToCallback: true — validate()에서 원본 Request 접근 가능
  • handleRequest() 오버라이드 — 에러 처리 커스터마이즈 (Optional Guard 등)

Strategy 패턴의 장점은 확장성이다. 새로운 인증 방식이 필요하면 Strategy 클래스 하나를 추가하고, AuthGuard('이름')으로 참조하면 끝이다. 기존 코드를 건드릴 필요가 없다.

관련 문서