Passport를 이용한 인증 구현
인증과 권한 부여를 구분하고 Passport 로컬 전략과 가드로 아이디·비밀번호 로그인 흐름을 구현합니다.
3장에서는 데이터를 안전하게 저장하고 관리하는 방법을 다뤘습니다. 4장에서는 웹 애플리케이션의 핵심인 인증(authentication)과 권한 부여(authorization)를 다룹니다. 사용자 로그인, 개인정보 보호, 기능별 접근 제어는 웹 서비스라면 반드시 필요한 주제입니다.
NestJS는 Node.js 생태계의 대표적인 인증 미들웨어인 Passport와 잘 통합되어 있어, 여러 인증 방식을 비교적 쉽게 구현할 수 있습니다. 이 글에서는 Passport의 기본 개념을 짚고, 아이디와 비밀번호로 로그인하는 로컬 인증을 NestJS에 적용해 봅니다.
인증과 권한 부여
두 용어는 자주 섞여 쓰이지만 서로 다른 개념입니다. 인증은 상대가 누구인지 확인하는 과정으로, 사용자가 주장하는 신원이 진짜인지 확인합니다. 아이디와 비밀번호로 로그인하는 것이 대표적인 예입니다. 권한 부여는 인증된 사용자가 무엇을 할 수 있는지 정하는 과정으로, 특정 리소스나 기능에 접근할 권한이 있는지 확인합니다. 일반 사용자는 게시물을 읽을 수 있지만 관리자만 삭제할 수 있다는 규칙이 그 예입니다.
이 글은 주로 인증에 초점을 맞추고, 권한 부여는 역할 기반 접근 제어 (RBAC) 구현에서 자세히 다룹니다.
Passport를 쓰는 이유
Passport는 Node.js를 위한 인증 미들웨어로, 로컬 인증, JWT, OAuth, 소셜 로그인 같은 여러 인증 방식을 웹 애플리케이션에 쉽게 붙일 수 있게 설계되었습니다. 인증 방식의 실행과 성공·실패의 전달을 공통 인터페이스로 묶어, 인증 코드를 라우팅이나 핵심 업무 로직과 분리하게 해 줍니다.
Passport는 전략(strategy)이라는 단위로 인증 방식을 플러그인처럼 제공하므로, 아이디·비밀번호, JWT, OAuth2(Google, GitHub 등), OpenID 같은 수많은 전략 가운데 필요한 것을 골라 쓸 수 있습니다. 특정 방식에 묶이지 않고 직접 전략을 만들거나 여러 전략을 조합할 수도 있습니다. @nestjs/passport 패키지는 Passport 전략을 Nest의 의존성 주입과 가드 구조에 연결해 줍니다.
로컬 인증 구현하기
Passport 로컬 인증 요청 흐름
guard·local 전략·Auth/Users 검증·역순으로 돌아오는 안전한 사용자·req.user·handler 또는 401의 실제 요청 순서이다.
Nest Passport · 현재 요청의 인증
로컬 검증 성공이 현재 요청의 req.user를 만듭니다
Local guard는 local 전략을 실행하고, 전략은 AuthService에 검증을 맡깁니다. 안전한 사용자만 현재 요청에 붙습니다. 자격 증명 실패는 401이며 저장소 장애 같은 실행 오류는 원래 예외로 전달합니다.
성공 경로
가드가 전략을 실행합니다.
LocalAuthGuard가 Passport의local전략을 시작하면, Passport Local 구현이 요청에서 기본 필드username·password를 읽어LocalStrategy.validate()에 전달합니다.검증을 위임합니다.
LocalStrategy.validate()는AuthService.validateUser()를 호출하고, 서비스는UsersService.findOne()으로 저장된 사용자 레코드를 조회합니다.안전한 사용자만 반환합니다. 자격 증명이 맞으면 민감 필드를 뺀 사용자가 전략과 가드로 돌아옵니다. 가드는 그 값을 현재 요청의
req.user에 넣고 라우트 핸들러를 통과시킵니다.
인증 실패와 실행 오류
사용자가 없거나 비밀번호가 다르면 AuthService는 null을 반환하고 전략은 UnauthorizedException을 던집니다. 가드는 HTTP 401로 요청을 끝내며 핸들러는 실행되지 않습니다.
저장소 장애 같은 예상하지 못한 오류는 잘못된 자격 증명으로 바꾸지 않습니다. 원래 오류를 전파해 Nest의 예외 처리와 관측 경계에서 다룹니다.
req.user는 이 요청의 인증 결과입니다. 세션이나 JWT처럼 다음 요청에도 사용할 로그인 상태는 이 흐름 뒤에서 별도로 구성합니다.
이 예의 조건
- 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
- 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
- 요청 파이프라인이 LocalAuthGuard를 호출하면 가드가 local 전략을 실행한다. LocalStrategy는 AuthService에 검증을 위임하고 AuthService는 UsersService에서 사용자를 찾는다. 성공하면 안전한 사용자가 역순으로 돌아와 가드가 req.user를 설정하고 핸들러를 실행하며, 실패하면 전략이 UnauthorizedException을 던져 401로 종료한다.
- 가드가 local 전략을 실행하고 전략은 AuthService로 자격 증명 검증을 위임한다. 서비스는 UsersService로 조회하여 비밀번호를 비교하고 성공한 안전한 사용자만 반환한다. 성공이 가드로 돌아오면 현재 요청의 req.user로 handler가 실행된다. 사용자 없음·불일치는 null과 401로 중단된다. 예상하지 못한 저장소 오류는 원래 예외로 전달한다.
관계도의 보충 설명
| 순번 | 원문 표시 |
|---|---|
| 1 | Passport 로컬 인증 요청 흐름 |
| 31 | 가드가 local 전략을 실행하고 전략은 AuthService로 자격 증명 검증을 위임한다. 서비스는 UsersService로 조회하여 비밀번호를 비교하고 성공한 안전한 사용자만 반환한다. 성공이 가드로 돌아오면 현재 요청의 req.user로 handler가 실행된다. 사용자 없음·불일치는 null과 401로 중단된다. 예상하지 못한 저장소 오류는 원래 예외로 전달한다. |
| 장면 | 관찰 값 | 전환 이유 |
|---|---|---|
| Guard와 전략 진입 LocalAuthGuard가 local 전략을 실행합니다. | Guard: LocalAuthGuard · 전략: local · handler: 아직 미실행 | 자격 증명 검증이 handler보다 먼저 시작됩니다. |
| 서비스로 검증 위임 LocalStrategy가 AuthService에 위임하고 UsersService에서 사용자를 찾습니다. | 조회: UsersService · 검증: AuthService · 전략: LocalStrategy | 조회 결과가 검증 결과로 돌아옵니다. |
| 사용자 전달 또는 거부 검증에 성공한 안전한 사용자만 req.user에 넣어 handler를 실행하고 사용자 없음·자격 증명 불일치는 401로 끝납니다. 예상하지 못한 저장소 오류는 원래 예외를 전파합니다. | 성공: 현재 req.user·handler 진입 · 사용자 없음·자격 증명 불일치: null→UnauthorizedException·401 · 예상하지 못한 저장소 오류: 원래 예외 전달, 401로 바꾸지 않음 · 세션 발급: 이 경로만으로 없음 | 정상 인증과 자격 증명 거절 및 기반 시설 예외를 별도 종료 경계로 유지합니다. |
Passport 로컬 인증 요청 흐름
guard·local 전략·Auth/Users 검증·역순으로 돌아오는 안전한 사용자·req.user·handler 또는 401의 실제 요청 순서이다.
Nest Passport · 현재 요청의 인증
로컬 검증 성공이 현재 요청의 req.user를 만듭니다
Local guard는 local 전략을 실행하고, 전략은 AuthService에 검증을 맡깁니다. 안전한 사용자만 현재 요청에 붙습니다. 자격 증명 실패는 401이며 저장소 장애 같은 실행 오류는 원래 예외로 전달합니다.
성공 경로
가드가 전략을 실행합니다.
LocalAuthGuard가 Passport의local전략을 시작하면, Passport Local 구현이 요청에서 기본 필드username·password를 읽어LocalStrategy.validate()에 전달합니다.검증을 위임합니다.
LocalStrategy.validate()는AuthService.validateUser()를 호출하고, 서비스는UsersService.findOne()으로 저장된 사용자 레코드를 조회합니다.안전한 사용자만 반환합니다. 자격 증명이 맞으면 민감 필드를 뺀 사용자가 전략과 가드로 돌아옵니다. 가드는 그 값을 현재 요청의
req.user에 넣고 라우트 핸들러를 통과시킵니다.
인증 실패와 실행 오류
사용자가 없거나 비밀번호가 다르면 AuthService는 null을 반환하고 전략은 UnauthorizedException을 던집니다. 가드는 HTTP 401로 요청을 끝내며 핸들러는 실행되지 않습니다.
저장소 장애 같은 예상하지 못한 오류는 잘못된 자격 증명으로 바꾸지 않습니다. 원래 오류를 전파해 Nest의 예외 처리와 관측 경계에서 다룹니다.
req.user는 이 요청의 인증 결과입니다. 세션이나 JWT처럼 다음 요청에도 사용할 로그인 상태는 이 흐름 뒤에서 별도로 구성합니다.
이 예의 조건
- 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
- 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
- 요청 파이프라인이 LocalAuthGuard를 호출하면 가드가 local 전략을 실행한다. LocalStrategy는 AuthService에 검증을 위임하고 AuthService는 UsersService에서 사용자를 찾는다. 성공하면 안전한 사용자가 역순으로 돌아와 가드가 req.user를 설정하고 핸들러를 실행하며, 실패하면 전략이 UnauthorizedException을 던져 401로 종료한다.
- 가드가 local 전략을 실행하고 전략은 AuthService로 자격 증명 검증을 위임한다. 서비스는 UsersService로 조회하여 비밀번호를 비교하고 성공한 안전한 사용자만 반환한다. 성공이 가드로 돌아오면 현재 요청의 req.user로 handler가 실행된다. 사용자 없음·불일치는 null과 401로 중단된다. 예상하지 못한 저장소 오류는 원래 예외로 전달한다.
관계도의 보충 설명
| 순번 | 원문 표시 |
|---|---|
| 1 | Passport 로컬 인증 요청 흐름 |
| 31 | 가드가 local 전략을 실행하고 전략은 AuthService로 자격 증명 검증을 위임한다. 서비스는 UsersService로 조회하여 비밀번호를 비교하고 성공한 안전한 사용자만 반환한다. 성공이 가드로 돌아오면 현재 요청의 req.user로 handler가 실행된다. 사용자 없음·불일치는 null과 401로 중단된다. 예상하지 못한 저장소 오류는 원래 예외로 전달한다. |
| 장면 | 관찰 값 | 전환 이유 |
|---|---|---|
| Guard와 전략 진입 LocalAuthGuard가 local 전략을 실행합니다. | Guard: LocalAuthGuard · 전략: local · handler: 아직 미실행 | 자격 증명 검증이 handler보다 먼저 시작됩니다. |
| 서비스로 검증 위임 LocalStrategy가 AuthService에 위임하고 UsersService에서 사용자를 찾습니다. | 조회: UsersService · 검증: AuthService · 전략: LocalStrategy | 조회 결과가 검증 결과로 돌아옵니다. |
| 사용자 전달 또는 거부 검증에 성공한 안전한 사용자만 req.user에 넣어 handler를 실행하고 사용자 없음·자격 증명 불일치는 401로 끝납니다. 예상하지 못한 저장소 오류는 원래 예외를 전파합니다. | 성공: 현재 req.user·handler 진입 · 사용자 없음·자격 증명 불일치: null→UnauthorizedException·401 · 예상하지 못한 저장소 오류: 원래 예외 전달, 401로 바꾸지 않음 · 세션 발급: 이 경로만으로 없음 | 정상 인증과 자격 증명 거절 및 기반 시설 예외를 별도 종료 경계로 유지합니다. |
그림은 요청 처리 흐름이 LocalAuthGuard를 호출하면, 가드가 local 전략을 실행하고, LocalStrategy가 AuthService에 검증을 맡기며, AuthService가 UsersService에서 사용자를 찾는 순서입니다. 검증에 성공하면 비밀번호를 뺀 안전한 사용자가 거꾸로 돌아와 가드가 req.user를 설정하고 핸들러를 실행합니다. 사용자가 없거나 비밀번호가 틀리면 전략이 UnauthorizedException을 던져 401로 끝나며, 저장소 장애처럼 예상하지 못한 오류는 원래 예외 그대로 전달됩니다.
패키지 설치
npm install @nestjs/passport passport passport-local
npm install --save-dev @types/passport-localpassport는 전략을 실행하는 본체이고, passport-local은 아이디와 비밀번호로 인증하는 전략이며, @nestjs/passport는 이 둘을 Nest의 가드와 의존성 주입에 연결합니다. @types/passport-local은 타입 정의만 담고 있으므로 개발 의존성으로 설치합니다.
사용자 서비스 준비
사용자 정보를 다루는 UsersModule과 UsersService가 이미 있다고 가정합니다. UsersModule은 UsersService를 providers에 등록하고 exports로 내보내야, 뒤의 AuthModule에서 주입받을 수 있습니다.
아래 서비스는 3장의 DB CRUD와 별개로 인증 흐름을 읽기 위한 배열 예제입니다. findOne(username)과 userId·username·password·roles 필드를 쓰므로, 3장의 사용자 저장소를 연결하려면 먼저 조회 메서드와 반환 필드를 맞춰야 합니다. 파일 이름이 같다고 두 서비스를 합치거나 기존 DB 모듈을 지우지 않습니다. 이 UsersService는 사용자 저장소를 감싸 username으로 레코드를 찾는 일만 맡고, 자격 증명이 맞는지는 뒤의 AuthService가 판단합니다.
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);
}
}이 배열과 평문 비밀번호는 인증 흐름만 확인하기 위한 예제입니다. 실제 서비스에서는 비밀번호 원문을 저장하거나 ===로 비교하지 말고, 검증된 비밀번호 해싱 라이브러리로 저장된 해시를 검증해야 합니다.
LocalStrategy 구현
Passport는 인증 로직을 전략으로 감쌉니다. 로컬 인증을 위한 LocalStrategy를 만듭니다.
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은 기본적으로 요청 본문의 username과 password 필드를 읽습니다. 이메일 같은 다른 이름을 쓰려면 super({ usernameField: 'email' })처럼 전략의 옵션과 실제 요청 필드를 함께 바꿔야 합니다.
AuthModule 만들기
인증에 관련된 코드를 하나의 모듈로 묶습니다. 아래 모듈에서는 imports와 providers를 봅니다. LocalStrategy는 providers에 등록되어 인스턴스가 만들어질 때 Passport에 local이라는 이름으로 등록되므로, 이 등록이 빠지면 가드가 실행할 전략을 찾지 못합니다. 또 UsersModule을 가져와야 AuthService가 그 모듈이 내보낸 UsersService를 주입받을 수 있습니다.
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 가드의 통합을 제공합니다.
AuthService 작성
AuthService는 저장소 조회와 자격 증명 검증을 하나의 인증 유스케이스로 묶습니다. 검증에 성공한 값에서는 비밀번호처럼 요청 처리에 필요 없는 민감한 필드를 뺍니다.
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의 예외 계층과 관측 시스템이 다루게 합니다.
로컬 가드와 인증 컨트롤러
전략 이름을 여러 경로에서 반복하지 않도록 AuthGuard('local')을 상속한 가드를 만들고, 로그인 경로에 적용합니다. AuthGuard('local')은 local이라는 이름으로 등록된 전략을 실행하는 가드 클래스를 돌려줍니다. 이를 상속한 빈 클래스를 두면 컨트롤러에는 문자열 대신 LocalAuthGuard만 적으면 되고, 전략 이름의 오타도 이 파일 한 곳에서만 생길 수 있습니다. 컨트롤러의 login()은 가드가 검증을 마친 뒤에만 실행되므로, 핸들러 안에서는 비밀번호를 다시 확인하지 않고 req.user를 그대로 넘깁니다.
import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
@Injectable()
export class LocalAuthGuard extends AuthGuard('local') {}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: { user: unknown }) {
return this.authService.login(req.user);
}
}NestJS 12의 새 프로젝트는 TypeScript의 strict 모드가 켜져 있으므로, @Request() req처럼 타입 없이 매개변수를 쓰면 암시적 any 오류가 납니다. 여기서는 가드가 넣어 준 user만 쓰므로 { user: unknown }으로 필요한 부분의 타입만 적었습니다.
Nest의 Passport 가드는 기본적으로 세션을 쓰지 않습니다. 성공한 validate()의 반환값을 현재 요청의 req.user에 넣는 것과, 로그인 상태를 다음 요청까지 유지하는 것은 별개의 결정입니다. JWT 방식이라면 login()에서 토큰을 발급하고 이후 요청의 JWT 전략이 토큰을 검증합니다. 서버 세션 방식이라면 세션 미들웨어와 Passport의 세션 복원 미들웨어, PassportModule.register({ session: true }), 직렬화·역직렬화 구현이 더 필요하며, 세션용 로그인 가드의 canActivate()에서는 await super.canActivate(context)로 request.user를 설정한 뒤 await super.logIn(request)를 호출해야 합니다.
루트 모듈에 AuthModule 추가
지금의 AppModule의 imports에 AuthModule을 추가합니다. 아래는 추가할 위치를 보여 주는 발췌이며, 기존 루트 모듈을 통째로 바꾸는 코드가 아닙니다. 고른 DB 모듈과 다른 imports·controllers·providers는 그대로 두고, Prisma 글처럼 ConfigModule.forRoot({ isGlobal: true })를 이미 등록했다면 그것도 지우지 않습니다. 다음 글의 JWT 설정은 이 전역 ConfigService 등록을 쓰며, 아직 등록하지 않았다면 다음 글에서 추가합니다.
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 {}테스트해 보기
이 글의 요청 주소와 그림은 전역 접두사 없이 적었습니다. 모듈, 컨트롤러, 서비스의 기본 개념의 main.ts처럼 app.setGlobalPrefix('api')를 유지했다면 로그인 주소는 http://localhost:3000/api/auth/login이고, 접두사가 없는 구성에서는 /auth/login입니다. 404가 나오면 자격 증명보다 먼저 지금의 main.ts의 접두사와 실제 포트를 확인합니다.
npm run start:dev로 애플리케이션을 실행하고, Postman이나 curl로 로그인 주소에 POST 요청을 보냅니다. 본문은 JSON의 {"username": "testuser", "password": "password123"}이나, x-www-form-urlencoded의 username=testuser&password=password123 형식으로 보냅니다.
성공하면 AuthService.login()이 돌려준 { "userId": 1, "username": "testuser", "roles": ["user"] }와 같은 응답을 받습니다. 사용자가 없거나 비밀번호가 틀리면 라우트 핸들러가 실행되지 않고 401 응답을 받으며, 정확한 응답 본문은 Nest 버전, HTTP 어댑터, 전역 예외 필터 설정에 따라 다를 수 있습니다.
Nest Passport · 역할과 로그인 상태
검증 책임을 나누고, 다음 요청의 상태는 세션·JWT로 이어갑니다
Guard·Strategy·AuthService·UsersService·Controller는 서로 다른 책임을 맡습니다. 현재 req.user 뒤에 세션 복원이나 JWT 발급·검증을 별도로 구성해야 다음 요청을 인증할 수 있습니다.
Guard
LocalAuthGuard가 local 전략을 실행하고 성공 결과를 현재 요청의 req.user에 넣습니다.Strategy
LocalStrategy가 요청 필드 계약을 설정하고 자격 증명 검증을 AuthService에 위임합니다.UnauthorizedException을 던집니다.Auth use case
AuthService가 사용자 레코드와 제출된 비밀번호를 검증하고 민감 필드를 제거합니다.User store
UsersService가 사용자 저장소를 조회하고 저장된 사용자 레코드를 반환합니다.Route
AuthController가 검증이 끝난 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에 남습니다.
이 표는 로컬 인증에서 각 파일이 맡는 일과 반환·오류의 처리 방식을 정리합니다. 가드는 전략을 실행해 성공한 결과를 req.user에 넣고, 전략은 요청 필드의 규칙을 정해 검증을 AuthService에 맡기며, AuthService는 자격 증명을 검증해 민감한 필드를 뺍니다. UsersService는 저장소를 조회할 뿐 HTTP 상태를 정하지 않고, 컨트롤러는 비밀번호를 다시 비교하지 않습니다. 아래 두 칸은 다음 요청까지 로그인 상태를 이어가려면 서버 세션이나 JWT를 따로 구성해야 한다는 점을 보여 줍니다.
더 깊이: 평문 비교를 대신할 비밀번호 해시
비밀번호 저장에는 SHA-256 같은 범용 해시가 아니라, 추측 공격의 비용을 높이려고 일부러 느리게 설계한 비밀번호 해시 함수를 씁니다. OWASP 비밀번호 저장 가이드는 첫 번째 선택으로 Argon2id를 꼽고, 최소 설정으로 메모리 19MiB, 반복 2회, 병렬도 1을 제시합니다. Argon2id를 쓸 수 없다면 scrypt를 N=2^17, r=8, p=1 이상으로 쓰고, bcrypt는 기존 시스템에서 작업 계수(work factor) 10 이상으로 쓰며, FIPS-140 준수가 필요하면 PBKDF2-HMAC-SHA256을 60만 회 이상 반복하라고 권합니다.
bcrypt에는 길이 제한도 있습니다. 대부분의 구현은 비밀번호의 앞 72바이트만 읽으므로, 그보다 긴 비밀번호는 뒷부분이 달라도 같은 해시로 검증됩니다. UTF-8에서 한글 한 글자는 3바이트이므로, 한글로만 된 비밀번호는 24글자에서 이미 이 한도에 닿습니다.
NestJS 12 문서는 인증 장을 아직 0.x 버전인 @nestjs/authentication 패키지로 설명하고, 이 글이 쓰는 Passport 통합은 별도의 레시피로 안내합니다. 이 패키지의 PasswordHasher는 Node.js 내장 node:crypto의 scrypt를 위의 OWASP 값으로 실행하며, 해시 하나에 128MiB 정도의 메모리를 쓰고 libuv 스레드 풀에서 계산해 이벤트 루프를 막지 않습니다. 또 verify()에 없는 사용자 대신 undefined를 넘기면 가짜 해시를 검사해, 응답 시간만으로 계정의 존재가 드러나지 않게 합니다. 앞에서 짚은 시간 차이 문제를 라이브러리 수준에서 다루는 방식입니다.
정리
- 인증은 상대가 누구인지 확인하고, 권한 부여는 인증된 사용자가 무엇을 할 수 있는지 정합니다.
LocalAuthGuard가local전략을 실행하고,LocalStrategy가AuthService에 검증을 맡겨, 성공한 안전한 사용자만 현재 요청의req.user에 넣습니다.- 자격 증명 실패는 401로 핸들러 전에 멈추고, 저장소 장애 같은 예상하지 못한 오류는 원래 예외로 전달합니다.
- Nest의 Passport 가드는 기본적으로 세션을 쓰지 않으므로, 다음 요청의 인증은 JWT나 세션을 따로 구성해야 합니다.
연습 문제
1. 현재 요청과 로그인 유지
올바른 자격 증명으로 로그인에 성공했습니다. 다음 요청에서 쿠키나 토큰을 보내지 않았는데도 방금 로그인했으니 req.user가 저절로 있다고 판단해도 되나요? 비밀번호가 틀린 요청에서는 AuthController.login()이 실행되나요?
해설 보기
지금의 설정은 Passport 로그인 세션을 만들지 않습니다. 성공한 사용자는 그 요청에만 붙으며, 다음 요청을 인증하려면 다음 글의 JWT나 별도의 세션 복원 경로가 필요합니다.
비밀번호가 틀리면 전략이 401 예외를 던져 가드에서 멈추므로, 로그인 핸들러는 실행되지 않습니다. 저장소 장애는 잘못된 비밀번호와 구분해 원래 오류로 전달합니다.
2. 다른 필드 이름으로 보내면
클라이언트가 {"email": "testuser", "password": "password123"}처럼 username 대신 email 필드로 로그인 요청을 보냈습니다. LocalStrategy의 super()는 옵션 없이 그대로입니다. validate()가 호출되나요? 응답은 어떻게 되나요?
해설 보기
passport-local은 기본적으로 username과 password 필드를 읽는데 username이 없으므로, 자격 증명이 빠졌다고 보고 validate()를 호출하지 않은 채 실패로 끝냅니다. Nest의 AuthGuard는 사용자가 없는 실패를 UnauthorizedException으로 바꾸므로 응답은 401입니다.
비밀번호가 맞아도 필드 이름이 맞지 않으면 실패하므로, 이메일로 로그인하려면 super({ usernameField: 'email' })로 전략의 옵션을 바꾸고 UsersService의 조회 기준도 함께 맞춰야 합니다.
3. 저장소 장애와 잘못된 비밀번호
UsersService.findOne()이 DB 연결 장애로 예외를 던지는 경우와, 사용자는 있지만 비밀번호가 틀려 validateUser()가 null을 돌려주는 경우의 응답을 비교하세요. 별도의 예외 필터는 없습니다. 두 경우를 모두 401로 바꾸면 어떤 문제가 생기나요?
해설 보기
비밀번호가 틀린 경우는 validate()가 UnauthorizedException을 던져 401이 됩니다. DB 장애의 경우는 validateUser()가 예외를 그대로 전파하고, 가드가 그 오류를 다시 던지므로 HttpException이 아닌 오류로 기본 예외 처리에서 500이 됩니다.
장애까지 401로 바꾸면 사용자는 비밀번호를 거듭 바꿔 입력하고, 운영자는 서버 장애를 인증 실패로 오해해 원인을 늦게 찾습니다. 자격 증명 실패와 실행 오류는 다른 응답과 다른 관측 신호로 남겨야 합니다.
다음 글 JWT 기반 인증 시스템 구축에서는 이 로그인 결과로 JWT를 발급하고, 다음 요청마다 그 토큰을 검증하도록 넓힙니다.