본문 바로가기

인증·인가와 서버 실행

JWT 인증과 관리자 작업의 인가를 연결하고 공개 응답과 실행 시 확인할 결과를 구분합니다.

인증 및 인가 구현

로그인한 사용자라고 해서 다른 사용자의 정보를 수정할 수 있어야 하는 것은 아닙니다. 먼저 요청을 보낸 사람이 누구인지 확인하고, 그 사람이 요청한 작업을 할 수 있는지 따로 판단하겠습니다. 앞의 /users는 관리자용으로 제한하고, 일반 사용자는 /auth/profile에서 자기 정보만 확인합니다.

그림의 위쪽은 로그인 때 비밀번호를 비교하는 과정이고, 아래쪽은 이후 요청에서 토큰과 현재 사용자·관리자 여부를 확인하는 과정입니다. 보호 요청마다 비밀번호 해시를 다시 비교하는 것은 아닙니다. 리소스별 소유권 규칙까지 구현하는 예제는 아닙니다.

신원 확인과 현재 정책 검사는 서로 다른 경계다 — 상태 추적

로그인에서 bcrypt/JWT 발급 뒤 보호 요청에서 JWT·현재 사용자·관리자 권한·공개 DTO를 확인한다. 401/403/허용과 credential 상태를 추적할 수 있다.

AUTHN · AUTHZ BOUNDARY

신원 확인과 현재 정책 검사는 서로 다른 경계다

로그인에서는 비밀번호 hash를 확인하고 JWT를 발급합니다. 보호 요청은 Bearer JWT를 검증한 뒤 현재 저장 사용자를 조회합니다. 사용자 관리는 관리자만 허용하고, 응답은 id·email·name만 담습니다. 리소스별 소유권 정책은 별도입니다.

신원 확인과 현재 정책 검사는 서로 다른 경계다로그인에서는 비밀번호를 확인해 토큰을 발급한다. 보호 요청은 JWT와 현재 저장 사용자를 확인한다. 사용자 관리는 현재 관리자 여부를 검사하고 id·email·name만 응답한다. 인증 실패는401, 관리자 작업 거절은403이다.로그인: 자격 증명 → 토큰 발급Credentialsemail · passwordHash 확인bcrypt.compare실패하면 인증 거절JWT 발급서명된 access token클라이언트로 반환보호 요청: Bearer JWT → 신원 → 정책 → 응답JWT 검증서명 · 만료issuer/audience 별도Principal검증된 신원현재 사용자 확인Policy check현재 관리자 여부거절하면 403Public DTO작업 성공 시허용 필드만 응답인증이 없거나 무효면 401 · 인증 성공도 자원 접근 권한을 보장하지 않습니다.
로그인의 비밀번호 확인과 이후 요청의 JWT/현재 DB 사용자 검증, admin 권한 분기를 구분한다.login→token: password를 매 요청 재확인하지 않음。token→current: JWT의 sub로 현재 사용자 조회。token→unauth: 인증 실패。current→route: 현재 사용자 기반 경로 정책。current→unauth: 삭제된 사용자 인증 실패。route→profile: 일반 인증 사용자 경로。route→admin: 관리자 경로。admin→ok: 관리자만 CRUD。admin→deny: 인증과 권한을 별개로 구분이후 요청유효오류profile/users예아니오로그인: password 비교로그인: password 비교성공하면 sub · email JWT보호 요청: Bearer JWT보호 요청: Bearer JWTsignature · exp 검증현재 DB 사용자현재 DB사용자삭제 ·admin변경반영401401토큰오류 ·사용자없음요청 경로 검사요청경로검사profile또는/usersprofileprofile인증된공개프로필/users: admin?/users:admin?현재 DBadmin검사허용된 CRUD허용된CRUD공개DTO만응답403403인증됨 ·권한부족
단계별 조건과 보충 설명
data-flow 핵심 계약
  1. 로그인 1 · Credentials → hash 확인

    email·password를 확인합니다. 비밀번호는 보호 요청마다 다시 비교하는 값이 아닙니다.

  2. 로그인 2 · JWT 발급

    확인된 로그인에 access token을 발급해 클라이언트로 반환합니다.

  3. 보호 요청 1 · Bearer JWT 검증

    서명·만료를 확인합니다. issuer/audience 검증은 설정이 필요하며 본문 예제에는 없습니다. 무효 인증은 401입니다.

  4. 보호 요청 2 · Principal → 정책

    현재 저장된 사용자를 조회합니다. 삭제된 계정은401, 일반 사용자의 users 관리 요청은403입니다. 관리자 여부는 JWT claim 대신 현재 조회 결과를 사용합니다.

  5. 보호 요청 3 · Public DTO

    profile·users 응답은 mapper가 id·email·name만 고릅니다. 공개 회원가입 register도 같은 mapper를 사용합니다. password hash와 isAdmin은 응답에 담지 않습니다.

  • 핵심 경계
  • 진행·선택 경로

