본문 바로가기
내일배움 과제/트렐로

nestjs jwt 인증, 비번 해싱 및 검증 , 로그아웃, 마이그레이션

by GREEN나무 2025. 2. 4.
728x90

 

// src\auth\auth.middleware.ts
// authorization토큰 인증
import {
  Injectable,
  NestMiddleware,
  UnauthorizedException,
} from '@nestjs/common';
import { JwtService } from '@nestjs/jwt'; // JWT 토큰을 생성하고 검증하는 데 사용하는 NestJS 서비스.
import { NextFunction, Request, Response } from 'express';
import { ConfigService } from '@nestjs/config'; //  환경 변수에서(.env) JWT 시크릿 키(JWT_SECRET_KEY)를 가져오는 데 사용

// Request 인터페이스 확장
// 토큰 검증 후 사용자 정보를 요청 객체에 저장
interface TokenRequest extends Request {
  user?: any; //  user 프로퍼티를 추가
}

@Injectable() // 의존성 주입(DI)
export class AuthMiddleware implements NestMiddleware {
  constructor(
    private jwtService: JwtService,
    private configService: ConfigService,
  ) {}

  // use 메서드는 NestJS의 미들웨어에서 필수적으로 구현해야 하는 메서드
  use(req: TokenRequest, res: Response, next: NextFunction) {
    const authHeader = req.headers.authorization;

    // 토큰 유무 확인
    if (!authHeader || !authHeader.startsWith('Bearer ')) {
      throw new UnauthorizedException('토큰이 필요합니다.');
    }

    const token = authHeader.split(' ')[1]; // Beare 뒤의 토큰값 가져오기

    try {
      // jwtService.verify(token, {비밀키}) 를 이용해 토큰 검증
      const decoded = this.jwtService.verify(token, {
        // 환경변수 가져오기
        secret: this.configService.get<string>('JWT_SECRET_KEY'),
      });

      req.user = decoded; // 요청 객체에 사용자 정보 저장
      next();
    } catch (error) {
      throw new UnauthorizedException('만료되었거나 유효하지 않은 토큰입니다.');
    }
  }
}

 

인증 미들웨어를 사용할 때

1. auth서비스,  컨트롤러, 모듈, jwt.전략? 파일을 만들어 항목별 컨트롤러에서 지정해 쓰는 방법과

  @Patch('me')
 @UseGuards(AuthGuard('jwt')) // 토큰 인증
  async updateUser(@Body() updateUserDto: UpdateUserDto, @Request() req) {
    const userPayload = req.user;
    return this.userService.update(updateUserDto, userPayload);
  }

 

2. 사용하려는 항목의 모듈에서 사용하는 url을 지정하는 방법이 있습니다.

// user 모듈에서 export할 때 예외 사항 지정
export class UserModule implements NestModule {
  configure(consumer: MiddlewareConsumer) {
    consumer
      .apply(AuthMiddleware)
      .exclude(
        { path: 'user/login', method: RequestMethod.POST },
        { path: 'user/signup', method: RequestMethod.POST },
        { path: 'user/refresh', method: RequestMethod.GET },
        { path: 'user/users', method: RequestMethod.GET },
      )
      .forRoutes(UserController);
  }
}

 

 


토큰 발행

import { JwtService } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config'; // .env 파일이나 환경 변수를 쉽게 불러올 수 있도록 도와주는 NestJS의 서비스.
...
@Injectable()
export class UserService {
  constructor(
    @InjectRepository(User)
    private userRepository: Repository<User>,
    private jwtService: JwtService, // JWT 토큰 생성을 위해 주입한 서비스
    private configService: ConfigService, // .env 파일에 있는 환경 변수 불러오기
  ) {}

...
    // 로그인 ...
    // JWT 토큰 생성
    const payload = { id: user.id, email: user.email };
    const accessToken = this.createAccessToken(payload);
    const refreshToken = this.createRefreshToken(payload);
    return { accessToken, refreshToken };
  }

  /** 액세스 토큰 생성 */
  private createAccessToken(payload: object): string {
    return this.jwtService.sign(payload, {
      secret: this.configService.get<string>('JWT_SECRET_KEY'),
      // ACCESS_TOKEN_EXPIRY값이 없으면 15분으로 지정
      expiresIn: this.configService.get<string>('ACCESS_TOKEN_EXPIRY', '15m'),
    });
  }

  /** 리프레시 토큰 생성 */
  private createRefreshToken(payload: object): string {
    return this.jwtService.sign(payload, {
      secret: this.configService.get<string>('JWT_SECRET_KEY'),
      expiresIn: this.configService.get<string>('REFRESH_TOKEN_EXPIRY', '7d'),
    });
  }

 

