[2024.07.26 TIL] 내일배움캠프 72일차 (최종 팀프로젝트, 역할 가드 구현, 이미지 업로드 API구현)

My_Code·2024년 7월 26일
0

TIL

목록 보기
87/113
post-thumbnail

본 내용은 내일배움캠프에서 활동한 내용을 기록한 글입니다.


💻 TIL(Today I Learned)

📌 Today I Done

✏️ 역할 가드 구현

  • 역할 가드(Guard)는 roles.decorator를 기반으로 인가를 할지 결정함

  • 즉, roles.decorator에서 알려주는 역할이 통과될 수 있는지 검사함

  • RolesGuard는 AuthGuard('jwt')를 상속하기 때문에 req.user를 통해서 로그인한 사용자의 정보에 접근할 수 있음

// roles.guard.ts

@Injectable()
export class RolesGuard extends AuthGuard('jwt') implements CanActivate {
  constructor(private readonly reflector: Reflector) {
    super();
  }

  // 로그인한 사용자가 해당 역할에 맞는지 확인
  async canActivate(context: ExecutionContext): Promise<boolean> {
    // jwt 검증이 통과 되었는지 확인
    const authenticated = await super.canActivate(context);
    if (!authenticated) return false;

    // @Roles(Role.Admin) -> 'roles'에 [Role.Admin] 배열이 담겨 있음
    // 즉, requiredRoles에 [Role.Admin] 배열이 들어감
    // reflector를 통해서 메타데이터를 탐색 후 'roles' 키의 값을 가져옴
    const requireRoles = this.reflector.getAllAndOverride<Role[]>('roles', [
      context.getHandler(),
      context.getClass(),
    ]);

    const { user } = context.switchToHttp().getRequest();

    // 사용자의 역할이 requireRoles 배열에 해닿하는지 확인
    if (!requireRoles.some((role) => user.role === role)) {
      throw new ForbiddenException('접근할 권한이 없습니다.');
    }

    return true;
  }
}
  • 그리고 권한이 주어진 역할을 설정하기 위한 커스텀 데코레이터를 사용

  • 매개변수 roles를 받아서 'roles'라는 이름의 메타데이터로 저장

  • 여기서 메타데이터는 빌드 타임에 선언해 둔 메타데이터를 활용하여 런타임에 동작을 제어할 수 있는 강력한 방법이라고 할 수 있음

  • 코드 자체는 매우 간단하지만 아래와 같은 코드에서 메타데이터를 설정하면 RolesGuard에서 아주 간단하게 사용자가 설정한 역할 데이터를 가져올 수 있음

// 역할(Role)을 위한 커스텀 데코레이터
export const Roles = (...roles: Role[]) => SetMetadata('roles', roles);

✏️ 이미지 업로드 API 구현

  • 이번 프로젝트에서는 이미지 업로드 API를 따로 구현함

  • 기존에는 Multipart를 이용해서 다른 코드들과 같이 값을 넣었으나 Insomnia나 Swagger에서 양식의 차이로 코드 작성이 어렵기 때문에 이러한 이미지 데이터를 다른 API로 따로 받기로 함

  • 우선 필요한 패키지를 설치해야 함

# Node.js 애플리케이션에서 S3 서비스를 사용하기 위한 패키지
npm install @aws-sdk/client-s3
  • 컨트롤러에서 이미지 업로드를 위한 Swagger 데코레이터와 FileInterceptor를 설정
@ApiTags('이미지')
@Controller('images')
export class ImagesController {
  constructor(private readonly imagesService: ImagesService) {}

  // 이미지 업로드 API
  @ApiBearerAuth()
  @UseGuards(RolesGuard)
  @Roles(Role.ADMIN)
  @UseInterceptors(FilesInterceptor('image'))
  @ApiImages('image')
  @Post()
  async uploadImage(@UploadedFiles() files: Express.Multer.File[]) {
    return await this.imagesService.uploadImage(files, 5);
  }
}
  • 서비스에서는 S3와 연결하기 위한 기본 세팅과 업로드에 필요한 데이터를 재구성해서 S3에 업로드하는 코드로 구성되어 있음

  • 그리고 혹시 이미지를 업로드하고 게시물을 등록하는 과정에서 트랜젝션 에러가 발생하는 경우 S3의 이미지 파일도 삭제하기 위해서 따로 S3 롤백 함수를 구현함

@Injectable()
export class ImagesService {
  s3: S3;