JWT claim은 암호화된 비밀 저장소가 아닙니다. 이 예제는 서명·만료·현재 사용자와 관리자 여부를 연결합니다. issuer/audience 제한, 토큰 폐기·갱신과 리소스별 정책은 추가 설계 범위입니다.

이 예의 조건

  • 원도표의 전체 구조와 설명은 위 관계도와 표에서 유지합니다. 아래 인쇄 표에도 원도표의 관계·조건·라벨·설명·주석을 보존합니다.
  • 두 관찰 칸의 위치는 고정입니다. 관찰 상태는 해당 순간의 값이며, 동작·이유는 그 조건에서의 판정과 변화 원인입니다. 독립 비교와 실패 분기는 별도 조건으로 읽고 모든 분기를 한 요청의 필수 실행 순서로 이어 읽지 않습니다.
  • 로그인과 이후 Bearer 보호 요청은 별도 호출입니다. 마지막 장면은 사용자 관리 /users 요청의 관리자 제한이며 일반 사용자의 /auth/profile 요청은 별도 허용 경로로 공개 DTO를 받습니다. profile과 /users 처리를 한 요청의 순서로 잇지 않습니다.
관계도의 보충 설명
원도표의 전체 관계·조건·설명
구분내용
전체 관계·조건 설명로그인에서는 비밀번호를 확인해 토큰을 발급한다. 보호 요청은 JWT와 현재 저장 사용자를 확인한다. 사용자 관리는 현재 관리자 여부를 검사하고 id·email·name만 응답한다. 인증 실패는 401, 관리자 작업 거절은 403이다.
원도표 조건·주석로그인 1 · Credentials → hash 확인: email·password를 확인합니다. 비밀번호는 보호 요청마다 다시 비교하는 값이 아닙니다.
원도표 조건·주석로그인 2 · JWT 발급: 확인된 로그인에 access token을 발급해 클라이언트로 반환합니다.
원도표 조건·주석보호 요청 1 · Bearer JWT 검증: 서명·만료를 확인합니다. issuer/audience 검증은 설정이 필요하며 본문 예제에는 없습니다. 무효 인증은 401입니다.
원도표 설명현재 저장된 사용자를 조회합니다. 삭제된 계정은 401, 일반 사용자의 users 관리 요청은 403입니다. 관리자 여부는 JWT claim 대신 현재 조회 결과를 사용합니다.
원도표 조건·주석보호 요청 2 · Principal → 정책: 현재 저장된 사용자를 조회합니다. 삭제된 계정은 401, 일반 사용자의 users 관리 요청은 403입니다. 관리자 여부는 JWT claim 대신 현재 조회 결과를 사용합니다.
원도표 조건·주석보호 요청 3 · Public DTO: profile·users 응답은 mapper가 id·email·name만 고릅니다. 공개 회원가입 register도 같은 mapper를 사용합니다. password hash와 isAdmin은 응답에 담지 않습니다.
전체 상태와 변화 근거
장면현재 관찰값설명변화 이유
로그인검사: 비밀번호 / 결과: JWT 또는 로그인 거절email과 pwd를 현재 사용자의 bcrypt 검사 결과로 확인한다JWT 발급을 위한 인증과 보호 요청의 토큰 검증은 서로 다른 호출이다
Bearer 신원인증 실패: 401 / 신원: 현재 사용자보호 요청에서 JWT와 현재 사용자를 확인한다보호 요청마다 비밀번호를 다시 해시하는 것은 아니다
사용자 관리 /users 권한/users 역할 부족: 403 / 독립 /auth/profile: 일반 사용자도 공개 DTO id·email·name사용자 관리 /users의 요청자가 현재 관리자가 아니면 403이며 /auth/profile 공개 DTO 경로와 구분한다인증된 현재 사용자의 관리자 여부는 /users 인가 조건이며 모든 보호 경로의 관리자 요구가 아니다

신원 확인과 현재 정책 검사는 서로 다른 경계다 — 상태 추적

로그인에서 bcrypt/JWT 발급 뒤 보호 요청에서 JWT·현재 사용자·관리자 권한·공개 DTO를 확인한다. 401/403/허용과 credential 상태를 추적할 수 있다.

AUTHN · AUTHZ BOUNDARY

신원 확인과 현재 정책 검사는 서로 다른 경계다

로그인에서는 비밀번호 hash를 확인하고 JWT를 발급합니다. 보호 요청은 Bearer JWT를 검증한 뒤 현재 저장 사용자를 조회합니다. 사용자 관리는 관리자만 허용하고, 응답은 id·email·name만 담습니다. 리소스별 소유권 정책은 별도입니다.

신원 확인과 현재 정책 검사는 서로 다른 경계다로그인에서는 비밀번호를 확인해 토큰을 발급한다. 보호 요청은 JWT와 현재 저장 사용자를 확인한다. 사용자 관리는 현재 관리자 여부를 검사하고 id·email·name만 응답한다. 인증 실패는401, 관리자 작업 거절은403이다.로그인: 자격 증명 → 토큰 발급Credentialsemail · passwordHash 확인bcrypt.compare실패하면 인증 거절JWT 발급서명된 access token클라이언트로 반환보호 요청: Bearer JWT → 신원 → 정책 → 응답JWT 검증서명 · 만료issuer/audience 별도Principal검증된 신원현재 사용자 확인Policy check현재 관리자 여부거절하면 403Public DTO작업 성공 시허용 필드만 응답인증이 없거나 무효면 401 · 인증 성공도 자원 접근 권한을 보장하지 않습니다.
로그인의 비밀번호 확인과 이후 요청의 JWT/현재 DB 사용자 검증, admin 권한 분기를 구분한다.login→token: password를 매 요청 재확인하지 않음。token→current: JWT의 sub로 현재 사용자 조회。token→unauth: 인증 실패。current→route: 현재 사용자 기반 경로 정책。current→unauth: 삭제된 사용자 인증 실패。route→profile: 일반 인증 사용자 경로。route→admin: 관리자 경로。admin→ok: 관리자만 CRUD。admin→deny: 인증과 권한을 별개로 구분이후 요청유효오류profile/users예아니오로그인: password 비교로그인: password 비교성공하면 sub · email JWT보호 요청: Bearer JWT보호 요청: Bearer JWTsignature · exp 검증현재 DB 사용자현재 DB사용자삭제 ·admin변경반영401401토큰오류 ·사용자없음요청 경로 검사요청경로검사profile또는/usersprofileprofile인증된공개프로필/users: admin?/users:admin?현재 DBadmin검사허용된 CRUD허용된CRUD공개DTO만응답403403인증됨 ·권한부족
단계별 조건과 보충 설명
data-flow 핵심 계약
  1. 로그인 1 · Credentials → hash 확인

    email·password를 확인합니다. 비밀번호는 보호 요청마다 다시 비교하는 값이 아닙니다.

  2. 로그인 2 · JWT 발급

    확인된 로그인에 access token을 발급해 클라이언트로 반환합니다.

  3. 보호 요청 1 · Bearer JWT 검증

    서명·만료를 확인합니다. issuer/audience 검증은 설정이 필요하며 본문 예제에는 없습니다. 무효 인증은 401입니다.

  4. 보호 요청 2 · Principal → 정책

    현재 저장된 사용자를 조회합니다. 삭제된 계정은401, 일반 사용자의 users 관리 요청은403입니다. 관리자 여부는 JWT claim 대신 현재 조회 결과를 사용합니다.

  5. 보호 요청 3 · Public DTO

    profile·users 응답은 mapper가 id·email·name만 고릅니다. 공개 회원가입 register도 같은 mapper를 사용합니다. password hash와 isAdmin은 응답에 담지 않습니다.

  • 핵심 경계
  • 진행·선택 경로

JWT claim은 암호화된 비밀 저장소가 아닙니다. 이 예제는 서명·만료·현재 사용자와 관리자 여부를 연결합니다. issuer/audience 제한, 토큰 폐기·갱신과 리소스별 정책은 추가 설계 범위입니다.

이 예의 조건

  • 원도표의 전체 구조와 설명은 위 관계도와 표에서 유지합니다. 아래 인쇄 표에도 원도표의 관계·조건·라벨·설명·주석을 보존합니다.
  • 두 관찰 칸의 위치는 고정입니다. 관찰 상태는 해당 순간의 값이며, 동작·이유는 그 조건에서의 판정과 변화 원인입니다. 독립 비교와 실패 분기는 별도 조건으로 읽고 모든 분기를 한 요청의 필수 실행 순서로 이어 읽지 않습니다.
  • 로그인과 이후 Bearer 보호 요청은 별도 호출입니다. 마지막 장면은 사용자 관리 /users 요청의 관리자 제한이며 일반 사용자의 /auth/profile 요청은 별도 허용 경로로 공개 DTO를 받습니다. profile과 /users 처리를 한 요청의 순서로 잇지 않습니다.

관계도의 보충 설명

