NestJS에서 JWT 인증 구현하기
Cookie vs JWT 비교부터 실전 구현까지. 놀라운소프트가 범용성을 위해 JWT를 선택한 이유와 NestJS에서 JWT 인증을 구현하는 완벽 가이드
TL;DR
- Cookie 인증은 브라우저 전용, JWT는 모바일/데스크톱 앱까지 범용 지원
- 놀라운소프트는 React Native 앱 지원을 위해 JWT 선택
- NestJS에서 JWT 인증 구현은 Passport 모듈로 20분이면 완료
왜 인증이 중요한가?
현대 웹/앱 서비스에서 인증은 선택이 아닌 필수입니다.
인증이 필요한 이유:
- 사용자 데이터 보호
- API 접근 제어
- 개인화된 서비스 제공
- 악의적 요청 차단
놀라운소프트는 지난 1년간 15개 프로젝트에서 인증 시스템을 구축했고, 그 과정에서 Cookie와 JWT의 장단점을 명확히 파악했습니다.
Cookie vs JWT: 무엇을 선택해야 하나?
인증 방식 비교 다이어그램
Cookie 기반 인증 흐름:
JWT 기반 인증 흐름:
Cookie 기반 인증
장점:
- HttpOnly 플래그로 XSS 공격 방어
- 서버에서 세션 즉시 무효화 가능 (로그아웃 즉시 적용)
- 구현이 직관적
단점:
- 브라우저 전용 (모바일 앱, 데스크톱 앱 지원 어려움)
- 세션 저장소 필요 (Redis 등 추가 인프라)
- CORS 설정 복잡 (SameSite 이슈)
- 서버 부하 증가 (매 요청마다 DB 조회)
JWT 기반 인증
장점:
- 모바일/데스크톱 앱 완벽 지원
- 별도 세션 저장소 불필요 (Stateless)
- 마이크로서비스 아키텍처에 적합
- 서버 부하 감소 (토큰만 검증)
단점:
- 토큰 탈취 시 만료까지 무효화 어려움
- XSS 공격에 취약 (LocalStorage 사용 시)
- 토큰 크기가 Cookie보다 큼 (페이로드 포함)
비교 표
| 항목 | Cookie 인증 | JWT 인증 |
|---|---|---|
| 브라우저 지원 | ✅ 완벽 | ✅ 완벽 |
| 모바일 앱 지원 | ❌ 어려움 | ✅ 완벽 |
| 세션 저장소 | ⚠️ 필요 | ✅ 불필요 |
| 로그아웃 즉시 적용 | ✅ 가능 | ⚠️ 어려움 |
| 서버 부하 | ⚠️ 높음 | ✅ 낮음 |
| CORS 설정 | ⚠️ 복잡 | ✅ 간단 |
놀라운소프트가 JWT를 선택한 이유
놀라운소프트는 범용성을 최우선 가치로 둡니다.
실제 프로젝트 사례
최근 진행한 프로젝트 분석 결과:
- 웹 + 모바일 앱 동시 개발: 73% (11/15 프로젝트)
- 웹 전용: 20% (3/15 프로젝트)
- 모바일 앱 전용: 7% (1/15 프로젝트)
결론: 대부분의 프로젝트가 React Native 앱과 함께 개발됩니다.
JWT를 선택한 3가지 이유
-
모바일 앱 완벽 지원
- React Native는 Cookie를 지원하지 않음
- JWT는 AsyncStorage와 완벽 호환
-
인프라 단순화
- Redis 세션 저장소 불필요
- 서버 확장 시 세션 공유 문제 없음
-
개발 속도
- NestJS Passport JWT 모듈로 20분 구현
- CORS 설정 간단 (Authorization 헤더만 허용)
보안 이슈 해결 방법
JWT의 단점인 토큰 무효화 문제는 다음 방법으로 해결:
- 짧은 만료 시간: Access Token 15분, Refresh Token 7일
- Refresh Token 저장: DB에 Refresh Token 저장하고 로그아웃 시 삭제
- IP/User-Agent 검증: 토큰 발급 시 메타데이터 저장 및 비교
NestJS Request Lifecycle과 JWT 인증
NestJS에서 JWT 인증이 어떻게 동작하는지 이해하려면 Request Lifecycle을 알아야 합니다.
Request Lifecycle 전체 흐름
HTTP 요청이 NestJS 서버에 도착하면 다음 순서로 처리됩니다:
각 단계별 역할과 실행 시점
| 순서 | 컴포넌트 | 역할 | 인증과의 관계 |
|---|---|---|---|
| 1 | Middleware | 요청 전처리 (로깅, CORS, body 파싱) | 인증 전 실행 |
| 2 | Guards | 인증/인가 검사 | JWT 인증 실행 시점 |
| 3 | Interceptors (Before) | 요청 변환, 로깅 시작 | 인증된 사용자 정보 사용 가능 |
| 4 | Pipes | 데이터 변환/검증 (DTO 검증) | 인증된 사용자 정보 사용 가능 |
| 5 | Controller | 비즈니스 로직 실행 | req.user로 사용자 접근 |
| 6 | Interceptors (After) | 응답 변환, 로깅 완료 | - |
| 7 | Exception Filters | 예외 처리 및 에러 응답 | 인증 실패 시 여기서 처리 |
왜 Guards에서 인증을 처리하는가?
NestJS에서 Guards는 "이 요청을 처리해도 되는가?" 를 결정합니다.
Guards의 특징:
canActivate()메서드가true를 반환해야 다음 단계로 진행false를 반환하면 요청이 즉시 거부됨- Controller보다 먼저 실행되어 불필요한 로직 실행 방지
- ExecutionContext로 요청 정보에 접근 가능
인증이 Guards에서 실행되는 이유:
1. Middleware 단계
- 아직 라우트 정보를 모름
- 어떤 Controller/Handler가 실행될지 불명확
- 인증이 필요한 라우트인지 판단 불가
2. Guards 단계 ✅
- 라우트 정보 확정됨
- @Public() 같은 메타데이터 접근 가능
- Controller 실행 전에 차단 가능
- req.user 할당 후 이후 단계에서 사용 가능
3. Pipes/Controller 단계
- 이미 로직 실행 시작
- 인증 실패 시 리소스 낭비
JWT 인증 실행 상세 흐름
Guards 단계에서 JWT 인증이 어떻게 처리되는지 상세히 살펴봅니다:
실행 순서 코드로 이해하기
실제 요청이 처리되는 순서를 코드로 확인해보겠습니다:
// 1. 요청 도착: GET /auth/profile
// Headers: { Authorization: "Bearer eyJhbG..." }
// 2. Middleware 실행 (있다면)
// - 로깅, CORS 처리 등
// 3. Guards 실행 - JwtAuthGuard
@UseGuards(AuthGuard("jwt"))
@Get("profile")
getProfile(@Request() req) {
// 5. Controller 실행 - Guards 통과 후에만 실행됨
return req.user;
}
// 3-1. AuthGuard가 JwtStrategy 호출
// 3-2. JwtStrategy.validate() 실행
async validate(payload: JwtPayload) {
// Authorization 헤더에서 토큰 추출 (자동)
// JWT_SECRET으로 서명 검증 (자동)
// 만료 시간 확인 (자동)
// 개발자가 작성하는 부분: 추가 검증
const user = await this.userService.findById(payload.sub);
if (!user) {
throw new UnauthorizedException();
}
// 리턴값이 req.user에 할당됨
return { id: user.id, email: user.email, role: user.role };
}
// 4. Pipes 실행 - DTO 검증 (있다면)
// 5. Controller Handler 실행
// req.user = { id: 1, email: "user@example.com", role: "user" }인증 실패 시 흐름
인증이 실패하면 Exception Filters에서 처리됩니다:
인증 실패 케이스:
| 상황 | 에러 | HTTP 상태 코드 |
|---|---|---|
| 토큰 없음 | UnauthorizedException | 401 |
| 토큰 형식 오류 | UnauthorizedException | 401 |
| 서명 검증 실패 | UnauthorizedException | 401 |
| 토큰 만료 | UnauthorizedException | 401 |
| 사용자 없음 (DB 조회 실패) | UnauthorizedException | 401 |
Global Guard vs Route Guard
인증을 적용하는 두 가지 방식이 있습니다:
1. Route Guard (라우트별 적용):
@Controller("users")
export class UserController {
@Get("public")
getPublicData() {
// 인증 없이 접근 가능
}
@UseGuards(AuthGuard("jwt")) // 이 라우트만 인증 필요
@Get("profile")
getProfile(@Request() req) {
return req.user;
}
}2. Global Guard (전역 적용):
// main.ts
app.useGlobalGuards(new JwtAuthGuard(reflector));
// 모든 라우트에 인증 적용
// @Public() 데코레이터로 예외 처리
@Public()
@Post("login")
login() {
// 인증 없이 접근 가능
}권장 방식: Global Guard + @Public() 데코레이터
- 기본적으로 모든 라우트 보호
- 명시적으로 공개할 라우트만 @Public() 적용
- 실수로 인증 누락 방지
NestJS에서 JWT 인증 구현하기
1. 패키지 설치
npm install @nestjs/jwt @nestjs/passport passport passport-jwt bcrypt
npm install -D @types/passport-jwt @types/bcrypt2. JWT 모듈 설정
auth/auth.module.ts:
import { Module } from "@nestjs/common";
import { JwtModule } from "@nestjs/jwt";
import { PassportModule } from "@nestjs/passport";
import { AuthService } from "./auth.service";
import { AuthController } from "./auth.controller";
import { JwtStrategy } from "./jwt.strategy";
import { UserModule } from "../user/user.module";
@Module({
imports: [
UserModule,
PassportModule,
JwtModule.register({
secret: process.env.JWT_SECRET,
signOptions: { expiresIn: "15m" },
}),
],
providers: [AuthService, JwtStrategy],
controllers: [AuthController],
exports: [AuthService],
})
export class AuthModule {}3. JWT Strategy 구현
auth/jwt.strategy.ts:
import { Injectable, UnauthorizedException } from "@nestjs/common";
import { PassportStrategy } from "@nestjs/passport";
import { ExtractJwt, Strategy } from "passport-jwt";
import { UserService } from "../user/user.service";
interface JwtPayload {
sub: number;
email: string;
iat: number;
exp: number;
}
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(private userService: UserService) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: process.env.JWT_SECRET,
});
}
async validate(payload: JwtPayload) {
// DB에서 사용자 존재 여부 확인
const user = await this.userService.findById(payload.sub);
if (!user) {
throw new UnauthorizedException("사용자를 찾을 수 없습니다");
}
// req.user에 할당될 객체
return {
id: user.id,
email: user.email,
role: user.role,
};
}
}4. Auth Service 구현
auth/auth.service.ts:
import { Injectable, UnauthorizedException } from "@nestjs/common";
import { JwtService } from "@nestjs/jwt";
import { UserService } from "../user/user.service";
import * as bcrypt from "bcrypt";
@Injectable()
export class AuthService {
constructor(
private jwtService: JwtService,
private userService: UserService
) {}
async login(email: string, password: string) {
// 1. 사용자 조회
const user = await this.userService.findByEmail(email);
if (!user) {
throw new UnauthorizedException("이메일 또는 비밀번호가 틀립니다");
}
// 2. 비밀번호 검증
const isValid = await bcrypt.compare(password, user.password);
if (!isValid) {
throw new UnauthorizedException("이메일 또는 비밀번호가 틀립니다");
}
// 3. JWT 토큰 생성
const payload = { sub: user.id, email: user.email };
const accessToken = this.jwtService.sign(payload);
return {
accessToken,
expiresIn: 900,
user: {
id: user.id,
email: user.email,
},
};
}
async register(email: string, password: string) {
// 1. 이메일 중복 확인
const exists = await this.userService.findByEmail(email);
if (exists) {
throw new UnauthorizedException("이미 존재하는 이메일입니다");
}
// 2. 비밀번호 해시
const hashedPassword = await bcrypt.hash(password, 10);
// 3. 사용자 생성
const user = await this.userService.create({
email,
password: hashedPassword,
});
// 4. 토큰 직접 발급
const payload = { sub: user.id, email: user.email };
const accessToken = this.jwtService.sign(payload);
return {
accessToken,
expiresIn: 900,
user: {
id: user.id,
email: user.email,
},
};
}
}5. Auth Controller 구현
auth/auth.controller.ts:
import {
Body,
Controller,
Post,
UseGuards,
Get,
Request,
} from "@nestjs/common";
import { AuthGuard } from "@nestjs/passport";
import { AuthService } from "./auth.service";
class LoginDto {
email: string;
password: string;
}
@Controller("auth")
export class AuthController {
constructor(private authService: AuthService) {}
@Post("register")
async register(@Body() body: LoginDto) {
return this.authService.register(body.email, body.password);
}
@Post("login")
async login(@Body() body: LoginDto) {
return this.authService.login(body.email, body.password);
}
@UseGuards(AuthGuard("jwt"))
@Get("profile")
getProfile(@Request() req) {
return req.user;
}
}6. 전역 Guard 설정 (선택)
모든 라우트에 기본적으로 인증 적용:
auth/jwt-auth.guard.ts:
import { Injectable, ExecutionContext } from "@nestjs/common";
import { AuthGuard } from "@nestjs/passport";
import { Reflector } from "@nestjs/core";
import { IS_PUBLIC_KEY } from "./public.decorator";
@Injectable()
export class JwtAuthGuard extends AuthGuard("jwt") {
constructor(private reflector: Reflector) {
super();
}
canActivate(context: ExecutionContext) {
// @Public() 데코레이터가 있으면 인증 스킵
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
context.getHandler(),
context.getClass(),
]);
if (isPublic) return true;
return super.canActivate(context);
}
}auth/public.decorator.ts:
import { SetMetadata } from "@nestjs/common";
export const IS_PUBLIC_KEY = "isPublic";
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);main.ts에서 전역 등록:
import { NestFactory, Reflector } from "@nestjs/core";
import { AppModule } from "./app.module";
import { JwtAuthGuard } from "./auth/jwt-auth.guard";
async function bootstrap() {
const app = await NestFactory.create(AppModule);
const reflector = app.get(Reflector);
app.useGlobalGuards(new JwtAuthGuard(reflector));
await app.listen(3000);
}
bootstrap();사용 예시:
@Public() // 인증 없이 접근 가능
@Post("login")
async login(@Body() body: LoginDto) {
return this.authService.login(body.email, body.password);
}주의사항 & 팁
1. JWT Secret 관리
// ❌ 절대 하드코딩 금지
JwtModule.register({
secret: "my-secret-key-123",
});
// ✅ 환경 변수 사용
JwtModule.register({
secret: process.env.JWT_SECRET,
});2. Refresh Token 구현
async refreshToken(refreshToken: string) {
// 1. Refresh Token 검증
const payload = this.jwtService.verify(refreshToken, {
secret: process.env.JWT_REFRESH_SECRET,
});
// 2. DB에서 Refresh Token 유효성 확인
const storedToken = await this.tokenRepository.findOne({
userId: payload.sub,
token: refreshToken,
revoked: false,
});
if (!storedToken) {
throw new UnauthorizedException("유효하지 않은 토큰입니다");
}
// 3. 새 Access Token 발급
return {
accessToken: this.jwtService.sign({
sub: payload.sub,
email: payload.email,
}),
};
}마무리
NestJS에서 JWT 인증 구현은 생각보다 간단합니다.
핵심 정리:
- Cookie는 브라우저 전용, JWT는 모든 플랫폼 지원
- 놀라운소프트는 범용성을 위해 JWT 선택
- NestJS Passport 모듈로 20분 안에 구현 가능
- Refresh Token과 짧은 만료 시간으로 보안 강화
프로젝트에서 인증 시스템 구축이 필요하다면 놀라운소프트가 도와드립니다. 기획부터 배포까지 빠르게 함께 만들어갑니다.
참고 자료: