Krong Dev.
Backend Nest.js TypeORM

NestJS 인증 구현 — 회원가입부터 JWT 토큰 검증까지

NestJS에서 회원가입, 로그인, JWT 발급, Passport Guard를 이용한 인증 파이프라인을 직접 구현하며 정리합니다.

NestJS 인증 구현 — 회원가입부터 JWT 토큰 검증까지

NestJS로 게시판 CRUD를 구현한 뒤, 자연스럽게 부딪힌 질문이 두 가지 있었습니다.

이 API는 아무나 호출해도 되는 건가?

로그인한 유저와 그렇지 않은 유저를 서버에서 어떻게 구분하지?

게시물을 만들고 수정하는 건 됐는데, “누가” 그걸 하는지를 서버가 전혀 모르는 상태였습니다. 인증이 없으면 누구든 아무 게시물이나 삭제할 수 있고, 그건 서비스라고 부를 수 없습니다. 이 글에서는 NestJS의 Auth 모듈을 만들어 회원가입·로그인을 구현하고, JWT 토큰 발급부터 Passport Guard를 이용한 인증 파이프라인까지 직접 구현한 흐름을 바탕으로 정리합니다.


유저를 만들고 저장하기

인증 기능은 왜 별도 모듈로 분리하는가

NestJS는 기능 단위로 모듈을 나누는 것을 권장합니다. 게시물 기능은 BoardModule, 인증 기능은 AuthModule로 분리하는 식입니다. 이렇게 하는 이유는 단순히 “폴더 정리”가 아닙니다.

모듈이 분리되어 있으면 각 모듈이 자기 책임만 지게 됩니다. 예를 들어 인증 로직을 수정할 때 게시물 코드를 건드릴 필요가 없고, 나중에 인증 모듈만 떼어내 다른 프로젝트에 재사용할 수도 있습니다. NestJS의 모듈 시스템은 이 분리를 프레임워크 레벨에서 강제하는 구조입니다.

AppModule (root)
  ├── BoardModule   →  게시물 CRUD
  └── AuthModule    →  인증 기능 전체
        ├── UserEntity / UserRepository
        ├── AuthController
        ├── AuthService
        └── JWT, Passport (Strategy, Guard)

AuthModule 안에 유저 관련 엔티티, 리포지토리, 컨트롤러, 서비스, 그리고 JWT/Passport 관련 설정을 전부 모아둡니다. NestJS CLI로 뼈대를 생성하면 app.module.tsimports 배열에 자동으로 등록까지 해 줍니다.

$ nest g mo auth
$ nest g co auth
$ nest g s auth

이 세 줄이면 모듈, 컨트롤러, 서비스 파일이 만들어집니다. 하지만 유저 정보를 담을 Entity와 DB 접근을 담당할 Repository는 직접 만들어야 합니다.


유저 데이터의 형태를 코드로 정의하기 — Entity

Entity는 “DB 테이블이 어떻게 생겼는지”를 TypeScript 클래스로 표현한 것입니다. NestJS에서는 TypeORM이라는 ORM 라이브러리를 주로 사용하는데, Entity 클래스에 데코레이터를 붙이면 TypeORM이 이 클래스를 보고 실제 DB 테이블을 생성합니다.

import { BaseEntity, Entity, PrimaryGeneratedColumn, Column, Unique } from "typeorm";

@Entity()
@Unique(["username"])
export class User extends BaseEntity {
  @PrimaryGeneratedColumn()
  id: number;

  @Column()
  username: string;

  @Column()
  password: string;
}

@PrimaryGeneratedColumn()은 자동 증가하는 기본 키를 만들고, @Column()은 일반 컬럼을 만듭니다. @Unique(["username"])은 DB 레벨에서 username 중복을 막는 제약 조건인데, 이건 뒤에서 에러 핸들링과 함께 다시 다룹니다.

Entity를 만들었으면 AuthModule에서 TypeORM의 forFeature로 등록해야 합니다. 이 등록을 해야 AuthService에서 Repository를 주입받아 사용할 수 있습니다.

@Module({
  imports: [TypeOrmModule.forFeature([User])],
  controllers: [AuthController],
  providers: [AuthService],
})
export class AuthModule {}

DI 라는 개념이 여기서 등장합니다. AuthService 생성자에 @InjectRepository(User)를 붙이면, NestJS가 알아서 UserRepository 인스턴스를 만들어 넣어줍니다. 개발자가 직접 new Repository()를 할 필요가 없습니다.

@Injectable()
export class AuthService {
  constructor(
    @InjectRepository(User)
    private userRepository: Repository<User>,
  ) {}
}

요청 한 건이 거치는 경로 — Controller, Service, Repository

회원가입 요청이 들어오면 NestJS 내부에서 다음 경로를 거칩니다.

HTTP POST /auth/signup

AuthController    →  요청을 받아 Service에 위임

AuthService       →  비즈니스 로직 (해싱, 검증 등)

UserRepository    →  실제 DB 쿼리 실행

Database          →  저장 완료

Response          →  클라이언트에 결과 반환

이 구조를 레이어 패턴이라 부릅니다. Controller는 “어떤 요청이 왔는지” 판단만 하고, 실제 로직은 Service에 넘기고, DB 접근은 Repository에 맡깁니다. 각 레이어가 자기 역할만 하기 때문에 코드를 수정할 때 영향 범위가 좁아집니다.

왜 Controller에 로직을 직접 쓰면 안 되는지 의문이 들 수 있습니다. 작은 프로젝트에서는 문제가 없어 보이지만, 회원가입 로직이 “이메일 발송”, “프로필 기본값 생성” 같은 부가 기능과 엮이기 시작하면 Controller가 수백 줄로 불어납니다. Service로 분리해두면 Controller는 라우팅만, Service는 로직만 담당하므로 코드를 찾고 고치기가 훨씬 쉬워집니다.


요청 바디를 믿지 않기 — DTO와 ValidationPipe

클라이언트가 회원가입 요청을 보낼 때, 바디에 username과 password가 들어와야 합니다. 하지만 아무런 검증 없이 받으면 빈 문자열이나 숫자가 들어와도 서버가 그대로 처리하게 됩니다.

DTO 는 “요청 바디가 어떤 모양이어야 하는지”를 클래스로 정의한 것입니다. 여기에 class-validator 데코레이터를 달면 조건까지 선언할 수 있습니다.

import { IsString, MinLength, MaxLength, Matches } from "class-validator";

export class AuthCredentialDto {
  @IsString()
  @MinLength(4)
  @MaxLength(20)
  username: string;

  @IsString()
  @MinLength(4)
  @MaxLength(20)
  @Matches(/^[a-zA-Z0-9]*$/, {
    message: "영문과 숫자만 사용할 수 있습니다.",
  })
  password: string;
}

@IsString()은 문자열인지, @MinLength(4)는 최소 4글자인지, @Matches는 정규식 패턴에 맞는지 확인합니다. 이 데코레이터들은 조건을 선언만 해둔 것이고, 실제 검증이 실행되려면 ValidationPipe가 필요합니다.

@Post("/signup")
signUp(
  @Body(ValidationPipe) authCredentialDto: AuthCredentialDto,
): Promise<void> {
  return this.authService.signUp(authCredentialDto);
}

@Body(ValidationPipe)를 붙이면 요청 바디가 Controller에 도달하기 전에 DTO의 데코레이터 조건과 대조합니다. 조건에 맞지 않으면 Controller 코드가 실행조차 되지 않고 400 Bad Request가 반환됩니다.

@Matchesmessage 옵션을 넣지 않으면 기본 에러 메시지가 매우 길고 기술적입니다. 예를 들어 정규식 패턴 자체가 에러 메시지에 노출되는데, 클라이언트에 그대로 전달하면 유저가 이해할 수 없습니다. message를 직접 지정하면 “영문과 숫자만 사용할 수 있습니다”처럼 유저 친화적인 메시지를 내려줄 수 있습니다.


같은 이름의 유저가 또 가입하려 하면

에러를 잡지 않으면 무조건 500이 떨어진다

Entity에 @Unique(["username"])을 걸어두면, 같은 username으로 두 번째 저장을 시도할 때 DB가 에러를 던집니다. 문제는 이 에러를 코드에서 잡아주지 않으면 어떻게 되는가입니다.

NestJS의 에러 처리 흐름은 이렇습니다. Service에서 에러가 발생하면 Controller로 올라가고, Controller에서도 잡지 않으면 NestJS의 기본 예외 필터가 받습니다. 기본 필터는 알 수 없는 에러에 대해 무조건 500 Internal Server Error를 반환합니다.

Service에서 에러 발생

catch가 없으면 Controller로 전파

Controller에서도 catch가 없으면 NestJS 기본 필터로 전파

기본 필터 : "모르는 에러 → 500"

클라이언트 입장에서 500은 “서버가 고장났다”는 의미입니다. 하지만 실제로는 단순히 중복 username일 뿐인데, 이걸 500으로 응답하면 클라이언트가 적절한 안내를 할 수 없습니다. “이미 사용 중인 이름입니다”라고 알려주려면 409 Conflict를 반환해야 합니다. 그래서 try/catch가 필요합니다.


username 중복을 막는 두 가지 전략

중복을 방지하는 방법은 크게 두 가지입니다.

방법설명단점
저장 전에 미리 조회findOne으로 존재 여부 확인 후 저장DB를 2번 조회 (비효율)
DB 제약 조건 + catchEntity에 @Unique 설정, 저장 시 에러를 catch에러 핸들링 코드 필요

첫 번째 방법은 직관적이지만, findOnesave 사이에 다른 요청이 끼어들면 중복이 발생할 수 있습니다. 이를 레이스 컨디션 이라 부릅니다. 두 번째 방법은 DB 레벨에서 제약을 걸기 때문에, 동시 요청이 와도 DB가 확실히 하나만 저장하고 나머지는 에러를 던집니다.


catch의 any 타입 문제와 QueryFailedError

try/catch로 감싸면 에러를 잡을 수 있지만, catch에서 받는 error 인자의 타입이 any입니다. TypeScript를 쓰는데 any라니, 이게 무슨 에러인지 코드에서 판별할 방법이 없습니다.

처음에는 interface를 직접 만들어 code 프로퍼티가 있으면 DB 에러일 것이라고 추측하는 방식을 시도했습니다. 하지만 이것은 내가 수동으로 만든 추측에 불과합니다. 실제로 code 프로퍼티를 가진 다른 종류의 에러가 끼어들면 잘못된 분기를 탈 수 있습니다.

그래서 찾은 것이 TypeORM의 QueryFailedError입니다. TypeORM은 DB 쿼리가 실패하면 이 클래스로 에러를 감싸서 던집니다. instanceof로 체크하면 “이건 확실히 DB 쿼리 실패 에러다”라고 타입 레벨에서 보장됩니다. 추측이 아닌 확인입니다.

import { QueryFailedError } from "typeorm";
import {
  ConflictException,
  InternalServerErrorException,
} from "@nestjs/common";

async createUser(authCredentialDto: AuthCredentialDto): Promise<void> {
  const { username, password } = authCredentialDto;
  const user = this.userRepository.create({ username, password });

  try {
    await this.userRepository.save(user);
  } catch (error) {
    if (error instanceof QueryFailedError) {
      const driverError = error.driverError as { code: string };
      if (driverError.code === "23505") {
        throw new ConflictException("Existing username");
      }
    }
    throw new InternalServerErrorException();
  }
}

instanceof QueryFailedError가 true이면 TypeORM이 감싼 DB 에러라는 것이 확정됩니다. 이 안에는 driverError라는 프로퍼티가 있고, 여기에 PostgreSQL(pg 라이브러리)이 던진 원본 에러 객체가 들어 있습니다.

23505는 PostgreSQL이 정의한 unique 제약 조건 위반 에러 코드입니다. 이 코드가 오면 ConflictException(409)을 던지고, 그 외의 DB 에러는 원인을 특정할 수 없으므로 InternalServerErrorException(500)으로 처리합니다.

이 에러 코드는 DB 벤더마다 다릅니다. MySQL이라면 1062, SQLite라면 SQLITE_CONSTRAINT처럼 각자 다른 코드를 씁니다. PostgreSQL 환경에서만 23505가 유효합니다.


비밀번호를 안전하게 다루기

평문 저장이 위험한 이유

아무런 처리 없이 user.password = "1234"로 저장하면, DB에 1234가 그대로 들어갑니다. DB가 유출되는 상황은 해킹뿐 아닙니다. 백업 파일이 노출되거나, 내부 직원이 DB에 접근하거나, SQL Injection으로 데이터가 빠져나가는 등 다양한 경로가 있습니다. 한 번이라도 유출되면 모든 유저의 비밀번호가 즉시 노출됩니다.


암호화 방식을 단계별로 비교하기

비밀번호를 보호하는 방법은 여러 단계가 있고, 각각 한계가 있습니다.

방식동작한계
평문 저장12341234유출 시 즉시 노출
양방향 암호화암호화 키로 암호화/복호화키가 노출되면 전부 복호화 가능
단순 해시SHA256("1234")03ac6...레인보우 테이블 공격에 취약
salt + 해시랜덤salt + "1234" → 고유 해시현재 권장 방식

양방향 암호화는 암호화 키만 있으면 원본을 복원할 수 있습니다. 이 키가 서버 코드나 환경변수에 저장되는데, 서버가 뚫리면 키도 함께 유출됩니다. 암호화 알고리즘 자체는 대부분 공개되어 있으므로 키만 얻으면 전체 비밀번호를 복원할 수 있습니다.

단방향 해시는 원본을 복원할 수 없다는 점에서 한 단계 안전합니다. 해시는 “암호문”이 아니라 입력값에 대한 함수의 출력이기 때문에, 해시값에서 원본을 역산하는 수학적 방법이 없습니다.

하지만 공격자는 역산 대신 다른 방법을 씁니다.


공격자는 해시를 어떻게 뚫으려 하는가

  • 무차별 대입(Brute Force) — 가능한 비밀번호 후보를 하나씩 해시 함수에 넣어보고, DB에 저장된 해시값과 같은 결과가 나오는지 비교합니다. 현대 GPU의 연산 능력으로 짧은 비밀번호는 몇 시간 안에 찾아낼 수 있습니다.

  • 레인보우 테이블(Rainbow Table) — 미리 수억 개의 “비밀번호 → 해시값” 쌍을 거대한 테이블로 만들어둡니다. DB에서 해시값을 꺼낸 뒤 이 테이블에서 같은 해시를 찾으면 원본 비밀번호를 알 수 있습니다. 전수 조사보다 훨씬 빠릅니다.

단순 해시의 치명적인 문제는, 같은 비밀번호는 항상 같은 해시를 생성한다는 것입니다. SHA256("1234")는 누가 계산해도 동일한 결과가 나옵니다. 만약 유저 100명이 비밀번호를 1234로 설정했다면, DB에 저장된 해시값도 100개가 전부 같습니다. 레인보우 테이블에서 그 해시 하나만 찾으면 100명의 비밀번호를 한꺼번에 알아낸 셈입니다.


salt가 레인보우 테이블을 무력화하는 원리