원도표의 전체 관계·조건·설명
구분내용
전체 관계·조건 설명로그인에서는 비밀번호를 확인해 토큰을 발급한다. 보호 요청은 JWT와 현재 저장 사용자를 확인한다. 사용자 관리는 현재 관리자 여부를 검사하고 id·email·name만 응답한다. 인증 실패는 401, 관리자 작업 거절은 403이다.
원도표 조건·주석로그인 1 · Credentials → hash 확인: email·password를 확인합니다. 비밀번호는 보호 요청마다 다시 비교하는 값이 아닙니다.
원도표 조건·주석로그인 2 · JWT 발급: 확인된 로그인에 access token을 발급해 클라이언트로 반환합니다.
원도표 조건·주석보호 요청 1 · Bearer JWT 검증: 서명·만료를 확인합니다. issuer/audience 검증은 설정이 필요하며 본문 예제에는 없습니다. 무효 인증은 401입니다.
원도표 설명현재 저장된 사용자를 조회합니다. 삭제된 계정은 401, 일반 사용자의 users 관리 요청은 403입니다. 관리자 여부는 JWT claim 대신 현재 조회 결과를 사용합니다.
원도표 조건·주석보호 요청 2 · Principal → 정책: 현재 저장된 사용자를 조회합니다. 삭제된 계정은 401, 일반 사용자의 users 관리 요청은 403입니다. 관리자 여부는 JWT claim 대신 현재 조회 결과를 사용합니다.
원도표 조건·주석보호 요청 3 · Public DTO: profile·users 응답은 mapper가 id·email·name만 고릅니다. 공개 회원가입 register도 같은 mapper를 사용합니다. password hash와 isAdmin은 응답에 담지 않습니다.
전체 상태와 변화 근거
장면현재 관찰값설명변화 이유
로그인검사: 비밀번호 / 결과: JWT 또는 로그인 거절email과 pwd를 현재 사용자의 bcrypt 검사 결과로 확인한다JWT 발급을 위한 인증과 보호 요청의 토큰 검증은 서로 다른 호출이다
Bearer 신원인증 실패: 401 / 신원: 현재 사용자보호 요청에서 JWT와 현재 사용자를 확인한다보호 요청마다 비밀번호를 다시 해시하는 것은 아니다
사용자 관리 /users 권한/users 역할 부족: 403 / 독립 /auth/profile: 일반 사용자도 공개 DTO id·email·name사용자 관리 /users의 요청자가 현재 관리자가 아니면 403이며 /auth/profile 공개 DTO 경로와 구분한다인증된 현재 사용자의 관리자 여부는 /users 인가 조건이며 모든 보호 경로의 관리자 요구가 아니다

로그인 요청에서는 이메일과 비밀번호를 확인하고, 이후 요청에서는 발급한 토큰을 확인합니다. 이 절에서는 POST /auth/login으로 토큰을 받은 뒤 GET /auth/profile에 보내는 흐름을 연결합니다.

NestJS에서는 @nestjs/passport와 passport.js를 통합해 이를 비교적 쉽게 구현할 수 있습니다.

이 예제의 JWT(JSON Web Token)는 서버가 서명한 사용자 번호와 이메일을 담은 토큰입니다. 클라이언트는 이후 요청의 Authorization: Bearer <토큰> 헤더에 이 값을 보냅니다. 토큰 내용은 암호화된 비밀 저장소가 아니므로 비밀번호 해시를 담지 않습니다. 관리자 여부도 토큰에서 가져오지 않고, 보호 요청마다 현재 저장된 사용자 정보에서 읽습니다.

필수 패키지 설치

bcryptjs 3에는 타입 정의가 포함되어 있어 @types/bcryptjs를 따로 설치하지 않습니다. 앞 절의 UserService와 아래 AuthService가 같은 bcryptjs를 사용해야 저장한 해시를 로그인에서 비교할 수 있습니다.

JWT 서명 키 준비: packages/server에서 다음 명령으로 무작위 값을 하나 생성합니다.

node -e "console.log(require('node:crypto').randomBytes(32).toString('base64url'))"

출력한 값을 로컬 .env의 JWT_SECRET에 넣습니다. .env와 이 값은 저장소에 올리지 않습니다. 아래 ConfigService.getOrThrow()는 키가 없으면 앱 시작을 거절하므로, 실행 오류가 나면 먼저 환경 변수 이름을 확인해 주세요.

AuthModule 및 AuthService 생성: 비밀번호 확인을 통과한 사용자를 서버 안에서 표현할 타입부터 만듭니다. 공개 응답에 필요한 세 필드와 가드가 사용할 관리자 여부만 남기며, 비밀번호와 생성 시각 같은 엔터티 필드는 포함하지 않습니다.

packages/server/src/auth/authenticated-user.ts
export interface AuthenticatedUser {
  id: number;
  email: string;
  name: string;
  isAdmin: boolean;
}

이 타입은 서버 내부의 인증 결과입니다. 클라이언트에게 보내는 PublicUserDto와 목적이 다르므로 shared에 그대로 내보내지 않습니다. 다음 서비스는 비밀번호가 맞으면 이 객체를 만들고, 로그인 응답에는 토큰만 반환합니다.

packages/server/src/auth/auth.service.ts
import { Injectable } from '@nestjs/common';
import { UserService } from '../user/user.service';
import { JwtService } from '@nestjs/jwt';
import * as bcrypt from 'bcryptjs';
import { AuthenticatedUser } from './authenticated-user';

@Injectable()
export class AuthService {
  constructor(
    private userService: UserService,
    private jwtService: JwtService, // JwtService 주입
  ) {}

  async validateUser(email: string, pass: string): Promise<AuthenticatedUser | null> {
    const user = await this.userService.findOneByEmail(email);
    if (user && (await bcrypt.compare(pass, user.password))) {
      return {
        id: user.id,
        email: user.email,
        name: user.name,
        isAdmin: user.isAdmin,
      };
    }
    return null;
  }

  async login(user: AuthenticatedUser) {
    const payload = { sub: user.id, email: user.email };
    return {
      accessToken: this.jwtService.sign(payload),
    };
  }
}

LocalStrategy 정의: LocalAuthGuard는 이름이 local인 전략을 호출합니다. 다음 파일을 만들고 로그인 본문의 email을 사용자 이름 필드로 지정합니다. 이메일이 없거나 비밀번호가 다르면 401 Unauthorized로 응답합니다.

packages/server/src/auth/local.strategy.ts
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { Strategy } from 'passport-local';
import { AuthService } from './auth.service';

@Injectable()
export class LocalStrategy extends PassportStrategy(Strategy) {
  constructor(private readonly authService: AuthService) {
    super({ usernameField: 'email', passwordField: 'password' });
  }

  async validate(email: string, password: string) {
    const user = await this.authService.validateUser(email, password);
    if (!user) {
      throw new UnauthorizedException();
    }
    return user;
  }
}

JwtStrategy 정의: 서명과 만료 검사가 끝난 뒤에도 현재 계정이 있는지 확인합니다. 토큰의 sub는 사용자 번호를 뜻하며, 아래 코드는 양의 정수인지 검사한 뒤 사용자를 다시 조회합니다.

packages/server/src/auth/jwt.strategy.ts
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { Strategy, ExtractJwt } from 'passport-jwt';
import { ConfigService } from '@nestjs/config'; // ConfigService 임포트
import { UserService } from '../user/user.service';
import { AuthenticatedUser } from './authenticated-user';

@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
  constructor(
    private configService: ConfigService,
    private userService: UserService,
  ) {
    super({
      jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), // Bearer 토큰에서 JWT 추출
      ignoreExpiration: false, // 만료된 토큰 무시 안 함
      secretOrKey: configService.getOrThrow<string>('JWT_SECRET'), // 키가 없으면 시작 거절
    });
  }

  async validate(payload: unknown): Promise<AuthenticatedUser> {
    if (
      typeof payload !== 'object' || payload === null ||
      !('sub' in payload) || typeof payload.sub !== 'number' ||
      !Number.isSafeInteger(payload.sub) || payload.sub <= 0 ||
      !('email' in payload) || typeof payload.email !== 'string'
    ) {
      throw new UnauthorizedException();
    }

    const user = await this.userService.findOneByIdOrNull(payload.sub);
    if (!user || user.email !== payload.email) {
      throw new UnauthorizedException();
    }

    return {
      id: user.id,
      email: user.email,
      name: user.name,
      isAdmin: user.isAdmin,
    };
  }
}

JwtAuthGuard가 이 전략의 반환값을 req.user에 넣습니다. 삭제된 계정이나 저장된 이메일과 맞지 않는 토큰은 401로 거절합니다. 이메일을 바꾼 뒤에는 새 이메일로 다시 로그인해야 하며, 관리자 권한이 바뀌면 다음 보호 요청에서 바뀐 값을 읽습니다.

서명·만료·현재 사용자 조회를 모두 연결했지만 토큰별 폐기 목록, 비밀번호 변경 시 기존 토큰 폐기, 발급자(iss)·대상(aud) 제한은 구현하지 않습니다. 갱신 토큰과 계정 복구도 별도 설계가 필요합니다. JWT_SECRET은 충분한 무작위 비밀값으로 주입하고 시작 시 누락을 거절해야 합니다.

AuthModule 설정: JwtModule과 PassportModule을 구성하고 서비스, 전략을 등록합니다.

packages/server/src/auth/auth.module.ts
import { Module } from '@nestjs/common';
import { PassportModule } from '@nestjs/passport';
import { JwtModule } from '@nestjs/jwt';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { AuthService } from './auth.service';
import { UserModule } from '../user/user.module';
import { LocalStrategy } from './local.strategy'; // 로컬 전략 (사용자 이름/비밀번호)
import { JwtStrategy } from './jwt.strategy';
import { AuthController } from './auth.controller';
import { LocalAuthGuard } from './local-auth.guard';
import { JwtAuthGuard } from './jwt-auth.guard';

@Module({
  imports: [
    UserModule, // UserService를 사용하기 위해 UserModule 임포트
    PassportModule,
    JwtModule.registerAsync({ // 비동기로 JWT 설정 (ConfigService 주입)
      imports: [ConfigModule],
      useFactory: async (configService: ConfigService) => ({
        secret: configService.getOrThrow<string>('JWT_SECRET'),
        signOptions: { expiresIn: '60m' }, // 토큰 만료 시간
      }),
      inject: [ConfigService],
    }),
  ],
  controllers: [AuthController],
  providers: [AuthService, LocalStrategy, JwtStrategy, LocalAuthGuard, JwtAuthGuard],
  exports: [AuthService], // AuthService를 다른 모듈에서 사용 가능하도록 내보내기
})
export class AuthModule {}

루트 모듈에 인증 모듈 연결: AuthModule을 만드는 것만으로 앱에 등록되지는 않습니다. 앞 절의 app.module.ts에서 다음처럼 import와 imports 배열에 추가합니다. UserModule의 exports: [UserService]와 AuthModule의 imports: [UserModule]도 함께 있어야 인증 서비스가 사용자 서비스를 주입받습니다.

packages/server/src/app.module.ts
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { UserModule } from './user/user.module';
import { AuthModule } from './auth/auth.module';

@Module({
  imports: [
    ConfigModule.forRoot({
      isGlobal: true, // 어디서든 ConfigService를 주입받아 사용 가능
      envFilePath: '.env', // .env 파일 경로 지정
    }),
    TypeOrmModule.forRoot({
      type: 'postgres',
      host: process.env.DATABASE_HOST,
      port: parseInt(process.env.DATABASE_PORT || '5432', 10),
      username: process.env.DATABASE_USER,
      password: process.env.DATABASE_PASSWORD,
      database: process.env.DATABASE_NAME,
      entities: [__dirname + '/**/*.entity{.ts,.js}'], // 모든 엔티티 파일을 자동으로 찾음
      synchronize: process.env.NODE_ENV === 'development', // 개발 환경에서만 동기화
      logging: process.env.NODE_ENV === 'development', // 개발 환경에서만 로깅
    }),
    UserModule,
    AuthModule,
  ],
  controllers: [AppController],
  providers: [AppService],
})
export class AppModule {}

AuthController 구현: 회원가입·로그인과 토큰으로 접근하는 프로필 확인 경로를 정의합니다.

packages/server/src/auth/auth.controller.ts
import { Controller, Get, Post, Request, UseGuards, Body, HttpCode, HttpStatus } from '@nestjs/common';
import { AuthService } from './auth.service';
import { LocalAuthGuard } from './local-auth.guard';
import { JwtAuthGuard } from './jwt-auth.guard';
import { CreateUserDto } from '../user/dto/create-user.dto';
import { UserService } from '../user/user.service';
import { AuthenticatedUser } from './authenticated-user';
import { PublicUserDto, toPublicUser } from '../user/dto/public-user.dto';

@Controller('auth')
export class AuthController {
  constructor(
    private authService: AuthService,
    private userService: UserService, // 회원가입을 위해 UserService 사용
  ) {}

  @Post('register')
  @HttpCode(HttpStatus.CREATED)
  async register(@Body() createUserDto: CreateUserDto): Promise<PublicUserDto> {
    return toPublicUser(await this.userService.create(createUserDto));
  }

  @UseGuards(LocalAuthGuard) // LocalAuthGuard를 사용하여 로컬 전략 실행
  @Post('login')
  @HttpCode(HttpStatus.OK) // 200 OK 응답
  async login(@Request() req: { user: AuthenticatedUser }) {
    // LocalAuthGuard가 req.user에 사용자 정보를 저장합니다.
    return this.authService.login(req.user);
  }

  @UseGuards(JwtAuthGuard)
  @Get('profile')
  profile(@Request() req: { user: AuthenticatedUser }): PublicUserDto {
    return toPublicUser(req.user);
  }
}

인증 가드 (local-auth.guard.ts, jwt-auth.guard.ts): passport-jwt와 passport-local을 NestJS 가드로 래핑합니다.

packages/server/src/auth/local-auth.guard.ts
import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';

@Injectable()
export class LocalAuthGuard extends AuthGuard('local') {} // 'local' 전략 사용
packages/server/src/auth/jwt-auth.guard.ts
import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';

@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {} // 'jwt' 전략 사용

앞 절의 UserController에는 이미 @UseGuards(JwtAuthGuard, RolesGuard)를 적용했습니다. 첫 가드가 사용자 신원을 확인하고, 다음 역할 가드는 그 사용자가 관리 작업을 할 수 있는지 판단합니다. 아래에서 RolesGuard와 데코레이터를 작성하면 해당 import가 연결됩니다.


역할 기반 인가 구현

로그인 실패는 401, 로그인한 일반 사용자가 /users를 호출하는 경우는 403으로 구분합니다. 이 예제는 관리자 한 역할만 다루며 여러 역할과 리소스별 정책을 조합하는 전체 RBAC 시스템은 만들지 않습니다.

역할 데코레이터 (packages/server/src/auth/roles.decorator.ts)
packages/server/src/auth/roles.decorator.ts
import { SetMetadata } from '@nestjs/common';

export const ROLES_KEY = 'roles';
export const Roles = (...roles: Array<'admin'>) => SetMetadata(ROLES_KEY, roles);

@Roles('admin')은 이 경로에 필요한 역할을 메타데이터로 남깁니다. 데코레이터만 붙이는 것으로 요청이 제한되지는 않으므로 다음 가드가 그 값을 읽어야 합니다. 이 최소 예제는 외부 shared enum에 의존하지 않습니다.

역할 가드 (packages/server/src/auth/roles.guard.ts)
packages/server/src/auth/roles.guard.ts
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { ROLES_KEY } from './roles.decorator';
import { AuthenticatedUser } from './authenticated-user';

@Injectable()
export class RolesGuard implements CanActivate {
  constructor(private reflector: Reflector) {}

  canActivate(context: ExecutionContext): boolean {
    const requiredRoles = this.reflector.getAllAndOverride<Array<'admin'>>(ROLES_KEY, [
      context.getHandler(), // 메서드 레벨 데코레이터
      context.getClass(),   // 클래스 레벨 데코레이터
    ]);
    if (!requiredRoles) {
      return true; // 역할 제한이 없으면 접근 허용
    }

    const { user } = context.switchToHttp().getRequest<{ user?: AuthenticatedUser }>();
    return requiredRoles.some((role) => role === 'admin' && user?.isAdmin === true);
  }
}
컨트롤러에 적용

앞 절의 완전한 user.controller.ts에는 클래스 단위로 @UseGuards(JwtAuthGuard, RolesGuard)와 @Roles('admin')을 붙였습니다. 따라서 목록 조회뿐 아니라 상세 조회·생성·수정·삭제도 같은 순서로 검사합니다. 이 예제에는 메서드별 역할 재정의가 없습니다.

JwtAuthGuard가 실패하면 역할 가드까지 가지 않고 401로 끝납니다. 신원이 확인된 일반 사용자는 RolesGuard가 false를 반환하여 403을 받습니다. UserModule의 providers에 두 가드가 있고, AuthModule의 전략들이 루트 모듈에 등록되었는지도 함께 확인해 주세요.


API 서버 실행 전 연결 확인

여기까지의 파일은 앞 절의 UserService와 UserModule을 이어서 사용합니다. local.strategy.ts와 두 가드 파일을 만들었는지, 루트 AppModule이 AuthModule을 가져오는지 확인합니다. 로그인 경로가 404라면 모듈·컨트롤러 등록부터, 401이라면 본문의 email·password와 저장된 사용자부터 확인해 주세요.

회원가입·프로필·관리자 사용자 응답은 모두 앞 절의 toPublicUser를 거칩니다. id·email·name만 공개하며, password·isAdmin·생성/수정 시각은 응답에 담지 않습니다. 로그인 응답은 사용자 DTO 대신 accessToken 하나를 반환합니다.

관리자 허용 응답을 살펴보려면 실습용 DB에 신뢰할 수 있는 초기화 절차로 관리자 계정을 준비해야 합니다. 이 절은 초기 관리자 발급 절차를 제공하지 않으며 공개 회원가입으로 관리자 계정을 만들 수도 없습니다. 관리자 계정을 준비하지 않았다면 회원가입한 사용자의 /users 요청이 403인 것이 의도한 결과입니다.