  constructor(private readonly configService: ConfigService) {
    this.s3 = new S3({
      region: this.configService.get('AWS_S3_REGION'),
      credentials: {
        accessKeyId: this.configService.get('AWS_S3_ACCESS_KEY'),
        secretAccessKey: this.configService.get('AWS_S3_SECRET_KEY'),
      },
    });
  }

  // 이미지를 S3에 업로드
  async imageUploadToS3(
    fileName: string, // 업로드될 파일의 이름
    file: Express.Multer.File, // 업로드할 파일
    ext: string // 파일 확장자
  ) {
    try {
      // AWS S3에 이미지 업로드 명령을 생성
      // 파일 이름, 파일 버퍼, 파일 접근 권한, 파일 타입 등을 설정
      const command = new PutObjectCommand({
        Bucket: this.configService.get('AWS_BUCKET'), // S3 버킷 이름
        Key: fileName, // 업로드될 파일의 이름
        Body: file.buffer, // 업로드할 파일
        ACL: 'public-read', // 파일 접근 권한
        ContentType: `image/${ext}`, // 파일 타입
      });

      // 생성된 명령을 S3에 전달하여 이미지 업로드
      await this.s3.send(command);
      // 업로드된 이미지의 URL을 반환
      return `https://s3.${process.env.AWS_S3_REGION}.amazonaws.com/${process.env.AWS_BUCKET}/${fileName}`;
    } catch (err) {
      console.error(err);
      throw new InternalServerErrorException(
        '파일 업로드가 실패했습니다. 관리자에게 문의해 주세요.'
      );
    }
  }

  // 사용자가 입력한 이미지 데이터를 받아서 S3에 전달 (이미지 업로드)
  async uploadImage(files: Express.Multer.File[], maxFilesLength: number) {
    if (files.length === 0) {
      throw new BadRequestException('이미지를 입력해 주세요.');
    }

    if (files.length > maxFilesLength) {
      throw new BadRequestException(`${maxFilesLength}장 이하로 업로드 가능합니다.`);
    }

    const allowedExtensions = ['.png', '.jpg', '.jpeg', '.bmp', '.gif'];

    // 오늘 날짜 구하기
    const today = new Date();
    const currentYear = today.getFullYear();
    const currentMonth = today.getMonth() + 1;
    const currentDate = today.getDate();
    const date = `${currentYear}-${currentMonth}-${currentDate}`;

    const imageUrls: object[] = [];

    await Promise.all(
      files.map(async (file) => {
        // 임의번호 생성
        let randomNumber: string = '';
        for (let i = 0; i < 8; i++) {
          randomNumber += String(Math.floor(Math.random() * 10));
        }

        // 확장자 검사
        const extension = path.extname(file.originalname).toLowerCase();
        if (!allowedExtensions.includes(extension)) {
          throw new BadRequestException(
            '허용된 확장자가 아닙니다. (.png, .jpg, .jpeg, .bmp, .gif)'
          );
        }

        const imageName = `ticketing/${date}_${randomNumber}`;
        const ext = file.originalname.split('.').pop();

        const imageUrl = await this.imageUploadToS3(`${imageName}.${ext}`, file, ext);
        imageUrls.push({ imageUrl });
      })
    );

    return imageUrls;
  }

  // 트랜젝션 실패 시 S3에 등록된 이미지 롤백
  async rollbackS3Image(images: string[]) {
    for (const image of images) {
      const existingImageKey = await this.extractKeyFromUrl(image);
      if (existingImageKey) {
        await this.deleteImage(existingImageKey);
      }
    }
  }

  // URL에서 S3 Key 추출
  async extractKeyFromUrl(url: string) {
    const urlParts = url.split('/');
    // URL의 마지막 부분이 key값
    const key = urlParts.slice(3).join('/');
    return key;
  }

  // S3에 등록된 이미지 삭제
  async deleteImage(key: string) {
    try {
      const params = {
        Bucket: this.configService.get('AWS_BUCKET'),
        Key: key,
      };

      // S3에 접근해서 해당 이미지 객체 삭제
      await this.s3.deleteObject(params);
    } catch (err) {
      console.log(err);
      throw new InternalServerErrorException();
    }
  }
}


📌 Tomorrow's Goal

✏️ 이메일 인증 API 구현

  • Nodemail를 이용한 이메일 인증 API 구현

  • 인증 코드를 Body로 입력 받아서 유효한지 검사

  • 인증 코드는 한 번 사용하면 필요 없기 때문에 데이터베이스가 아니라 Redis를 활용할 예정



📌 Today's Goal I Done

✔️ 이미지 업로드 API 구현


profile
조금씩 정리하자!!!

0개의 댓글