salt는 해싱 전에 비밀번호 앞에 붙이는 랜덤 문자열입니다. 유저마다 다른 salt를 생성하므로, 같은 비밀번호라도 서로 다른 해시가 만들어집니다.

유저 A : salt_abc + "1234" → 해시 x9f2k...
유저 B : salt_xyz + "1234" → 해시 p3m7j...

같은 "1234"인데 해시 결과가 다릅니다. 공격자가 미리 만들어둔 레인보우 테이블은 salt 없이 계산한 것이므로, salt가 붙은 해시와는 일치하지 않습니다. salt의 경우의 수만큼 테이블을 새로 만들어야 하는데, 이건 사실상 불가능합니다.


bcrypt로 해싱하기

bcryptjs는 salt 생성과 해싱을 한 번에 처리하는 전용 패스워드 해싱 함수입니다.

$ npm install bcryptjs
import * as bcrypt from "bcryptjs";

// 회원가입 시 — 비밀번호를 해싱하여 저장
const salt = await bcrypt.genSalt();
const hashedPassword = await bcrypt.hash(password, salt);
// DB에는 hashedPassword만 저장

bcrypt.genSalt()가 랜덤 salt를 생성하고, bcrypt.hash()가 salt + password를 합쳐 해싱합니다. 생성된 해시 문자열 안에 salt 정보가 포함되어 있어서, salt를 별도 컬럼에 저장할 필요가 없습니다. 나중에 비교할 때 bcrypt.compare()가 해시 안에서 salt를 자동으로 추출합니다.


로그인 — 해시끼리 비교하기

로그인 요청이 오면 username으로 유저를 조회한 뒤, 클라이언트가 보낸 평문 비밀번호와 DB에 저장된 해시를 bcrypt.compare로 비교합니다.

async signIn(
  authCredentialDto: AuthCredentialDto,
): Promise<{ accessToken: string }> {
  const { username, password } = authCredentialDto;
  const user = await this.userRepository.findOneBy({ username });

  if (user && (await bcrypt.compare(password, user.password))) {
    // 인증 성공 → JWT 발급 (다음 섹션에서 구현)
    const payload = { username };
    const accessToken = this.jwtService.sign(payload);
    return { accessToken };
  } else {
    throw new UnauthorizedException("로그인 실패");
  }
}

bcrypt.compare("1234", "저장된해시")는 내부적으로 해시에서 salt를 꺼내고, 그 salt + "1234"를 다시 해싱한 뒤, 결과가 저장된 해시와 같은지 비교합니다. 같으면 true, 다르면 false를 반환합니다.

유저가 존재하지 않거나 비밀번호가 틀리면 UnauthorizedException(401)을 던집니다. “유저가 없습니다”와 “비밀번호가 틀렸습니다”를 구분하지 않는 이유는, 공격자에게 “이 username은 존재한다”는 정보를 주지 않기 위해서입니다.


토큰으로 “누구인지”를 증명하기

서버가 로그인 상태를 기억하는 방법 — 세션 vs 토큰

로그인에 성공했다는 것을 서버가 어떻게 기억할까요? 전통적인 방식은 세션입니다. 서버가 로그인 정보를 메모리에 저장하고, 클라이언트에 세션 ID를 넘겨주는 방식입니다. 하지만 서버가 여러 대로 늘어나면(스케일 아웃), 각 서버가 동일한 세션 정보를 공유해야 하는 문제가 생깁니다.

JWT 는 서버가 상태를 저장하지 않는 방식으로 이 문제를 해결합니다. 서버는 토큰을 발급만 하고, 이후 요청마다 클라이언트가 토큰을 보내면 서버는 그 토큰이 자기가 발급한 것인지 서명(Signature)으로 검증합니다. 서버 메모리에 아무것도 저장할 필요가 없습니다.


JWT 내부는 어떻게 생겼는가

JWT는 .으로 구분된 세 부분으로 이루어져 있습니다.

JWT 구조

Header : 토큰에 대한 메타 데이터를 포함하고 있다. ex. 타입, 해싱 알고리즘, SHA256, …

Payload : 유저 정보(issuer), 만료 기간(expiration time), 주제(subject), …

Verify Signature : JWT의 마지막 세그먼트는 토큰이 보낸 사람에 의해 서명되었으며, 어떤 식으로든 변경되지 않았는지 확인하는 데 사용되는 서명이다. 서명은 헤더 및 페이로드 세그먼트, 서명 알고리즘, 비밀 또는 공개 키를 사용하여 생성된다.

HeaderPayload는 Base64로 인코딩될 뿐, 암호화되지 않습니다. 누구나 디코딩해서 내용을 읽을 수 있습니다. 그래서 Payload에 비밀번호, 주민번호 같은 민감정보를 절대 넣으면 안 됩니다. 필요한 최소 정보(예: username)만 담습니다.

Signature가 핵심입니다. 서버만 알고 있는 Secret Key를 써서 Header + Payload를 해싱한 결과물입니다. 누군가 Payload를 변조하면 Signature가 달라지므로, 서버가 검증할 때 “이건 내가 발급한 토큰이 아니다”라고 판단할 수 있습니다.


발급에서 검증까지 — 토큰이 이동하는 전체 경로

1. 유저가 로그인 요청 (username + password)

2. 서버 : 비밀번호 검증 → 성공 시 JWT 생성

   payload({ username }) + Secret Key → sign → accessToken

3. 클라이언트 : 토큰을 저장 (localStorage, cookie 등)

4. 보호된 API 요청 시 Authorization 헤더에 토큰 포함

   Authorization: Bearer eyJhbGciOiJI...

5. 서버 : Secret Key로 Signature 재생성 후 토큰의 Signature와 비교

6. 일치 → 요청 처리 / 불일치 → 401 Unauthorized

4번에서 클라이언트가 토큰을 보내는 방식은 HTTP 헤더의 Authorization 필드에 Bearer <토큰> 형태로 넣는 것이 표준입니다. 서버는 이 헤더에서 토큰을 추출하고, 자기가 가진 Secret Key로 Signature를 다시 만들어 비교합니다.


NestJS에 JWT와 Passport 붙이기

필요한 패키지를 설치합니다.

$ npm install @nestjs/jwt @nestjs/passport passport passport-jwt
$ npm install -D @types/passport-jwt

@nestjs/jwt는 토큰 생성·검증을, @nestjs/passport는 인증 전략(Strategy)을 NestJS에 통합하는 역할을 합니다. passport-jwt는 JWT 방식의 인증 전략 구현체이고, @types/passport-jwt는 TypeScript 타입 정보입니다.

AuthModule에 등록합니다.

import { Module } from "@nestjs/common";
import { JwtModule } from "@nestjs/jwt";
import { PassportModule } from "@nestjs/passport";
import { TypeOrmModule } from "@nestjs/typeorm";
import { AuthController } from "./auth.controller";
import { AuthService } from "./auth.service";
import { User } from "./user.entity";
import { JwtStrategy } from "./jwt.strategy";

@Module({
  imports: [
    PassportModule.register({ defaultStrategy: "jwt" }),
    JwtModule.register({
      secret: "your-secret-key", // 실무에서는 반드시 환경변수
      signOptions: { expiresIn: 3600 }, // 1시간 (초 단위)
    }),
    TypeOrmModule.forFeature([User]),
  ],
  controllers: [AuthController],
  providers: [AuthService, JwtStrategy],
  exports: [JwtStrategy, PassportModule],
})
export class AuthModule {}

Secret은 소스 코드에 하드코딩하면 안 됩니다. 코드가 GitHub 등에 올라가는 순간 Secret이 노출되고, 그 Secret으로 누구나 유효한 토큰을 위조할 수 있습니다. process.env.JWT_SECRET처럼 환경변수로 관리해야 합니다.

signOptions.expiresIn은 토큰의 만료 시간입니다. 3600이면 1시간 뒤 토큰이 무효화됩니다. 만료 시간이 너무 길면 토큰이 탈취됐을 때 피해 기간이 길어지고, 너무 짧으면 유저가 자주 재로그인해야 합니다. 실무에서는 짧은 accessToken + 긴 refreshToken을 조합하는 전략을 많이 사용합니다.


토큰 안의 username으로 유저를 꺼내오기 — JwtStrategy

토큰을 발급하는 것까지는 완료했습니다. 하지만 클라이언트가 유효하지 않은 토큰, 만료된 토큰, 혹은 변조된 토큰을 보낼 수도 있습니다. 이를 검증하는 것이 JwtStrategy의 역할입니다.

동작 순서를 정리하면 이렇습니다.

  1. 요청 헤더에서 Bearer 토큰을 추출한다.
  2. Secret Key로 Signature가 유효한지 검증한다.
  3. 유효하면 Payload에서 username을 꺼낸다.
  4. 그 username으로 DB에서 유저를 조회한다.
  5. 유저가 존재하면 validate()에서 유저 객체를 return한다.
  6. return된 객체가 자동으로 req.user에 주입된다.

1-3번은 Passport와 passport-jwt가 내부적으로 처리합니다. 개발자가 직접 구현하는 것은 4-6번, 즉 validate() 메서드입니다.

import { Injectable, UnauthorizedException } from "@nestjs/common";
import { PassportStrategy } from "@nestjs/passport";
import { InjectRepository } from "@nestjs/typeorm";
import { ExtractJwt, Strategy } from "passport-jwt";
import { Repository } from "typeorm";
import { User } from "./user.entity";

@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
  constructor(
    @InjectRepository(User)
    private userRepository: Repository<User>,
  ) {
    super({
      secretOrKey: "your-secret-key",
      jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
    });
  }

  async validate(payload: { username: string }): Promise<User> {
    const { username } = payload;
    const user = await this.userRepository.findOneBy({ username });

    if (!user) {
      throw new UnauthorizedException();
    }

    return user;
  }
}

PassportStrategy(Strategy)를 상속하면 Passport가 토큰 추출과 Signature 검증을 자동으로 처리한 뒤, 검증이 통과하면 validate()를 호출합니다. validate()가 return하는 객체를 NestJS가 자동으로 req.user에 넣어줍니다.

jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken()은 “Authorization 헤더에서 Bearer 뒤의 토큰을 꺼내라”는 설정입니다. 이 설정이 없으면 Passport가 토큰을 어디서 찾아야 하는지 모릅니다.


req.user를 더 깔끔하게 꺼내기 — 커스텀 데코레이터

validate()가 return한 유저는 req.user에 들어갑니다. 컨트롤러에서 @Req() req로 요청 객체를 받아 req.user를 꺼내도 되지만, 매번 이렇게 하면 코드가 장황해집니다.

커스텀 데코레이터를 만들면 파라미터에 @GetUser() user: User만 붙여서 바로 유저 객체를 받을 수 있습니다.

import { createParamDecorator, ExecutionContext } from "@nestjs/common";
import { User } from "./user.entity";

export const GetUser = createParamDecorator((_data: unknown, ctx: ExecutionContext): User => {
  const req = ctx.switchToHttp().getRequest();
  return req.user;
});

createParamDecorator는 NestJS가 제공하는 함수로, 커스텀 파라미터 데코레이터를 만들 때 사용합니다. ctx.switchToHttp().getRequest()로 HTTP 요청 객체에 접근하고, 거기서 user를 꺼내 return합니다.

사용하는 쪽에서는 이렇게 됩니다.

@Post("/test")
@UseGuards(AuthGuard())
test(@GetUser() user: User) {
  console.log("user", user);
}