토큰 재발행(재발행할 근거가 되는 리프레시토큰이 유효해야함)

 

 

 

로그아웃(사용못하는 토큰 발급)

1.  Redis를 사용해 블랙리스트(로그아웃인 상태의 유저)에 추가하는 방법(여러 파일을 추가해야함)

2. 사용불가능한 토큰 발행 ( 토큰이 자동으로 해더에 추가되어야 함)

      유효기한을 1초로 주거나

      payload에 id를 안넣거나 

      아무 의미없는 값(ex. 0)을 토큰값으로 주기

 

 

 


비밀번호 암호화

  // 비밀번호 해싱 함수
  private async hashPassword(password: string): Promise<string> {
    // .env 파일에서 BCRYPT_SALT_ROUNDS 값을 가져오고, 없으면 기본값 10을 사용.
    const saltRounds = Number(
      this.configService.get('BCRYPT_SALT_ROUNDS', '10'),
    );
    console.log(`Salt Rounds: ${saltRounds}, Type: ${typeof saltRounds}`); // 디버깅용 로그
    return bcrypt.hash(password, saltRounds); // 비밀번호를 해싱해서 안전하게 저장.
  }  
  ...
   // 비밀번호 해싱
  createUserDto.password = await this.hashPassword(createUserDto.password);

 

비밀번호 검증

password : 로그인 바디로 입력받은 비번

user.password : user테이블에 저장된 암호화된 비번

bcrypt.compare(정상값, 해싱된 값) : 암호화 된 값을 해독해 정상값과 같은지 검증하는 코드

 

※ 해싱은 입력값(비밀번호)을 고정된 길이의 난수화된 문자열(해시 값) 로 변환하는 과정

※ 일반적으로 비밀번호 저장 시 Bcrypt 같은 느린 해시 알고리즘을 사용하는 것이 안전

    // 비밀번호 검증
    const isMatch = await bcrypt.compare(password, user.password);
    if (!isMatch) {
      throw new UnauthorizedException(
        `${email} 유저의 비밀번호가 올바르지 않습니다.`,
      );
    }

유저 모듈

더보기
// user.module.ts
import {
  Module,
  MiddlewareConsumer,
  NestModule,
  RequestMethod,
} from '@nestjs/common';

import { JwtModule } from '@nestjs/jwt'; // WT 관련 기능을 제공하는 모듈. 이를 imports 배열에 추가하여 사용
import { TypeOrmModule } from '@nestjs/typeorm';
import { ConfigService } from '@nestjs/config';

import { User } from './entities/user.entity';
import { UserController } from './user.controller';
import { UserService } from './user.service';
import { AuthMiddleware } from '../auth/auth.middleware'; // 분리된 미들웨어

@Module({
  imports: [
    TypeOrmModule.forFeature([User]),
    JwtModule.registerAsync({
      useFactory: (config: ConfigService) => ({
        secret: config.get<string>('JWT_SECRET_KEY'),
      }),
      inject: [ConfigService],
    }),
  ],
  controllers: [UserController],
  providers: [UserService],
  exports: [UserService, JwtModule],
})
// export class UserModule {}
export class UserModule implements NestModule {
  configure(consumer: MiddlewareConsumer) {
    consumer
      .apply(AuthMiddleware)
      .exclude(
        { path: 'user/login', method: RequestMethod.POST },
        { path: 'user/signup', method: RequestMethod.POST },
        { path: 'user/refresh', method: RequestMethod.GET },
        { path: 'user/users', method: RequestMethod.GET },
      )
      .forRoutes(UserController);
  }
}

유저 서비스

더보기
// 유저 서비스
import _ from 'lodash';
import {
  BadRequestException,
  Injectable,
  ConflictException,
  NotFoundException,
  UnauthorizedException,
  Req,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { JwtService } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config'; // .env 파일이나 환경 변수를 쉽게 불러올 수 있도록 도와주는 NestJS의 서비스.
import * as bcrypt from 'bcrypt';

import { CreateUserDto } from './dto/create-user.dto';
import { LoginUserDto } from './dto/login-user.dto';
import { UpdateUserDto } from './dto/update-user.dto';
import { DeleteUserDto } from './dto/delete-user.dto';
import { User } from './entities/user.entity';

@Injectable()
export class UserService {
  constructor(
    @InjectRepository(User)
    private userRepository: Repository<User>,
    private jwtService: JwtService, // JWT 토큰 생성을 위해 주입한 서비스
    private configService: ConfigService, // .env 파일에 있는 환경 변수 불러오기
  ) {}

  // 비밀번호 해싱 함수
  private async hashPassword(password: string): Promise<string> {
    // .env 파일에서 BCRYPT_SALT_ROUNDS 값을 가져오고, 없으면 기본값 10을 사용.
    const saltRounds = Number(
      this.configService.get('BCRYPT_SALT_ROUNDS', '10'),
    );
    console.log(`Salt Rounds: ${saltRounds}, Type: ${typeof saltRounds}`); // 디버깅용 로그
    return bcrypt.hash(password, saltRounds); // 비밀번호를 해싱해서 안전하게 저장.
  }

  //POST	회원가입	/user/signup
  async create(createUserDto: CreateUserDto) {
    if (createUserDto.verifyPassword !== createUserDto.password) {
      throw new UnauthorizedException(`비밀번호가 서로 다릅니다.`);
    }

    const existUser = await this.userRepository.findOne({
      where: { email: createUserDto.email },
    });

    if (existUser) {
      throw new ConflictException(
        `이미 가입된 ID입니다. ID: ${createUserDto.email}`,
      );
    }
    // verifyPassword 삭제
    delete createUserDto.verifyPassword;

    if (!createUserDto.password?.trim()) {
      throw new NotFoundException(`비밀번호를 입력하세요`);
    }

    // 비밀번호 해싱 후 저장
    console.log(createUserDto.password); // pw1234

    // 여기서 막힘
    createUserDto.password = await this.hashPassword(createUserDto.password);
    console.log('회원가입 비번해싱 :', createUserDto.password);

    const newUser = this.userRepository.create(createUserDto);
    await this.userRepository.save(newUser);
    console.log('회원가입 서비스 - 저장성공');

    return {
      message: '회원가입이 완료되었습니다.',
      email: createUserDto.email,
    };
  }

  //POST	로그인	/user/login
  async login(loginUserDto: LoginUserDto) {
    const { email, password } = loginUserDto;

    const user = await this.userRepository.findOne({
      where: { email, deletedAt: null },
      select: ['id', 'email', 'password'],
    });

    if (!user) {
      throw new NotFoundException(`${email} 유저를 찾을 수 없습니다.`);
    }

    // 비밀번호 검증
    const isMatch = await bcrypt.compare(password, user.password);
    if (!isMatch) {
      throw new UnauthorizedException(
        `${email} 유저의 비밀번호가 올바르지 않습니다.`,
      );
    }

    // JWT 토큰 생성
    const payload = { id: user.id, email: user.email };
    const accessToken = this.createAccessToken(payload);
    const refreshToken = this.createRefreshToken(payload);
    return { accessToken, refreshToken };
  }

  /** 액세스 토큰 생성 */
  private createAccessToken(payload: object): string {
    return this.jwtService.sign(payload, {
      secret: this.configService.get<string>('JWT_SECRET_KEY'),
      expiresIn: this.configService.get<string>('ACCESS_TOKEN_EXPIRY', '15m'),
    });
  }

  /** 리프레시 토큰 생성 */
  private createRefreshToken(payload: object): string {
    return this.jwtService.sign(payload, {
      secret: this.configService.get<string>('JWT_SECRET_KEY'),
      expiresIn: this.configService.get<string>('REFRESH_TOKEN_EXPIRY', '7d'),
    });
  }

  // accessToken 재발행
  /** 토큰 재발행 */
  async refreshToken(refreshToken: string) {
    try {
      const decoded = this.jwtService.verify(refreshToken, {
        secret: this.configService.get<string>('JWT_SECRET_KEY'),
      });

      const payload = { id: decoded.id, email: decoded.email };
      const newAccessToken = this.createAccessToken(payload);
      //const newRefreshToken = this.createRefreshToken(payload);

      return { accessToken: newAccessToken }; //;, refreshToken: newRefreshToken
    } catch (error) {
      throw new UnauthorizedException('유효하지 않은 리프레시 토큰입니다.');
    }
  }

  //POST	로그아웃	/user/logout
  /** 로그아웃 (토큰 유효 시간 1초 미만으로 설정) */
  async logout(user) {
    const payload = { id: user.id, email: user.email };
    console.log('로그아웃 서비스');
    // 만료 시간이 1초 미만인 액세스 토큰과 리프레시 토큰 생성
    const expiredAccessToken = this.jwtService.sign(payload, {
      secret: this.configService.get<string>('JWT_SECRET_KEY'),
      expiresIn: '1s',
    });

    const expiredRefreshToken = this.jwtService.sign(payload, {
      secret: this.configService.get<string>('JWT_SECRET_KEY'),
      expiresIn: '1s',
    });
    return {
      message: '로그아웃이 성공적으로 완료되었습니다.',
      accessToken: expiredAccessToken,
      refreshToken: expiredRefreshToken,
    };
  }

  // test용 전체조회
  async findAll() {
    const user = await this.userRepository.find({});
    if (!user) {
      throw new NotFoundException(`유저를 찾을 수 없습니다.`);
    }
    return user;
  }

  //GET	회원 정보 조회	/user/:userId
  async findOne(userId: number) {
    if (!userId) {
      throw new UnauthorizedException('찾으려는 userId를 입력하세요');
    }
    const user = await this.userRepository.findOne({
      where: { id: userId },
      select: ['name', 'email'],
    });

    if (!user) {
      throw new NotFoundException(`유저를 찾을 수 없습니다.`);
    }
    return user;
  }

  //GET	내 정보 상세조회	/user/me
  async findMe(userPayload: any) {
    const user = await this.userRepository.findOne({
      where: { id: userPayload.id },
      select: ['name', 'email', 'password'],
    });

    if (!user) {
      throw new NotFoundException(`유저 정보를 찾을 수 없습니다.`);
    }
    return user;
  }

  //PATCH	회원 정보 수정	/user/me
  // 이름, 비번 수정
  async update(updateUserDto: UpdateUserDto, userPayload: any) {
    if (!updateUserDto) {
      throw new UnauthorizedException('수정할 정보를 입력하세요');
    }

    // 조회
    const myInfo = await this.userRepository.findOne({
      where: { id: userPayload.id },
      select: ['password', 'name'],
    });

    if (!myInfo) {
      throw new NotFoundException(`내 계정정보를 확인할 수 없습니다.`);
    }
    // 비번 비교 (bcrypt 사용)
    // bcrypt.compare() : bcrypt 라이브러리에서 제공하는 함수로, 해시된 비밀번호와 사용자로부터 입력받은 평문 비밀번호를 비교할 때 사용
    const isMatch = await bcrypt.compare(
      updateUserDto.password,
      myInfo.password,
    );
    if (!isMatch) {
      throw new UnauthorizedException('비밀번호가 일치하지 않습니다.');
    }

    // 업데이트할 데이터 생성
    const updatedData: Partial<User> = {
      name: updateUserDto.name || myInfo.name,
      password: updateUserDto.newPassword
        ? await this.hashPassword(updateUserDto.newPassword)
        : myInfo.password,
    };
    // updateUserDto 삭제
    if (updateUserDto.newPassword) {
      delete updateUserDto.newPassword;
    }
    // 데이터베이스 업데이트
    //  await this.userRepository.update({ id: userPayload.id }, updatedData);
    //save()는 데이터가 존재하면 업데이트하고, 존재하지 않으면 삽입(INSERT)한다.
    await this.userRepository.save({ id: userPayload.id, ...updatedData });

    const newUser = await this.userRepository.findOne({
      where: { id: userPayload.id },
      select: ['id', 'name', 'email'],
    });

    if (!newUser) {
      throw new NotFoundException(`${userPayload.id} 유저를 찾을 수 없습니다.`);
    }

    // JWT 토큰 생성
    const payload = { id: newUser.id, email: newUser.email };
    const accessToken = this.createAccessToken(payload);
    const refreshToken = this.createRefreshToken(payload);

    return { accessToken, refreshToken, newUser };
  }

  //DELETE	회원 탈퇴	/user/me
  async remove(deleteUserDto: DeleteUserDto, userPayload: any) {
    if (!deleteUserDto) {
      throw new UnauthorizedException('수정할 정보를 입력하세요');
    }

    // 조회
    const myInfo = await this.userRepository.findOne({
      where: { id: userPayload.id },
      select: ['password'],
    });
    if (!myInfo) {
      throw new NotFoundException(`내 계정정보를 확인할 수 없습니다.`);
    }

    // 비번 비교
    const isMatch = await bcrypt.compare(
      deleteUserDto.password,
      myInfo.password,
    );
    if (!isMatch) {
      throw new UnauthorizedException('비밀번호가 일치하지 않습니다.');
    }

    // 소프트 삭제 수행
    const result = await this.userRepository.softDelete({ id: userPayload.id });
    if (result.affected === 0) {
      throw new BadRequestException('회원을 찾지 못했습니다.');
    }

    const deletedUser = await this.userRepository.findOne({
      where: { id: userPayload.id },
      withDeleted: true,
    });
    if (!deletedUser?.deletedAt) {
      throw new BadRequestException('회원 탈퇴에 실패했습니다.');
    }

    return { message: '회원 탈퇴가 완료되었습니다.' };
  }
}

 

유저 컨트롤러

더보기
// 유저 컨트롤러
import {
  Controller,
  Headers,
  Get,
  Post,
  Body,
  Req,
  Patch,
  Param,
  Delete,
  Request,
  BadRequestException,
  UseGuards,
  UnauthorizedException,
} from '@nestjs/common';
import { UserService } from './user.service';
import { CreateUserDto } from './dto/create-user.dto';
import { LoginUserDto } from './dto/login-user.dto';
import { UpdateUserDto } from './dto/update-user.dto';
import { DeleteUserDto } from './dto/delete-user.dto';
import { ApiOperation, ApiTags } from '@nestjs/swagger';
import { AuthGuard } from '@nestjs/passport';

@Controller('user')
export class UserController {
  constructor(private readonly userService: UserService) {}

  /**POST	회원가입	/user/signup
POST	로그인	/user/login
POST	로그아웃	/user/logout
GET	회원 정보 조회	/user/:userId
GET	내 정보 상세조회	/user/me
PATCH	회원 정보 수정	/user/me
DELETE	회원 탈퇴	/user/me */

  @Post('/signup')
  async signUp(@Body() createUserDto: CreateUserDto) {
    console.log('회원가입 컨트롤러');
    return this.userService.create(createUserDto);
  }

  /** 로그인 시 토큰 발급 */
  @Post('/login')
  async login(@Body() loginUserDto: LoginUserDto) {
    return this.userService.login(loginUserDto);
  }

  /** 토큰 재발행 */
  @Get('/refresh')
  async refreshAccessToken(@Headers('RefreshToken') refreshToken: string) {
    if (!refreshToken || !refreshToken.startsWith('Bearer ')) {
      throw new UnauthorizedException('리프레시 토큰이 없습니다.');
    }

    const myRefreshToken = refreshToken.split(' ')[1];
    return this.userService.refreshToken(myRefreshToken);
  }

  /** 로그아웃 (토큰 무효화) */
  @Post('/logout')
  // @UseGuards(AuthGuard('jwt')) // ✅ 토큰 필수
  async logout(@Request() req) {
    console.log('로그아웃 컨트롤 - ', req.user);
    return this.userService.logout(req.user);
  }

  @Get('/users')
  async users() {
    return this.userService.findAll();
  }

  @Get('userId/:userId')
  async getUserInfo(@Param('userId') userId: string, @Request() req) {
    //const userPayload = req.user;
    return this.userService.findOne(+userId);
  }

  @Get('me')
  async getMyInfo(@Request() req) {
    const userPayload = req.user;
    return this.userService.findMe(userPayload);
  }

  @Patch('me')
  async updateUser(@Body() updateUserDto: UpdateUserDto, @Request() req) {
    const userPayload = req.user;
    return this.userService.update(updateUserDto, userPayload);
  }

  @Delete('me')
  async deleteUser(@Body() deleteUserDto: DeleteUserDto, @Request() req) {
    const userPayload = req.user;
    return this.userService.remove(deleteUserDto, userPayload);
  }
}

DTO

더보기

./dto/create-user.dto

import { PickType } from '@nestjs/mapped-types'; // 타입 가져오기
import { User } from '../entities/user.entity';
import { ApiProperty } from '@nestjs/swagger';
import {
  IsDate,
  IsNotEmpty,
  IsNumber,
  IsOptional,
  IsString,
  Length,
} from 'class-validator'; // 데코레이터 가져오기

export class CreateUserDto extends PickType(User, [
  'email',
  'name',
  'password',
]) {
  @ApiProperty({ example: '청게 장조림' })
  @IsNotEmpty({ message: '이름을 입력해주세요' })
  @IsString()
  name: string;

  @ApiProperty({ example: 'email1234@gmail.com' })
  @IsNotEmpty({ message: 'email을 입력해주세요' })
  @IsString()
  email: string;

  @ApiProperty({ example: 'pw1234' })
  @IsNotEmpty({ message: '비밀번호를 입력해주세요' })
  @IsString()
  @Length(1, 8, { message: '비밀번호는 1자 이상 8자 이하로 입력해주세요' })
  password: string;

  @ApiProperty({ example: 'pw1234' })
  @IsNotEmpty({ message: '비밀번호를 확인해주세요' })
  @IsString()
  @Length(1, 30, { message: '비밀번호는 1자 이상 30자 이하로 입력해주세요' })
  verifyPassword: string;
}

./dto/login-user.dto

import { PickType } from '@nestjs/mapped-types'; // 타입 가져오기
import { User } from '../entities/user.entity';
import { ApiProperty } from '@nestjs/swagger';
import {
  IsDate,
  IsNotEmpty,
  IsNumber,
  IsOptional,
  IsString,
} from 'class-validator'; // 데코레이터 가져오기
export class LoginUserDto extends PickType(User, ['email', 'password']) {
  @ApiProperty({ example: 'email1234@gmail.com' })
  @IsNotEmpty({ message: 'email을 입력해주세요' })
  @IsString()
  email: string;

  @ApiProperty({ example: 'pw1234' })
  @IsNotEmpty({ message: '비밀번호를 입력해주세요' })
  @IsString()
  password: string;
}

./dto/update-user.dto'

import { PickType } from '@nestjs/mapped-types'; // 타입 가져오기
import { User } from '../entities/user.entity';
import { ApiProperty } from '@nestjs/swagger';
import {
  IsDate,
  IsNotEmpty,
  IsNumber,
  IsOptional,
  IsString,
} from 'class-validator'; // 데코레이터 가져오기
export class UpdateUserDto extends PickType(User, ['name', 'password']) {
  @ApiProperty({ example: '청게 장조림' })
  @IsString()
  name: string;

  @ApiProperty({ example: 'pw1234' })
  @IsString()
  newPassword: string;

  @ApiProperty({ example: 'pw1234' })
  @IsNotEmpty({ message: '비밀번호를 입력해주세요' })
  @IsString()
  password: string;
}

./dto/delete-user.dto';

import { PickType } from '@nestjs/mapped-types'; // 타입 가져오기
import { User } from '../entities/user.entity';
import { ApiProperty } from '@nestjs/swagger';
import {
  IsDate,
  IsNotEmpty,
  IsNumber,
  IsOptional,
  IsString,
} from 'class-validator'; // 데코레이터 가져오기
export class DeleteUserDto extends PickType(User, ['password']) {
  @ApiProperty({ example: 'pw1234' })
  @IsNotEmpty({ message: '비밀번호를 입력해주세요' })
  @IsString()
  password: string;
}

 

user.entity.ts

더보기
import { Board } from 'src/board/entities/board.entity';
import { Member } from 'src/member/entities/member.entity';
import {
  Column,
  CreateDateColumn,
  DeleteDateColumn,
  Entity,
  OneToMany,
  PrimaryGeneratedColumn,
  UpdateDateColumn,
} from 'typeorm';

@Entity({
  name: 'users',
})
export class User {
  @PrimaryGeneratedColumn()
  id: number;

  @Column({ type: 'varchar', unique: true, nullable: false })
  email: string;

  @Column({ type: 'varchar', nullable: false, length: 15 })
  name: string;

  @Column({ type: 'varchar', length: 255, nullable: false }) // 해싱하면 길어지니 한계 없애기
  password: string;
  // 예시 :  '$2b$10$1dcY95A77vNF5RgUl8MwYeee3ED2KnmnIUlCx51eHY7v5kW7n7igi'

  @CreateDateColumn()
  createAt: Date;

  @UpdateDateColumn()
  updatedAt: Date;

  @DeleteDateColumn()
  deletedAt: Date;

  @OneToMany(() => Member, (member) => member.user)
  member: Member[];

  @OneToMany(() => Board, (board) => board.user)
  board: Board[];
}

 

 


db 마이그레이션

db에 마이그레이션 테이블이 생김.

마이그레이션에서 이전기록을 보는 방법은 아직 모르겠음

 

TypeORM 및 관련 패키지 설치

npm install typeorm @nestjs/typeorm mysql2


type ORM 버전 확인

npx typeorm -v



package.json에 추가

"scripts": {
  "typeorm": "typeorm-ts-node-commonjs"
}


마이그레이션 실행

npx typeorm migration:generate -n UpdateUserNameLength

 

# .env
DB_USERNAME = 'root'
DB_PASSWORD = 'aaaa1234'
DB_HOST = 'localhost'
DB_PORT = 3306
DB_NAME = 'Trello'
DB_SYNC = false

# .env 파일의 값은 기본적으로 문자열입니다.
# Number로 사용하려면 환경변수를 가져가서 숫자타입으로 바꾸기
JWT_SECRET_KEY="gerolly10Legs"
ACCESS_TOKEN_EXPIRY=15m # 15분
REFRESH_TOKEN_EXPIRY=7d # 7일
BCRYPT_SALT_ROUNDS=10 # 암호화 2¹⁰(= 1024)번 연산

 

// app.module.ts

import Joi from 'joi';
import { SnakeNamingStrategy } from 'typeorm-naming-strategies';

import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { TypeOrmModule, TypeOrmModuleOptions } from '@nestjs/typeorm';

import { BoardModule } from './board/board.module';
import { CommentModule } from './comment/comment.module';
import { ColumnModule } from './column/column.module'; // 컬럼 모듈 가져오기

import { CardModule } from './card/card.module';
import { AlarmModule } from './alarm/alarm.module';
import { MemberModule } from './member/member.module';
import { UserModule } from './user/user.module';
import { FileModule } from './file/file.module';

const typeOrmModuleOptions = {
  useFactory: async (
    configService: ConfigService,
  ): Promise<TypeOrmModuleOptions> => ({
    namingStrategy: new SnakeNamingStrategy(),
    type: 'mysql',
    username: configService.get('DB_USERNAME'),
    password: configService.get('DB_PASSWORD'),
    host: configService.get('DB_HOST'),
    port: configService.get('DB_PORT'),
    database: configService.get('DB_NAME'),
    entities: [__dirname + '/**/entities/*.{ts,js}'], // 이곳에서 자신의 작업물의 엔티티 등록  -  경로 잘못??
    // entities: [
    //   process.env.NODE_ENV === 'production'
    //     ? 'dist/**/*.entity.js' // 배포 환경에서는 dist 폴더 사용
    //     : 'src/**/*.entity.ts',  // 개발 환경에서는 src 사용
    // ],
    // synchronize은 테이블 만들 때만 쓰고  migration 사용하기(데이터 초기화 방지)
    synchronize: configService.get('DB_SYNC'), // true - 기존 테이블이 있다면 자동으로 수정됨
    migrationsRun: true, // 앱 실행 시 마이그레이션 적용
    logging: true,
  }),
  inject: [ConfigService],
};

@Module({
  imports: [
    ConfigModule.forRoot({
      isGlobal: true,
      validationSchema: Joi.object({
        JWT_SECRET_KEY: Joi.string().required(),
        DB_USERNAME: Joi.string().required(),
        DB_PASSWORD: Joi.string().required(),
        DB_HOST: Joi.string().required(),
        DB_PORT: Joi.number().required(),
        DB_NAME: Joi.string().required(),
        DB_SYNC: Joi.boolean().required(),
      }),
    }),
    TypeOrmModule.forRootAsync(typeOrmModuleOptions),
    BoardModule,
    CommentModule,
    ColumnModule,
    UserModule,
    MemberModule,
    AlarmModule,
    CardModule,
    FileModule,
  ],
  controllers: [],
  providers: [],
})
export class AppModule {}

 

 


서버 빌드, 실행


서버 빌드

npm run build


서버 실행

npm run start:dev



package.json의 scripts

  "scripts": {
    "build": "nest build",
    "start:dev": "nest start --watch"
  },

package.json

더보기
{
  "name": "trello-project",
  "version": "0.0.1",
  "description": "",
  "author": "",
  "private": true,
  "license": "UNLICENSED",
  "scripts": {
    "build": "nest build",
    "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
    "start": "nest start",
    "start:dev": "nest start --watch",
    "start:debug": "nest start --debug --watch",
    "start:prod": "node dist/main",
    "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
    "test": "jest",
    "test:watch": "jest --watch",
    "test:cov": "jest --coverage",
    "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
    "test:e2e": "jest --config ./test/jest-e2e.json",
    "typeorm": "typeorm-ts-node-commonjs"
  },
  "dependencies": {
    "@nestjs/common": "^11.0.1",
    "@nestjs/config": "^4.0.0",
    "@nestjs/core": "^11.0.1",
    "@nestjs/jwt": "^11.0.0",
    "@nestjs/mapped-types": "^2.1.0",
    "@nestjs/passport": "^11.0.4",
    "@nestjs/platform-express": "^11.0.1",
    "@nestjs/swagger": "^11.0.3",
    "@nestjs/typeorm": "^11.0.0",
    "@types/lodash": "^4.17.14",
    "@types/papaparse": "^5.3.15",
    "@types/passport-jwt": "^4.0.1",
    "bcrypt": "^5.1.1",
    "class-transformer": "^0.5.1",
    "class-validator": "^0.14.1",
    "ioredis": "^5.4.2",
    "joi": "^17.13.3",
    "lodash": "^4.17.21",
    "migration": "^0.3.0",
    "multer": "^1.4.5-lts.1",
    "mysql2": "^3.12.0",
    "papaparse": "^5.5.1",
    "passport": "^0.7.0",
    "passport-jwt": "^4.0.1",
    "reflect-metadata": "^0.2.2",
    "rxjs": "^7.8.1",
    "typeorm": "^0.3.20",
    "typeorm-naming-strategies": "^4.1.0",
    "uuid": "^11.0.5"
  },
  "devDependencies": {
    "@eslint/eslintrc": "^3.2.0",
    "@eslint/js": "^9.18.0",
    "@nestjs/cli": "^11.0.0",
    "@nestjs/schematics": "^11.0.0",
    "@nestjs/testing": "^11.0.1",
    "@swc/cli": "^0.6.0",
    "@swc/core": "^1.10.7",
    "@types/bcrypt": "^5.0.2",
    "@types/express": "^5.0.0",
    "@types/jest": "^29.5.14",
    "@types/multer": "^1.4.12",
    "@types/node": "^22.13.0",
    "@types/supertest": "^6.0.2",
    "eslint": "^9.18.0",
    "eslint-config-prettier": "^10.0.1",
    "eslint-plugin-prettier": "^5.2.2",
    "globals": "^15.14.0",
    "jest": "^29.7.0",
    "prettier": "^3.4.2",
    "source-map-support": "^0.5.21",
    "supertest": "^7.0.0",
    "swagger-jsdoc": "^6.2.8",
    "swagger-ui-express": "^5.0.1",
    "ts-jest": "^29.2.5",
    "ts-loader": "^9.5.2",
    "ts-node": "^10.9.2",
    "tsconfig-paths": "^4.2.0",
    "typescript": "^5.7.3",
    "typescript-eslint": "^8.20.0"
  },
  "jest": {
    "moduleFileExtensions": [
      "js",
      "json",
      "ts"
    ],
    "rootDir": "src",
    "testRegex": ".*\\.spec\\.ts$",
    "transform": {
      "^.+\\.(t|j)s$": "ts-jest"
    },
    "collectCoverageFrom": [
      "**/*.(t|j)s"
    ],
    "coverageDirectory": "../coverage",
    "testEnvironment": "node"
  }
}

'내일배움 과제 > 트렐로' 카테고리의 다른 글

jest 테스트 추가하기  (0) 2025.02.05
user CRUD API 구현하기  (0) 2025.01.24
2차회의  (0) 2025.01.23
1차회의 - 250122  (0) 2025.01.22