NestJS JWT 모듈
웹 애플리케이션에서 인증을 구현할 때 가장 많이 쓰이는 방식이 JWT(JSON Web Token)다. 세션 기반 인증은 서버에 상태를 저장해야 하고, 서버가 여러 대로 확장되면 세션 동기화 문제가 생긴다. JWT는 토큰 자체에 사용자 정보를 담아서 서버가 상태를 관리할 필요 없이 토큰만 검증하면 되기 때문에, 이런 문제를 구조적으로 회피한다.
NestJS에서는 @nestjs/jwt 패키지가 JWT 발급과 검증을 담당한다. 내부적으로 Node.js의 jsonwebtoken 라이브러리를 사용하지만, NestJS의 모듈 시스템과 DI에 맞게 감싸서 제공한다.
JWT 기본 구조
JWT는 .으로 구분된 세 부분으로 구성된다.
xxxxx.yyyyy.zzzzz
Header.Payload.Signature
- Header: 토큰 타입(JWT)과 서명 알고리즘(HS256 등)
- Payload: 실제 데이터(claims). 사용자 ID, 만료 시간 등
- Signature: Header + Payload를 비밀키로 서명한 값
Payload는 Base64로 인코딩된 것이지 암호화된 게 아니다. 누구나 디코딩해서 내용을 볼 수 있다. 따라서 비밀번호 같은 민감 정보를 Payload에 넣으면 안 된다. Signature가 보장하는 건 "이 토큰이 변조되지 않았다"는 무결성이지, 내용의 비밀성이 아니다.
설치와 기본 설정
npm install @nestjs/jwt
JwtModule을 AuthModule에 등록하면 해당 모듈 범위에서 JwtService를 주입받아 사용할 수 있다.
import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
@Module({
imports: [
JwtModule.register({
secret: 'my-secret-key',
signOptions: { expiresIn: '1h' },
}),
],
})
export class AuthModule {}
register()에 전달하는 옵션이 모듈 전체의 기본값이 된다. 개별 sign()이나 verify() 호출 시 옵션을 오버라이드할 수 있다.
주요 등록 옵션
| 옵션 | 설명 |
|---|---|
secret | HMAC 대칭키 (HS256/HS384/HS512) |
privateKey | RSA/EC 비대칭 개인키 (RS256 등) |
publicKey | RSA/EC 비대칭 공개키 (검증용) |
signOptions | 기본 서명 옵션 (expiresIn, algorithm 등) |
verifyOptions | 기본 검증 옵션 (algorithms, ignoreExpiration 등) |
secretOrKeyProvider | 요청마다 동적으로 키를 결정하는 함수 |
비동기 설정: registerAsync
실무에서 JWT 비밀키는 하드코딩하지 않는다. 환경 변수나 설정 서비스에서 가져와야 하는데, 이럴 때 registerAsync()를 사용한다.
JwtModule.registerAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
secret: config.get<string>('JWT_SECRET'),
signOptions: { expiresIn: config.get<string>('JWT_EXPIRES_IN', '1h') },
}),
})
useFactory에서 다른 서비스를 주입받아 동적으로 옵션을 구성한다. ConfigService뿐 아니라 DB에서 키를 가져오는 서비스 등 어떤 Provider든 주입할 수 있다.
global 옵션
JWT가 여러 모듈에서 필요하다면 매번 import하는 대신 글로벌로 등록할 수 있다.
JwtModule.registerAsync({
global: true,
// ...
})
이렇게 하면 AppModule에서 한 번만 등록하고 어디서든 JwtService를 주입받을 수 있다.
JwtService 사용
JwtService는 토큰 발급, 검증, 디코딩 세 가지 핵심 기능을 제공한다.
토큰 발급: sign / signAsync
@Injectable()
export class AuthService {
constructor(private jwtService: JwtService) {}
async login(user: User) {
const payload = { sub: user.id, email: user.email, role: user.role };
return {
accessToken: this.jwtService.sign(payload),
};
}
}
sign()은 동기 메서드다. Payload 객체를 받아서 JWT 문자열을 반환한다. sub(subject)는 JWT 표준 클레임으로, 보통 사용자 ID를 담는다.
호출 시 옵션을 오버라이드할 수도 있다.
// 리프레시 토큰은 만료 시간을 더 길게
const refreshToken = this.jwtService.sign(
{ sub: user.id },
{ expiresIn: '7d', secret: refreshSecret },
);
signAsync()는 비동기 버전이다. secretOrKeyProvider를 비동기 함수로 설정했을 때 필요하다. 일반적인 상황에서는 sign()으로 충분하다.
토큰 검증: verify / verifyAsync
try {
const payload = await this.jwtService.verifyAsync(token, {
secret: jwtSecret,
});
// payload: { sub: 1, email: 'user@example.com', iat: ..., exp: ... }
} catch (error) {
throw new UnauthorizedException('유효하지 않은 토큰');
}
verify()는 토큰이 유효하면 디코딩된 Payload를 반환하고, 만료되었거나 서명이 올바르지 않으면 예외를 던진다. 실무에서는 verifyAsync()를 사용하는 것이 권장된다. verify()는 동기적으로 실행되어 이벤트 루프를 블로킹하기 때문이다. 토큰 하나 검증하는 건 빠르지만, 트래픽이 많으면 누적되어 성능에 영향을 줄 수 있다.
검증 시 발생할 수 있는 예외들:
| 예외 | 원인 |
|---|---|
TokenExpiredError | 토큰 만료 (exp 클레임 초과) |
JsonWebTokenError | 서명 불일치, 잘못된 형식 |
NotBeforeError | nbf 클레임 이전에 사용 시도 |
토큰 디코딩: decode
const payload = this.jwtService.decode(token);
decode()는 서명을 검증하지 않고 Payload만 꺼낸다. 토큰의 만료 시간을 미리 확인하거나, 검증 전에 어떤 사용자의 토큰인지 확인할 때 사용한다. 인증 목적으로는 절대 사용하면 안 된다. 변조된 토큰도 정상적으로 디코딩되기 때문이다.
인증 아키텍처: Guard + JWT
NestJS에서 JWT 인증의 전형적인 구조는 Guard를 사용하는 것이다.
@Injectable()
export class AuthGuard implements CanActivate {
constructor(
private jwtService: JwtService,
private configService: ConfigService,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest();
const token = this.extractTokenFromHeader(request);
if (!token) {
throw new UnauthorizedException();
}
try {
const payload = await this.jwtService.verifyAsync(token, {
secret: this.configService.get<string>('JWT_SECRET'),
});
request['user'] = payload;
} catch {
throw new UnauthorizedException();
}
return true;
}
private extractTokenFromHeader(request: Request): string | undefined {
const [type, token] = request.headers.authorization?.split(' ') ?? [];
return type === 'Bearer' ? token : undefined;
}
}
이 Guard의 동작 흐름:
Authorization헤더에서Bearer토큰을 추출verifyAsync()로 토큰 검증- 검증 성공 시 디코딩된 payload를
request.user에 할당 - 이후 컨트롤러에서
@Req() req로req.user에 접근
컨트롤러에서는 이렇게 사용한다.
@Controller('profile')
export class ProfileController {
@UseGuards(AuthGuard)
@Get()
getProfile(@Req() req) {
return req.user; // { sub: 1, email: '...', iat: ..., exp: ... }
}
}
커스텀 데코레이터로 깔끔하게
매번 req.user에서 꺼내는 건 번거롭다. 커스텀 파라미터 데코레이터를 만들면 깔끔해진다.
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
export const CurrentUser = createParamDecorator(
(data: string | undefined, ctx: ExecutionContext) => {
const request = ctx.switchToHttp().getRequest();
const user = request.user;
return data ? user?.[data] : user;
},
);
@UseGuards(AuthGuard)
@Get()
getProfile(@CurrentUser() user: JwtPayload) {
return user;
}
@UseGuards(AuthGuard)
@Get('id')
getUserId(@CurrentUser('sub') userId: number) {
return userId;
}
Access Token + Refresh Token 전략
Access Token 하나만 쓰면 만료 시간을 어떻게 설정하든 문제가 있다. 짧게 설정하면 사용자가 자주 재로그인해야 하고, 길게 설정하면 토큰이 탈취됐을 때 위험 기간이 길어진다.
이 딜레마를 해결하는 게 이중 토큰 전략이다.
@Injectable()
export class AuthService {
constructor(
private jwtService: JwtService,
private configService: ConfigService,
) {}
async generateTokenPair(user: User) {
const payload = { sub: user.id, email: user.email };
const [accessToken, refreshToken] = await Promise.all([
this.jwtService.signAsync(payload, {
secret: this.configService.get('JWT_ACCESS_SECRET'),
expiresIn: '15m',
}),
this.jwtService.signAsync(
{ sub: user.id },
{
secret: this.configService.get('JWT_REFRESH_SECRET'),
expiresIn: '7d',
},
),
]);
return { accessToken, refreshToken };
}
async refresh(refreshToken: string) {
try {
const payload = await this.jwtService.verifyAsync(refreshToken, {
secret: this.configService.get('JWT_REFRESH_SECRET'),
});
return this.generateTokenPair({ id: payload.sub } as User);
} catch {
throw new UnauthorizedException('리프레시 토큰이 만료되었습니다');
}
}
}
핵심 포인트:
- Access Token: 짧은 수명(15분~1시간). API 요청마다 사용. 탈취되어도 피해 기간이 짧다.
- Refresh Token: 긴 수명(7일~30일). Access Token 재발급용. 보통 HttpOnly 쿠키에 저장.
- 비밀키 분리: Access Token과 Refresh Token에 다른 비밀키를 사용한다. Refresh Token의 키가 노출되어도 Access Token을 직접 만들 수는 없다.
Refresh Token의 Payload에는 최소한의 정보만 담는다. 재발급 시 DB에서 최신 사용자 정보를 다시 조회해서 새 Access Token을 만들기 때문에, 권한 변경이 즉시 반영된다.
비대칭 키 (RSA/EC)
기본적으로 @nestjs/jwt는 HMAC(HS256)을 사용한다. 발급과 검증에 같은 키를 쓰는 대칭키 방식이다. 마이크로서비스 환경에서는 문제가 생긴다. 토큰을 검증해야 하는 모든 서비스에 동일한 비밀키를 공유해야 하기 때문이다.
비대칭 키를 사용하면 이 문제가 해결된다. 개인키로 서명하고 공개키로 검증한다. 인증 서버만 개인키를 갖고, 다른 서비스들은 공개키만 있으면 된다.
import { readFileSync } from 'fs';
JwtModule.register({
privateKey: readFileSync('./keys/private.pem', 'utf-8'),
publicKey: readFileSync('./keys/public.pem', 'utf-8'),
signOptions: { algorithm: 'RS256' },
})
검증만 하는 서비스에서는 publicKey만 설정하면 된다.
// 다른 마이크로서비스
JwtModule.register({
publicKey: readFileSync('./keys/public.pem', 'utf-8'),
verifyOptions: { algorithms: ['RS256'] },
})
RSA 키 쌍 생성:
# 개인키 생성
openssl genrsa -out private.pem 2048
# 공개키 추출
openssl rsa -in private.pem -pubout -out public.pem
secretOrKeyProvider: 동적 키 결정
멀티테넌트 환경처럼 요청마다 다른 키를 사용해야 하는 경우가 있다. secretOrKeyProvider를 사용하면 토큰이나 요청 정보를 기반으로 동적으로 키를 결정할 수 있다.
JwtModule.register({
secretOrKeyProvider: (requestType, tokenOrPayload, options) => {
if (requestType === JwtSecretRequestType.SIGN) {
// 서명할 때: payload에서 테넌트 정보 확인
return getSecretForTenant(tokenOrPayload.tenantId);
}
// 검증할 때: 토큰을 먼저 디코딩해서 테넌트 확인
const decoded = jwt.decode(tokenOrPayload as string) as any;
return getSecretForTenant(decoded.tenantId);
},
})
requestType으로 현재 서명(SIGN)인지 검증(VERIFY)인지 구분한다. 서명 시에는 tokenOrPayload가 payload 객체이고, 검증 시에는 토큰 문자열이다.
이 provider가 비동기 함수(Promise 반환)인 경우, 반드시 signAsync()와 verifyAsync()를 사용해야 한다. 동기 메서드(sign(), verify())와 함께 쓰면 예외가 발생한다.
실무 팁
Payload에 담을 정보
// 좋은 예: 최소한의 식별 정보
{ sub: userId, role: 'admin' }
// 나쁜 예: 과도한 정보
{ sub: userId, email: '...', name: '...', address: '...', permissions: [...] }
Payload가 커지면 토큰 크기가 커지고, 매 요청의 Authorization 헤더에 실려 가기 때문에 네트워크 비용이 증가한다. 필요한 정보는 토큰의 sub로 DB에서 조회하는 게 낫다.
토큰 무효화 (블랙리스트)
JWT의 구조적 한계는 한번 발급된 토큰을 서버에서 강제로 무효화할 수 없다는 것이다. 사용자가 비밀번호를 변경하거나 로그아웃했을 때 기존 토큰을 즉시 차단해야 한다면, Redis 등에 블랙리스트를 유지해야 한다.
async logout(token: string) {
const payload = this.jwtService.decode(token);
const ttl = payload.exp - Math.floor(Date.now() / 1000);
if (ttl > 0) {
await this.redis.set(`blacklist:${token}`, '1', 'EX', ttl);
}
}
async validateToken(token: string) {
const isBlacklisted = await this.redis.get(`blacklist:${token}`);
if (isBlacklisted) {
throw new UnauthorizedException();
}
return this.jwtService.verifyAsync(token);
}
블랙리스트의 TTL을 토큰의 남은 만료 시간으로 설정하면, 만료된 토큰은 자동으로 블랙리스트에서도 제거된다.
타입 안전한 Payload
verify()가 반환하는 타입은 기본적으로 any다. 제네릭으로 타입을 지정할 수 있다.
interface JwtPayload {
sub: number;
email: string;
role: string;
iat: number;
exp: number;
}
const payload = await this.jwtService.verifyAsync<JwtPayload>(token);
// payload.sub → number
// payload.role → string
정리
- sign/verify 기본 동작은 HMAC 대칭키이며, 마이크로서비스 환경에서는 RSA/EC 비대칭 키로 발급-검증 서버를 분리한다
- Access Token(15분)+Refresh Token(7일) 이중 전략으로 보안과 UX를 모두 확보하고, 비밀키는 반드시 분리한다
- JWT는 발급 후 서버에서 무효화할 수 없으므로, 강제 로그아웃이 필요하면 Redis 블랙리스트를 병행한다
관련 문서
- NestJS Guards - Guard의 동작 원리와 실행 파이프라인
- NestJS Custom Parameter Decorator - createParamDecorator 상세
- NestJS ConfigType + registerAs - 타입 안전한 환경 설정