@Req() req로 전체 요청 객체를 받아 req.user를 쓰는 것과 동작은 같지만, 의도가 훨씬 명확합니다. “이 핸들러는 인증된 유저가 필요하다”는 것이 코드만 봐도 드러납니다.


인증이 필요한 API에 Guard 걸기

게시물 API를 인증된 유저만 사용하도록 보호하려면 두 가지가 필요합니다.

첫째, BoardModule에서 AuthModule을 imports합니다. 이래야 AuthModule이 export한 PassportModule과 JwtStrategy를 BoardModule에서 사용할 수 있습니다.

@Module({
  imports: [TypeOrmModule.forFeature([Board]), AuthModule],
  controllers: [BoardController],
  providers: [BoardService],
})
export class BoardModule {}

둘째, 보호할 컨트롤러나 핸들러에 @UseGuards(AuthGuard())를 붙입니다.

@Controller("/boards")
@UseGuards(AuthGuard())
export class BoardController {
  // 이 컨트롤러의 모든 핸들러는 JWT 인증 필요
}

컨트롤러 레벨에 붙이면 그 안의 모든 핸들러에 적용됩니다. 특정 핸들러에만 붙이는 것도 가능합니다. Guard가 붙은 엔드포인트에 토큰 없이 접근하면 401 Unauthorized가 반환됩니다.


요청이 Controller에 닿기 전에 거치는 것들

NestJS에는 요청이 컨트롤러에 도달하기 전에 거치는 여러 레이어가 있습니다. 이 순서를 모르면 디버깅에서 헤매게 됩니다.

레이어역할
Middleware가장 먼저 실행. Express 미들웨어와 동일
Guard인증/인가 판단. 통과 여부를 boolean으로 결정
Interceptor요청 전후 처리. 로깅, 응답 변환, 캐시 등
Pipe유효성 검사, 데이터 변환
Filter에러가 발생했을 때 잡아서 처리

실행 순서를 흐름으로 그리면 다음과 같습니다.

Client Request

Middleware        →  요청 로깅, CORS 등

Guard             →  인증/인가 판단 (통과 or 거부)

Interceptor       →  요청 전처리

Pipe              →  유효성 검사, 변환

Controller → Service → Controller

Interceptor       →  응답 후처리

Filter            →  에러 발생 시 처리

Client Response

여기서 중요한 것은 Guard가 Pipe보다 먼저 실행된다는 점입니다. 인증에 실패하면 Pipe(ValidationPipe)가 실행되지 않습니다. “ValidationPipe가 동작하지 않는다”는 문제의 원인이 실은 Guard에서 401로 막혔기 때문인 경우가 있습니다.

반대로 말하면, Guard를 통과한 요청만 Pipe에 도달하므로, 인증되지 않은 요청에 대해 유효성 검사 비용을 낭비하지 않는 구조이기도 합니다.


마치며

게시판 CRUD만 있던 프로젝트에 Auth 모듈을 추가하면서, 회원가입 → 로그인 → JWT 발급 → 토큰 검증 → 보호된 API 접근까지 하나의 인증 파이프라인을 완성했습니다.

직접 구현하면서 세 가지가 확실히 와닿았습니다.

  • 에러 핸들링에서 instanceof QueryFailedError를 사용하면, catch의 any 타입 문제를 타입 레벨에서 해결할 수 있다. 수동으로 만든 interface에 의존하는 것은 추측이고, instanceof는 확인이다.
  • 비밀번호 보안은 “해시를 쓴다”가 아니라 “salt + 해시를 쓴다”가 올바른 기준이다. salt가 없으면 같은 비밀번호는 같은 해시를 생성하고, 레인보우 테이블 한 장이면 다수의 유저 비밀번호가 한 번에 뚫린다.
  • NestJS의 미들웨어 실행 순서(Middleware → Guard → Interceptor → Pipe)를 모르면, “왜 내 Pipe가 동작하지 않는가” 같은 상황에서 엉뚱한 곳을 디버깅하게 된다.