놀라운소프트
기술NestJSJWT인증보안Backend

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 기반 인증 흐름:

JWT 기반 인증 흐름:

장점:

  • 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가지 이유

  1. 모바일 앱 완벽 지원

    • React Native는 Cookie를 지원하지 않음
    • JWT는 AsyncStorage와 완벽 호환
  2. 인프라 단순화

    • Redis 세션 저장소 불필요
    • 서버 확장 시 세션 공유 문제 없음
  3. 개발 속도

    • 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 서버에 도착하면 다음 순서로 처리됩니다:

각 단계별 역할과 실행 시점

순서컴포넌트역할인증과의 관계
1Middleware요청 전처리 (로깅, CORS, body 파싱)인증 전 실행
2Guards인증/인가 검사JWT 인증 실행 시점
3Interceptors (Before)요청 변환, 로깅 시작인증된 사용자 정보 사용 가능
4Pipes데이터 변환/검증 (DTO 검증)인증된 사용자 정보 사용 가능
5Controller비즈니스 로직 실행req.user로 사용자 접근
6Interceptors (After)응답 변환, 로깅 완료-
7Exception 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 상태 코드
토큰 없음UnauthorizedException401
토큰 형식 오류UnauthorizedException401
서명 검증 실패UnauthorizedException401
토큰 만료UnauthorizedException401
사용자 없음 (DB 조회 실패)UnauthorizedException401

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/bcrypt

2. 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과 짧은 만료 시간으로 보안 강화

프로젝트에서 인증 시스템 구축이 필요하다면 놀라운소프트가 도와드립니다. 기획부터 배포까지 빠르게 함께 만들어갑니다.


참고 자료: