본문으로 건너뛰기

안동민 개발노트

본문 시작

Passport를 이용한 인증 구현

인증과 권한 부여를 구분하고 Passport 로컬 전략과 가드로 아이디·비밀번호 로그인 흐름을 구현합니다.

3장에서는 NestJS 애플리케이션의 데이터를 안전하게 관리하는 방법을 다뤘습니다.

4장에서는 웹 애플리케이션의 핵심 요소인 인증(Authentication)과 권한 부여(Authorization)를 다룹니다.

사용자 로그인, 개인정보 보호, 기능 접근 제어 등 현대 웹 서비스에서 반드시 필요한 주제입니다.

NestJS는 Node.js 생태계의 대표 인증 미들웨어인 Passport.js와 긴밀하게 통합되어, 다양한 인증 전략을 비교적 쉽게 구현할 수 있습니다.

이번 절에서는 Passport의 기본 개념을 짚고, NestJS에 적용해 사용자 인증을 구현하는 흐름을 살펴봅니다.


인증과 권한 부여란?

두 용어는 자주 혼용되지만, 명확히 다른 개념을 가지고 있습니다.

  • 인증(Authentication): 당신이 누구인지 확인하는 과정입니다. 사용자가 주장하는 신원(ID)이 진짜인지 확인하는 절차입니다. 예를 들어, 아이디와 비밀번호를 통해 로그인하는 것이 인증의 대표적인 예입니다. (예: 로그인하셨군요, 환영합니다!)
  • 권한 부여(Authorization): 당신이 무엇을 할 수 있는지 결정하는 과정입니다. 인증된 사용자가 특정 리소스나 기능에 접근할 수 있는 권한이 있는지 확인하는 절차입니다. 예를 들어, 일반 사용자는 게시물을 읽을 수 있지만, 관리자만 게시물을 삭제할 수 있는 것이 권한 부여의 예입니다. (예: 관리자만 이 기능을 사용할 수 있습니다.)

이번 절에서는 주로 인증에 초점을 맞추고, 다음 절에서 권한 부여에 대해 더 자세히 다루겠습니다.


Passport.js란 무엇이며 왜 사용할까요?

Passport.js는 Node.js를 위한 강력하고 유연한 인증 미들웨어입니다.

웹 애플리케이션에 다양한 인증 전략(로컬 인증, JWT, OAuth, 소셜 로그인 등)을 쉽게 통합할 수 있도록 설계되었습니다.

Passport는 전략 실행과 성공·실패 전달을 공통 인터페이스로 묶어, 인증 어댑터를 라우팅 및 핵심 비즈니스 로직과 분리하도록 돕습니다.

Passport 사용의 주요 장점
  • 모듈화된 전략: Passport는 전략(Strategy)이라는 개념을 사용하여 다양한 인증 방식을 플러그인 형태로 제공합니다. 로컬(아이디/비밀번호), JWT, OAuth2 (Google, Facebook, GitHub 등), OpenID 등 수백 가지의 전략 중에서 필요한 것을 선택하여 적용할 수 있습니다.
  • 유연하고 확장 가능: 특정 인증 방식에 얽매이지 않고, 필요에 따라 커스텀 전략을 만들거나 여러 전략을 조합하여 사용할 수 있습니다.
  • NestJS와의 통합 용이성: @nestjs/passport 패키지가 Passport 전략을 Nest의 의존성 주입 및 Guard 구조에 연결합니다.

Passport를 이용한 로컬(Local) 인증 구현

Passport 인증 구현은 strategy 선택, validate() 반환값, guard 적용 위치, 실패 응답 기준으로 읽습니다.

Nest Passport 로컬 인증에서 가드가 local 전략을 실행하고, AuthService가 UsersService로 사용자 레코드를 조회해 자격 증명을 검증한 뒤 성공 사용자를 req.user에 넣거나 401로 요청을 끝내는 흐름

Nest · Passport Local

검증 성공이 req.user를 만든다

가드는 local 전략을 실행하고, 전략은 인증 유스케이스에 검증을 위임합니다. 성공한 안전한 사용자만 요청에 붙고, 인증 실패에서는 라우트 핸들러가 실행되지 않습니다.

Nest Passport 로컬 인증 시퀀스 요청 파이프라인이 LocalAuthGuard를 호출하면 가드가 local 전략을 실행한다. LocalStrategy는 AuthService에 검증을 위임하고 AuthService는 UsersService에서 사용자를 찾는다. 성공하면 안전한 사용자가 역순으로 돌아와 가드가 req.user를 설정하고 핸들러를 실행하며, 실패하면 전략이 UnauthorizedException을 던져 401로 종료한다. ALT [valid] [invalid] request local strategy validateUser() findOne() record | none safe user safe user req.user set null Unauthorized 401 · stop 요청 파이프라인 request · handler LocalAuthGuard strategy: local LocalStrategy validate() AuthService credential policy UsersService user lookup

성공 경로

  1. 가드가 전략을 실행합니다. LocalAuthGuard가 Passport의 local 전략을 시작하면, Passport Local 구현이 요청에서 기본 필드 username·password를 읽어 LocalStrategy.validate()에 전달합니다.

  2. 검증을 위임합니다. LocalStrategy.validate()AuthService.validateUser()를 호출하고, 서비스는 UsersService.findOne()으로 저장된 사용자 레코드를 조회합니다.

  3. 안전한 사용자만 반환합니다. 자격 증명이 맞으면 민감 필드를 뺀 사용자가 전략과 가드로 돌아옵니다. 가드는 그 값을 현재 요청의 req.user에 넣고 라우트 핸들러를 통과시킵니다.

인증 실패와 실행 오류

사용자가 없거나 비밀번호가 다르면 AuthServicenull을 반환하고 전략은 UnauthorizedException을 던집니다. 가드는 HTTP 401로 요청을 끝내며 핸들러는 실행되지 않습니다.

저장소 장애 같은 예상하지 못한 오류는 잘못된 자격 증명으로 바꾸지 않습니다. 원래 오류를 전파해 Nest의 예외 처리와 관측 경계에서 다룹니다.

req.user는 이 요청의 인증 결과입니다. 세션이나 JWT처럼 다음 요청에도 사용할 로그인 상태는 이 흐름 뒤에서 별도로 구성합니다.

가장 기본적인 인증 방식인 로컬 인증(아이디와 비밀번호 사용)을 NestJS와 Passport를 사용하여 구현해 보겠습니다.

단계 1: 필요한 패키지 설치
npm install @nestjs/passport passport passport-local
npm install --save-dev @types/passport-local
단계 2: User 모듈 및 서비스 준비 (가정)

사용자 정보를 관리하는 UsersModule, UsersService가 이미 있다고 가정합니다.

UsersService는 사용자 저장소를 감싸며, 여기서는 username으로 사용자 레코드를 조회하는 일만 맡습니다. 자격 증명이 맞는지 판단하는 인증 유스케이스는 뒤에서 AuthService에 둡니다.

src/users/users.service.ts
import { Injectable } from '@nestjs/common';

// 실제 DB 연동 로직 대신 간단한 사용자 데이터 배열 사용
const users = [
  { userId: 1, username: 'testuser', password: 'password123', roles: ['user'] },
  { userId: 2, username: 'admin', password: 'adminpassword', roles: ['admin'] },
];

@Injectable()
export class UsersService {
  async findOne(username: string): Promise<any | undefined> {
    return users.find(user => user.username === username);
  }
}

위 배열과 평문 비밀번호는 인증 흐름만 확인하기 위한 예제입니다. 실제 서비스에서는 비밀번호 원문을 저장하거나 ===로 비교하지 말고, 검증된 비밀번호 해싱 라이브러리로 저장된 해시를 검증해야 합니다.

단계 3: Passport 전략 구현 (LocalStrategy)

Passport는 인증 로직을 전략으로 캡슐화합니다.

로컬 인증을 위한 LocalStrategy를 생성합니다.

src/auth/strategies/local.strategy.ts
import { Strategy } from 'passport-local';
import { PassportStrategy } from '@nestjs/passport';
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { AuthService } from '../auth.service';

@Injectable()
export class LocalStrategy extends PassportStrategy(Strategy) {
  constructor(private authService: AuthService) {
    super(); // 기본 필드 이름: username, password
  }

  async validate(username: string, password: string): Promise<any> {
    const user = await this.authService.validateUser(username, password);
    if (!user) {
      throw new UnauthorizedException();
    }
    return user;
  }
}

passport-local은 기본적으로 요청 본문의 usernamepassword 필드를 읽습니다. 이메일 같은 다른 이름을 쓰려면 super({ usernameField: 'email' })처럼 전략 옵션과 실제 요청 필드를 함께 바꿔야 합니다.

단계 4: 인증 모듈(AuthModule) 생성

인증 관련 로직을 하나의 모듈로 묶습니다.

src/auth/auth.module.ts
import { Module } from '@nestjs/common';
import { AuthService } from './auth.service';
import { LocalStrategy } from './strategies/local.strategy';
import { UsersModule } from '../users/users.module'; // UsersModule 임포트
import { PassportModule } from '@nestjs/passport';
import { AuthController } from './auth.controller';

@Module({
  imports: [UsersModule, PassportModule],
  providers: [AuthService, LocalStrategy],
  controllers: [AuthController],
})
export class AuthModule {}
  • AuthService: 사용자 조회 결과와 자격 증명을 검증하고 안전한 사용자 값을 만듭니다.
  • LocalStrategy: Passport가 전달한 로컬 자격 증명을 AuthService에 위임합니다.
  • PassportModule: Passport 전략과 Nest Guard의 통합을 제공합니다.
단계 5: 인증 서비스(AuthService) 작성

AuthService는 저장소 조회와 자격 증명 검증을 하나의 인증 유스케이스로 묶습니다. 검증 성공 값에서는 비밀번호처럼 요청 처리에 필요 없는 민감 필드를 제거합니다.

src/auth/auth.service.ts
import { Injectable } from '@nestjs/common';
import { UsersService } from '../users/users.service';

@Injectable()
export class AuthService {
  constructor(private usersService: UsersService) {}

  async validateUser(username: string, pass: string): Promise<any> {
    const user = await this.usersService.findOne(username);
    if (user && user.password === pass) {
      const { password, ...result } = user;
      return result;
    }
    return null;
  }

  // 실제 JWT 발급 로직은 다음 절에서 다룰 예정입니다.
  async login(user: any) {
    return user;
  }
}

여기서도 평문 비교는 실행 흐름을 작게 보여 주기 위한 예제일 뿐입니다. 사용자 없음과 비밀번호 불일치를 같은 null 및 401 경계로 합치면 응답 내용만으로 계정 존재 여부를 구분하기 어려워집니다. 실제 서비스에서는 비밀번호 해시 검증 경로의 타이밍 차이와 로그인 시도 제한도 함께 점검해야 합니다. 반면 데이터베이스 장애처럼 예상하지 못한 오류까지 null로 바꾸면 안 됩니다. 그런 오류는 그대로 전파해 Nest의 예외 계층과 관측 시스템이 처리하게 합니다.

단계 6: 로컬 가드와 인증 컨트롤러 작성

클라이언트의 로그인 요청을 처리하는 엔드포인트를 만듭니다.

전략 이름을 여러 라우트에 반복하지 않도록 AuthGuard('local')을 상속한 가드를 만들고 로그인 라우트에 적용합니다.

src/auth/guards/local-auth.guard.ts
import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';

@Injectable()
export class LocalAuthGuard extends AuthGuard('local') {}
src/auth/auth.controller.ts
import { Controller, Post, Request, UseGuards } from '@nestjs/common';
import { AuthService } from './auth.service';
import { LocalAuthGuard } from './guards/local-auth.guard';

@Controller('auth')
export class AuthController {
  constructor(private authService: AuthService) {}

  @UseGuards(LocalAuthGuard)
  @Post('login')
  async login(@Request() req) {
    return this.authService.login(req.user);
  }
}

Nest의 Passport 가드는 기본적으로 세션을 사용하지 않습니다. 성공한 validate() 반환값을 현재 요청의 req.user에 넣는 것과 로그인 상태를 다음 요청까지 유지하는 것은 별도 결정입니다. JWT 방식이라면 login()에서 토큰을 발급하고 이후 요청의 JWT 전략이 토큰을 검증합니다. 서버 세션 방식이라면 세션 미들웨어와 Passport 세션 복원 미들웨어, PassportModule.register({ session: true }), 직렬화·역직렬화 구현이 추가로 필요합니다. 세션용 로그인 가드의 canActivate()에서는 먼저 await super.canActivate(context)request.user를 설정한 뒤 await super.logIn(request)을 호출해야 합니다.

단계 7: Root 모듈에 AuthModule 임포트

AppModuleAuthModule을 임포트하여 애플리케이션이 인증 기능을 사용할 수 있도록 합니다.

src/app.module.ts
import { Module } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { AuthModule } from './auth/auth.module';

@Module({
  imports: [AuthModule],
  controllers: [AppController],
  providers: [AppService],
})
export class AppModule {}

테스트해보기

애플리케이션을 실행합니다.

npm run start:dev

Postman이나 curl을 사용하여 http://localhost:3000/auth/login으로 POST 요청을 보냅니다.

  • Bodyx-www-form-urlencoded 또는 raw (JSON) 형태로 usernamepassword를 포함해야 합니다.
    {
        "username": "testuser",
        "password": "password123"
    }
  • x-www-form-urlencoded를 사용하는 경우: username=testuser password=password123

성공 시: { "userId": 1, "username": "testuser", "roles": ["user"] }와 유사한 응답을 받게 됩니다 (AuthService의 login 메서드 반환 값).

실패 시 (존재하지 않는 사용자 또는 잘못된 비밀번호): 라우트 핸들러는 실행되지 않고 HTTP 401 Unauthorized 응답을 받습니다. 정확한 응답 본문은 Nest 버전, HTTP 어댑터, 전역 예외 필터 설정에 따라 달라질 수 있습니다.


마지막으로 로컬 인증을 구현할 때 각 파일이 맡는 책임과 실패 응답 경계를 한 번 더 점검해 보겠습니다.

Nest Passport 로컬 인증의 Guard, Strategy, AuthService, UsersService, Controller 책임과 현재 요청의 req.user 이후 세션 또는 JWT로 로그인 상태를 유지하는 경계 비교

Nest · Authentication Boundaries

검증과 로그인 상태를 분리한다

각 구성요소는 한 경계만 소유합니다. 로컬 전략의 성공은 현재 요청에 사용자를 제공할 뿐이며, 다음 요청의 인증 상태는 세션이나 JWT 중 선택한 방식으로 따로 이어갑니다.

로컬 인증 구성요소별 책임과 실패 경계
구성요소 소유하는 책임 반환·오류 경계
Guard LocalAuthGuardlocal 전략을 실행하고 성공 결과를 현재 요청의 req.user에 넣습니다. 사용자 결과가 없으면 401로 중단합니다. 전략·서비스가 던진 실행 오류는 원래 예외로 전파합니다.
Strategy LocalStrategy가 요청 필드 계약을 설정하고 자격 증명 검증을 AuthService에 위임합니다. 안전한 사용자를 반환하거나, 검증 결과가 없으면 UnauthorizedException을 던집니다.
Auth use case AuthService가 사용자 레코드와 제출된 비밀번호를 검증하고 민감 필드를 제거합니다. 잘못된 자격 증명은 한 가지 실패 결과로 합치고, 저장소 장애 같은 실행 오류는 숨기지 않습니다.
User store UsersService가 사용자 저장소를 조회하고 저장된 사용자 레코드를 반환합니다. HTTP 상태나 Passport 정책을 결정하지 않으며, 저장소 오류를 호출자에게 전파합니다.
Route AuthController가 검증이 끝난 req.user로 로그인 응답 또는 토큰 발급 유스케이스를 호출합니다. 비밀번호를 다시 비교하지 않으며, 가드가 실패하면 컨트롤러까지 제어가 오지 않습니다.

LocalAuthGuard

local 전략을 실행해 안전한 사용자를 현재 요청의 req.user에 넣습니다. 사용자 결과가 없으면 401로 중단하고, 다른 실행 오류는 원래 예외로 전파합니다.

LocalStrategy

요청 필드를 전략 인자로 바꾸고 AuthService에 검증을 위임합니다. 검증 결과가 없으면 UnauthorizedException을 던집니다.

AuthService

사용자 레코드와 제출된 비밀번호를 검증하고 민감 필드를 제거합니다. 잘못된 자격 증명과 저장소 장애를 같은 실패로 숨기지 않습니다.

UsersService

사용자 저장소 조회만 맡습니다. HTTP 상태나 Passport 정책을 결정하지 않고 저장소 오류를 호출자에게 전파합니다.

Controller

검증이 끝난 req.user를 소비해 응답을 만듭니다. 비밀번호를 다시 비교하지 않으며 가드가 실패하면 실행되지 않습니다.

서버 세션으로 이어가기

서버가 로그인 상태를 보관하고 브라우저는 세션 쿠키를 보냅니다. 세션 및 Passport 세션 복원 미들웨어, PassportModule.register(){ session: true } 설정, 직렬화·역직렬화 구현이 필요합니다.

세션 로그인 가드는 먼저 super.canActivate()await하여 request.user를 설정한 뒤 super.logIn(request)으로 세션에 기록해야 합니다.

JWT로 이어가기

AuthService가 로컬 인증 성공 뒤 access token을 발급하고, 클라이언트는 보호 요청마다 Bearer token을 보냅니다.

JwtAuthGuard와 JWT 전략이 매 요청에서 서명·만료를 검증하고 새 req.user를 만듭니다. Passport 로그인 세션은 사용하지 않습니다.

세션과 JWT는 자격 증명 검증 뒤의 상태 유지 방식입니다. 둘 중 무엇을 택해도 비밀번호 검증 책임은 AuthService에, 사용자 조회 책임은 UsersService에 남습니다.


로컬 인증의 핵심은 AuthGuard가 전략을 실행하고, LocalStrategy가 자격 증명 검증을 AuthService에 위임하며, 성공한 안전한 사용자 값만 현재 요청의 req.user로 넘기는 경계를 분명히 두는 것입니다.

다음 절에서는 이 로그인 결과를 매 요청에서 다시 확인할 수 있도록 JWT(JSON Web Tokens) 기반 인증으로 확장합니다.