API 서버 실행

앞 절과 이 절의 파일을 모두 작성한 뒤에는 다음 순서로 동작을 살펴볼 수 있습니다. 아래 표의 기대 응답과 실제 응답을 비교해 주세요.

packages/server 디렉터리에서 다음 명령어를 실행합니다.

npm run start:dev

이 실행은 앞 절의 실습용 PostgreSQL과 .env 설정이 준비되었다는 전제입니다. 서버는 http://localhost:4100에서 시작합니다. DB 연결 오류가 나면 먼저 실습 DB가 실행 중인지와 DATABASE_* 값을 확인합니다.

로컬 실습 DB에 사용자가 없다면 POST /auth/register로 먼저 등록합니다. 아래 이메일·비밀번호는 로컬 실습용 입력이며 운영 계정에 재사용하지 않습니다.

curl -i http://localhost:4100/auth/register -H "Content-Type: application/json" -d '{"email":"[email protected]","password":"local-practice-password","name":"학습자"}'
curl -i http://localhost:4100/auth/login -H "Content-Type: application/json" -d '{"email":"[email protected]","password":"local-practice-password"}'

회원가입 응답의 기대 형태는 { "id": 1, "email": "[email protected]", "name": "학습자" }입니다. 실제 id는 DB가 생성한 값이므로 1과 다를 수 있습니다. 로그인에 성공하면 200과 accessToken을 받습니다. 다음 요청의 <받은 accessToken> 자리에 그 값을 넣습니다.

curl -i http://localhost:4100/auth/profile -H "Authorization: Bearer <받은 accessToken>"
요청확인할 결과다를 때 먼저 확인할 곳
등록한 이메일·올바른 비밀번호로 로그인200, accessTokenLocalStrategy 등록, email 필드 설정, 해시 저장
잘못된 비밀번호 또는 없는 이메일로 로그인401비밀번호 비교 결과와 UnauthorizedException
받은 토큰으로 프로필 확인200, 사용자 id·email·nameBearer 헤더, 현재 저장 사용자, JwtStrategy와 서명 키
토큰 없이 또는 잘못된 토큰으로 프로필 확인401JwtAuthGuard 적용 여부
isAdmin: true를 추가한 공개 회원가입400, 관리자 계정이 생성되지 않음CreateUserDto와 전역 forbidNonWhitelisted
일반 사용자의 토큰으로 GET /users403클래스의 @Roles('admin')과 RolesGuard
토큰 없이 GET /users401JwtAuthGuard가 역할 가드보다 먼저 적용됐는지
미리 준비한 관리자 토큰으로 GET /users200, 각 항목에 공개 세 필드만 있음현재 저장된 isAdmin, toPublicUser

관리자 생성·수정 요청에도 isAdmin을 추가하면 400으로 거절하도록 입력 DTO를 공유합니다. 일반 관리 수정이 권한 승격 경로로 바뀌지 않게 하기 위한 제한입니다. 정상 생성은 201, 조회·수정은 200과 공개 DTO, 삭제는 본문이 없는 204를 기대합니다.

삭제된 계정의 기존 토큰은 401, 관리자 권한을 회수한 계정의 관리 요청은 403이 되도록 현재 사용자 조회를 연결했습니다. 관리자 권한이 없어져도 유효한 계정의 자기 프로필 조회는 허용됩니다. 저장소 오류 처리·이메일 중복 충돌·토큰 폐기·운영용 인증 정책까지 완성한 예제로 해석하지 않습니다.


백엔드 구현 요약

이 절에서는 NestJS를 활용하여 타입스크립트 기반의 API 서버를 구현하는 핵심 과정을 살펴보았습니다.

앞 절의 사용자 서비스에 이메일·비밀번호 확인과 JWT 발급을 연결했습니다. 보호 요청은 현재 사용자를 다시 조회하고, /users 관리 작업은 현재 관리자 여부를 확인합니다. 회원가입·프로필·관리자 응답은 같은 공개 DTO로 변환하며, 내부 인증 결과와 JWT 내용은 공개 응답과 구분합니다.

NestJS의 모듈 구조와 데코레이터 문법은 타입스크립트와 함께 구조화된 API 서버를 만드는 데 도움이 됩니다.

다음 절에서는 이 API 서버와 연동될 클라이언트 애플리케이션을 구현합니다. 회원가입은 공개 /auth/register로 보내고, 로그인 뒤에는 /auth/profile의 id·email·name을 화면에 연결합니다. /users는 관리자 관리 경로이며 프로필 응답에는 isAdmin이 없다는 계약을 클라이언트에도 맞춰 주세요.