JWT 기반 인증 시스템 구축
안녕하세요! 지난 절에서는 Passport.js를 활용하여 기본적인 로컬 인증 시스템을 구축하는 방법을 알아보았습니다. 이제 현대 웹 애플리케이션에서 널리 사용되는 JWT(JSON Web Tokens) 기반 인증 시스템을 NestJS에 통합하는 방법을 심도 있게 다뤄보겠습니다.
JWT는 클라이언트-서버 간의 통신에서 정보를 안전하게 전달하기 위한 간결하고 자체 포함적인(self-contained) 방법입니다. JWT는 주로 인증에 사용되며, 한 번 발급되면 서버는 토큰 자체의 유효성만 검증하여 사용자를 인증할 수 있으므로, 서버에 세션 정보를 저장할 필요가 없어 Stateless(무상태) 한 아키텍처 구현에 매우 유리합니다.
JWT(JSON Web Tokens)란 무엇인가?
JWT는 세 부분으로 구성된 문자열입니다. 각 부분은 .
으로 구분됩니다.
header.payload.signature
Header (헤더): 토큰의 타입(JWT
)과 서명에 사용된 알고리즘(예: HS256
, RS256
)이 포함됩니다.
{
"alg": "HS256",
"typ": "JWT"
}
Payload (페이로드): 클레임(Claim)이라고 불리는 실제 정보가 포함됩니다. 클레임은 사용자 ID, 역할, 만료 시간 등 토큰에 담고 싶은 모든 데이터를 포함할 수 있습니다. 페이로드 정보는 암호화되지 않고 Base64Url로 인코딩만 되므로, 민감한 정보를 직접 넣어서는 안 됩니다.
{
"sub": "1234567890", // subject (주제), 사용자 ID 같은 고유 식별자
"name": "John Doe",
"iat": 1516239022, // issued at (발급 시간)
"exp": 1516242622, // expiration time (만료 시간)
"roles": ["admin", "user"]
}
Signature (서명): 헤더와 페이로드를 Base64Url로 인코딩한 값, 그리고 서버만 알고 있는 비밀 키(Secret Key)를 사용하여 생성된 암호화된 문자열입니다. 서명은 토큰이 위변조되지 않았음을 확인하는 데 사용됩니다. 클라이언트가 토큰을 서버에 전송하면, 서버는 이 서명을 검증하여 토큰의 무결성을 확인합니다.
JWT는 Base64Url로 인코딩된 문자열이기 때문에 디코딩하면 내용을 쉽게 볼 수 있습니다. 따라서 페이로드에 민감한 정보를 절대 직접 넣어서는 안 됩니다. 민감한 정보는 서버에 저장하고, JWT에는 해당 정보를 조회할 수 있는 식별자(예: 사용자 ID)만 포함해야 합니다.
NestJS에서 JWT 기반 인증 시스템 구축하기
JWT 기반 인증 시스템은 주로 다음과 같은 흐름으로 동작합니다.
사용자 로그인: 클라이언트가 아이디/비밀번호를 서버에 전송합니다.
인증 및 토큰 발급: 서버는 사용자 정보를 검증하고, 유효하면 해당 사용자에 대한 JWT를 생성하여 클라이언트에 응답으로 보냅니다.
리소스 접근: 클라이언트는 서버에 보호된 리소스에 접근할 때마다 HTTP Authorization
헤더에 Bearer
스키마와 함께 JWT를 포함하여 전송합니다.
토큰 검증 및 권한 부여: 서버는 수신된 JWT의 유효성을 검증하고(서명, 만료 시간 등), 토큰의 페이로드에 포함된 정보를 기반으로 사용자 인증 및 권한 부여를 수행합니다.
이제 NestJS에서 이 과정을 구현해 보겠습니다.
단계 1: 필요한 패키지 설치
npm install @nestjs/jwt passport-jwt @types/passport-jwt
단계 2: AuthModule
및 AuthService
업데이트
기존 AuthModule
과 AuthService
에 JWT 관련 기능을 추가합니다.
import { Module } from '@nestjs/common';
import { AuthService } from './auth.service';
import { LocalStrategy } from './strategies/local.strategy';
import { JwtStrategy } from './strategies/jwt.strategy'; // 새로 추가할 JWT 전략
import { UsersModule } from '../users/users.module';
import { PassportModule } from '@nestjs/passport';
import { JwtModule } from '@nestjs/jwt'; // JWT 모듈 임포트
import { AuthController } from './auth.controller';
import { ConfigModule, ConfigService } from '@nestjs/config'; // 환경 변수 사용을 위해 추가
@Module({
imports: [
UsersModule,
PassportModule,
ConfigModule, // 환경 변수 모듈 임포트
JwtModule.registerAsync({ // JWT 모듈 비동기 등록 (ConfigService 사용 위함)
imports: [ConfigModule],
useFactory: async (configService: ConfigService) => ({
secret: configService.get<string>('JWT_SECRET'), // 환경 변수에서 JWT 비밀 키 가져오기
signOptions: { expiresIn: '60s' }, // 토큰 만료 시간 (예: 60초)
}),
inject: [ConfigService],
}),
],
providers: [AuthService, LocalStrategy, JwtStrategy], // JwtStrategy 추가
controllers: [AuthController],
exports: [AuthService], // AuthService를 다른 모듈에서 사용하려면 export
})
export class AuthModule {}
JwtModule.registerAsync
: JWT 비밀 키나 만료 시간 같은 설정을 환경 변수에서 가져오기 위해 비동기 방식으로 등록합니다.ConfigService
는@nestjs/config
패키지를 통해 환경 변수를 읽어오는 데 사용됩니다.secret
: JWT 서명에 사용될 비밀 키입니다. 절대 외부에 노출되어서는 안 되며, 강력하고 복잡한 문자열을 사용해야 합니다.expiresIn
: 토큰의 만료 시간입니다. 짧게 설정하면 보안에 유리하지만, 사용자 경험을 위해 적절히 조절해야 합니다.
import { Injectable } from '@nestjs/common';
import { UsersService } from '../users/users.service';
import { JwtService } from '@nestjs/jwt'; // JwtService 임포트
@Injectable()
export class AuthService {
constructor(
private usersService: UsersService,
private jwtService: JwtService, // JwtService 주입
) {}
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) {
const payload = { username: user.username, sub: user.userId, roles: user.roles }; // JWT에 포함할 페이로드
return {
access_token: this.jwtService.sign(payload), // 페이로드로 JWT 생성 및 반환
};
}
}
jwtService.sign(payload)
:JwtModule
에 설정된 비밀 키와 만료 시간을 사용하여 페이로드를 암호화하고 JWT를 생성합니다.
단계 3: JWT 전략 구현 (JwtStrategy
)
클라이언트가 전송한 JWT를 검증하고, 유효한 토큰에서 사용자 정보를 추출하는 전략을 만듭니다.
import { ExtractJwt, Strategy } from 'passport-jwt';
import { PassportStrategy } from '@nestjs/passport';
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; // 환경 변수 사용을 위해 추가
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(private configService: ConfigService) { // ConfigService 주입
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), // Request 헤더의 Bearer 토큰에서 JWT 추출
ignoreExpiration: false, // 만료된 토큰은 무시하지 않고 유효성 검사 실패 처리
secretOrKey: configService.get<string>('JWT_SECRET'), // JWT 서명에 사용된 비밀 키
});
}
// validate 메서드는 토큰이 유효하게 검증되면 호출됩니다.
// 페이로드(payload)를 인자로 받아, 필요한 사용자 정보를 반환합니다.
async validate(payload: any) {
// 실제로는 payload.sub (userId)를 사용하여 데이터베이스에서 사용자 정보를 조회할 수 있습니다.
// 여기서는 간단히 페이로드에 있는 정보를 그대로 반환합니다.
return { userId: payload.sub, username: payload.username, roles: payload.roles };
}
}
jwtFromRequest
: JWT를 요청에서 추출하는 방식을 정의합니다. 여기서는Authorization: Bearer <token>
헤더에서 추출하도록 설정했습니다.ignoreExpiration
:false
로 설정하면 만료된 토큰을 자동으로 거부합니다.secretOrKey
: JWT 발급 시 사용했던 비밀 키와 동일한 키를 사용하여 서명을 검증합니다.
단계 4: 환경 변수 설정
.env
파일에 JWT 비밀 키를 추가합니다. (프로젝트 루트 디렉토리에 .env
파일이 없다면 생성)
# .env
JWT_SECRET=super_secret_jwt_key_that_no_one_knows_but_me
주의: JWT_SECRET
값은 실제 운영 환경에서는 매우 복잡하고 예측 불가능한 문자열로 설정해야 하며, Git 저장소에 직접 커밋되지 않도록 .gitignore
에 .env
를 추가하는 것이 필수입니다.
단계 5: 인증 컨트롤러(AuthController
) 업데이트
login
엔드포인트는 JWT를 반환하도록 수정하고, 보호된 라우트(예: 사용자 프로필 조회)를 추가합니다.
import { Controller, Post, Request, UseGuards, Get } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { AuthService } from './auth.service';
@Controller('auth')
export class AuthController {
constructor(private authService: AuthService) {}
@UseGuards(AuthGuard('local')) // 로컬 전략을 사용하여 로그인 처리
@Post('login')
async login(@Request() req) {
// req.user는 LocalStrategy.validate()에서 반환된 사용자 정보입니다.
return this.authService.login(req.user); // JWT 발급
}
// 'jwt' 전략을 사용하여 보호된 리소스에 접근하는 예시
@UseGuards(AuthGuard('jwt')) // JWT 전략을 사용하여 요청을 보호합니다.
@Get('profile')
getProfile(@Request() req) {
// req.user는 JwtStrategy.validate()에서 반환된 사용자 정보입니다.
return req.user;
}
}
JWT 인증 시스템 테스트해보기
애플리케이션을 실행합니다. npm run start:dev
로그인 요청 (JWT 발급):
Postman 등을 사용하여 http://localhost:3000/auth/login
으로 POST
요청을 보냅니다.
Body (JSON)
{
"username": "testuser",
"password": "password123"
}
성공적으로 로그인하면 다음과 유사한 JWT를 응답으로 받게 됩니다.
{
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6InRlc3R1c2VyIiwic3ViIjoxLCJyb2xlcyI6WyJ1c2VyIl0sImlhdCI6MTY3ODg4NjQwMCwiZXhwIjoxNjc4ODg2NDYwfQ.YOUR_JWT_TOKEN_HERE"
}
이 access_token
값을 복사해 둡니다.
보호된 리소스 접근 (JWT 사용):
새로운 요청을 생성하여 http://localhost:3000/auth/profile
로 GET
요청을 보냅니다.
Headers 탭에서 다음과 같이 Authorization
헤더를 추가합니다.
- Key:
Authorization
- Value:
Bearer <복사한 access_token 값>
(예:Bearer eyJhbGciOiJIUzI1Ni...
)
성공적으로 접근하면 다음과 유사한 사용자 정보가 포함된 응답을 받게 됩니다.
{
"userId": 1,
"username": "testuser",
"roles": ["user"],
"iat": 1678886400,
"exp": 1678886460
}
토큰 만료 확인: JwtModule
에 설정한 expiresIn
시간(예: 60초)이 지난 후 다시 profile
엔드포인트에 요청을 보내면, 401 Unauthorized
에러를 받게 됩니다.
이것으로 NestJS에서 JWT 기반 인증 시스템을 구축하는 과정을 마쳤습니다. JWT는 무상태성을 통해 서버의 부담을 줄이고 확장성을 높이는 데 기여합니다. 하지만 토큰 탈취에 대한 방어, 만료 시간 관리, 토큰 재발급(Refresh Token) 등의 추가적인 보안 고려 사항이 필요합니다.
다음 절에서는 인증된 사용자의 권한을 관리하여 특정 리소스에 대한 접근을 제어하는 권한 부여(Authorization) 방법에 대해 알아보겠습니다.