[2024.07.02 TIL] 내일배움캠프 54일차 (공연 예매 개인과제, 공연 등록 API 구현, Multer + AWS S3 구현)

My_Code·2024년 7월 3일
0

TIL

목록 보기
71/113
post-thumbnail

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


💻 TIL(Today I Learned)

📌 Today I Done

✏️ 개인과제 공연 등록 기능 구현

  • ADMIM으로 로그인한 사용자가 공연을 등록하는 API

  • createShowDto를 통해서 사용자의 입력을 Controller로 가져옴

  • 매개변수로 풀어서 Service로 넘기기에는 입력하는 데이터가 많아서 createShowDto 객체 형태로 한 번에 넘김

  • Service에서 데이터를 추가할 테이블의 종류가 많기 때문에 트랜젝션으로 묶어서 테이블에 데이터 추가

  • 이미지를 여러개 받기 때문에 Multer와 AWS S3를 활용

  • 일단 이미지는 Multer를 통해서 이미지URL를 받아오는 것까지 진행함


✏️ show 모듈에서 TypeORM를 설정

  • show 모듈에서 TypeORM를 사용할 엔티티들을 정의
// show.module.ts

@Module({
  imports: [
    // JwtModule이라는 동적 모듈을 설정하고 다른 auth 모듈에서 사용하기 위한 코드
    JwtModule.registerAsync({
      useFactory: (config: ConfigService) => ({
        secret: config.get<string>('JWT_SECRET_KEY'),
      }),
      inject: [ConfigService],
    }),
    TypeOrmModule.forFeature([Show, ShowImage, ShowTime, ShowPlace, ShowPrice]),
    AwsModule,
  ],
  providers: [ShowService],
  controllers: [ShowController],
  exports: [ShowService],
})
export class ShowModule {}

✏️ 사용자 입력값 설정

  • 사용자의 입력은 createShowDto를 통해서 받음

  • 추후에 좌석 지정에 대한 코드도 구현할 계획이기에 등급별 좌석에 대한 데이터도 사용자에게 받아옴

// create-show.dto.ts

export class CreateShowDto {
  // 공연 제목
  @IsString()
  @IsNotEmpty({ message: '공연 제목을 입력해 주세요.' })
  title: string;

  // 공연 내용
  @IsString()
  @IsNotEmpty({ message: '공연 내용을 입력해 주세요.' })
  content: string;

  // 공연 카테고리
  @IsEnum(Category)
  @IsNotEmpty({ message: '공연 카테고리를 입력해 주세요.' })
  category: Category;

  // 공연 상영 시간
  @Type(() => Number)
  @IsNumber()
  @IsNotEmpty({ message: '공연 상영 시간을 입력해 주세요.' })
  runningTime: number;

  // 공연 시간 배열
  // 가져오는 시간 배열이 문자열 형태의 배열이기 때문에
  // 데이터를 가공할 필요가 있음
  @Transform(({ value }) => {
    const dateTimeArray = value.slice(1, -1).split(',');
    const dates = dateTimeArray.map((str: string) => new Date(str.trim().slice(1, -1)));
    if (Array.isArray(dates)) {
      return dates.map((item) => new Date(item));
    }
    return dates;
  })
  @IsArray()
  @IsDate({ each: true })
  @IsNotEmpty({ message: '공연 시간을 입력해 주세요.' })
  times: Date[];

  // 장소명
  @IsString()
  @IsNotEmpty({ message: '장소명을 입력해 주세요.' })
  placeName: string;

  // A좌석 수
  @Type(() => Number)
  @IsNumber()
  @IsNotEmpty({ message: '장소의 A좌석 수를 입력해 주세요.' })
  seatA: number;

  // S좌석 수
  @IsOptional()
  @Type(() => Number)
  @IsNumber()
  seatS: number;

  // R좌석 수
  @IsOptional()
  @Type(() => Number)
  @IsNumber()
  seatR: number;

  // Vip좌석 수
  @IsOptional()
  @Type(() => Number)
  @IsNumber()
  seatVip: number;

  // A좌석 가격
  @Type(() => Number)
  @IsNumber()
  @IsNotEmpty({ message: '장소의 A좌석 가격을 입력해 주세요.' })
  priceA: number;

  // S좌석 가격
  @IsOptional()
  @Type(() => Number)
  @IsNumber()
  priceS: number;

  // R좌석 가격
  @IsOptional()
  @Type(() => Number)
  @IsNumber()
  priceR: number;

  // Vip좌석 가격
  @IsOptional()
  @Type(() => Number)
  @IsNumber()
  priceVip: number;
}

✏️ 공연 정보 엔티티 설정

  • 공연의 기본적인 정보와 다른 테이블과의 관계를 설정
// show.entity.ts

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

  @Column({ type: 'varchar', nullable: false })
  title: string;

  @Column({ type: 'text', nullable: false })
  content: string;

  @Column({ type: 'enum', enum: Category, nullable: false })
  category: Category;

  @Column({ type: 'int', nullable: false })
  runningTime: number;

  @CreateDateColumn()
  createdAt: Date;

  @UpdateDateColumn()
  updatedAt: Date;

  @OneToMany(() => ShowImage, (showImage) => showImage.show)
  showImages: ShowImage[];

  @OneToMany(() => ShowTime, (showTime) => showTime.show)
  showTimes: ShowTime[];

  @OneToOne(() => ShowPrice, (showPrice) => showPrice.show)
  showPrice: ShowPrice;

  @OneToOne(() => ShowPlace, (showPlace) => showPlace.show)
  showPlace: ShowPlace;
}

✏️ 공연 이미지 엔티티 설정

  • 공연 등록 시 등록할 이미지들의 URL를 저장하는 엔티티

  • 공연 엔티티와 일대다 관계를 가짐

// showImage.entity.ts

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

  // 공연 외래키 설정
  @Column({ type: 'int', name: 'show_id' })
  showId: number;

  @Column({ type: 'varchar', nullable: false })
  imageUrl: string;

  @CreateDateColumn()
  createdAt: Date;

  // 공연 엔티티와 관계 설정
  @ManyToOne(() => Show, (show) => show.showImages, { onDelete: 'CASCADE' })
  @JoinColumn({ name: 'show_id' })
  show: Show;
}

✏️ 공연 시간 엔티티 설정

  • 공연을 하는 시간대들을 저장하는 엔티티

  • 공연 엔티티와 일대다 관계를 가짐

// showTime.entity.ts

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

  // 공연 외래키 설정
  @Column({ type: 'int', name: 'show_id' })
  showId: number;

  @Column({ type: 'datetime', nullable: false })
  showTime: Date;

  @CreateDateColumn()
  createdAt: Date;

  @UpdateDateColumn()
  updatedAt: Date;

  // 공연 엔티티와 관계 설정
  @ManyToOne(() => Show, (show) => show.showTimes, { onDelete: 'CASCADE' })
  @JoinColumn({ name: 'show_id' })
  show: Show;
}

✏️ 공연 장소 엔티티 설정

  • 공연을 하는 장소에 대한 정보를 저장하는 엔티티

  • 총 좌석이 몇개고 각 등급별 좌석이 몇개 있는지 저장

  • 공연 엔티티와 일대일 관계를 가짐

// showPlace.entity.ts

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

  // 공연 외래키 설정
  @Column({ type: 'int', name: 'show_id' })
  showId: number;

  @Column({ type: 'varchar', nullable: false })
  placeName: string;

  @Column({ type: 'int', nullable: false })
  totalSeat: number;

  @Column({ type: 'int', nullable: false })
  seatA: number;

  @Column({ type: 'int', nullable: true, default: 0 })
  seatS: number;

  @Column({ type: 'int', nullable: true, default: 0 })
  seatR: number;

  @Column({ type: 'int', nullable: true, default: 0 })
  seatVip: number;

  @CreateDateColumn()
  createdAt: Date;

  @UpdateDateColumn()
  updatedAt: Date;

  // 공연 엔티티와 관계 설정
  @OneToOne(() => Show, (show) => show.showPlace, { onDelete: 'CASCADE' })
  @JoinColumn({ name: 'show_id' })
  show: Show;
}

✏️ 공연 가격 엔티티 설정

  • 공연 예매를 위한 가격 정보를 저장하는 엔티티

  • 각 등급별 좌석의 가격 정보를 저장

  • 공연 엔티티와 일대일 관계를 가짐

// showPrice.entity.ts

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

  // 공연 외래키 설정
  @Column({ type: 'int', name: 'show_id' })
  showId: number;

  @Column({ type: 'int', nullable: false })
  priceA: number;

  @Column({ type: 'int', nullable: true, default: 0 })
  priceS: number;

  @Column({ type: 'int', nullable: true, default: 0 })
  priceR: number;

  @Column({ type: 'int', nullable: true, default: 0 })
  priceVip: number;

  @CreateDateColumn()
  createdAt: Date;

  @UpdateDateColumn()
  updatedAt: Date;

  // 공연 엔티티와 관계 설정
  @OneToOne(() => Show, (show) => show.showPrice, { onDelete: 'CASCADE' })
  @JoinColumn({ name: 'show_id' })
  show: Show;
}

✏️ 역할 인가를 위한 커스텀 데코레이터

  • 해당 메서드가 어떤 역할이 가능한지 설정하기 위한 커스텀 데코레이터

  • @Roles(Role.ADMIN) 처럼 사용함으로써 사용 가능한 역할을 지정

// auth/utils/roles.decorator.ts

import { SetMetadata } from '@nestjs/common';
import { Role } from 'src/user/types/userRole.type';

export const Roles = (...roles: Role[]) => SetMetadata('roles', roles);

✏️ JWT 토큰과 역할 인가 체크를 위한 가드(Guard)

  • 클래스 전역으로 설정 가능하기에 클래스의 메서드에도 적용 가능

  • 해당 메서드가 실행되기 위해서 JWT 토큰이 유효한지 검사

  • 그 후 메서드의 역할 데코레이터에 설정된 역할 메타데이터를 기반으로 사용 가능 여부를 파악

// auth/utils/roles.guard.ts

// 가드(Guard)는 roles.decorator를 기반으로 인가를 할지 결정함
// 즉, roles.decorator에서 알려주는 역할이 통과될 수 있는지 검사함

import { Role } from 'src/user/types/userRole.type';

import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';

@Injectable()
// AuthGuard('jwt') jwt 인증이 된 상태에서 역할을 확인하기 위해서 extends를 함
export class RolesGuard extends AuthGuard('jwt') implements CanActivate {
  // eslint-disable-next-line prettier/prettier
  constructor(private reflector: Reflector) {
    super();
  }

  // 가능할 경우에 동작하는 것
  async canActivate(context: ExecutionContext) {
    const authenticated = await super.canActivate(context);
    if (!authenticated) {
      return false;
    }

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

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

    // 사용자의 role이 메타데이터 'roles' 배열인 requiredRoles에 포함되는지 확인
    // 포함되어 있으면 true 반환
    return requiredRoles.some((role) => user.role === role);
  }
}

✏️ 공연 Controller의 공연 등록 메서드

  • 공연 등록 API를 사용하기 위해 공연 Controller에 해당 메서드를 등록

  • 해당 기능은 ADMIN만 사용 가능하기에 JWT 토큰과 역할 데코레이터에 의해 설정된 역할에 해당하는지 검증

  • 파일이 입력되면 @UseInterceptors(FilesInterceptor('files', 10)) 데코레이터를 통해서 files 라는 키의 파일 데이터를 가져옴

  • @UploadedFiles() 데코레이터는 인터셉터를 통해 가져온 파일 데이터를 files 변수에 할당함

// show.controller.ts

@UseGuards(RolesGuard)
@Controller('show')
export class ShowController {
  // eslint-disable-next-line prettier/prettier
  constructor(private readonly showService: ShowService) {}

  // 공연 등록 (ADMIN만 사용 가능)
  @Roles(Role.ADMIN)
  @UseInterceptors(FilesInterceptor('files', 10))
  @Post()
  async createShow(@Body() createShowDto: CreateShowDto, @UploadedFiles() files) {
    console.log(files);
    return await this.showService.createShow(createShowDto, files);
  }
}

✏️ 공연 Service의 공연 등록 메서드

  • 사용자 입력을 통해 받아온 createShowDto 객체 형태로 가져와서 객체 구조 분해 할당으로 해당 객체를 풀어서 사용

  • 공연의 기본 정보, 장소, 가격, 시간을 하나의 트랜젝션에서 처리해야 하기 때문에 queryRunner를 통해서 해당 기능들을 트랜젝션 처리함

  • 이 때 queryRunner를 사용할 때 save + create 조합을 정확히 사용해야 트랜젝션이 동작함

  • 아직 데이터베이스에 이미지 URL를 저장하는 코드는 구현하지 못했지만 files를 통해서 이미지가 AWS S3에 등록되는 것을 확인함

// show.service.ts

...

// 공연 등록
  async createShow(createShowDto: CreateShowDto, files: Express.Multer.File[]) {
    const {
      title,
      content,
      category,
      runningTime,
      times,
      placeName,
      seatA,
      seatS,
      seatR,
      seatVip,
      priceA,
      priceS,
      priceR,
      priceVip,
    } = createShowDto;

    const uploadImage = await this.imageUpload(files);

    console.log(uploadImage);

    const queryRunner = this.dataSource.createQueryRunner();

    await queryRunner.connect();
    await queryRunner.startTransaction();

    try {
      // show 테이블에 데이터 저장
      const show = await queryRunner.manager.save(
        this.showRepository.create({
          title,
          content,
          category,
          runningTime,
        }),
      );
      // show_place 테이블에 데이터 저장
      const showPlace = await queryRunner.manager.save(
        this.showPlaceRepository.create({
          showId: show.id,
          placeName,
          totalSeat: seatA + (seatS ?? 0) + (seatR ?? 0) + (seatVip ?? 0),
          seatA,
          seatS,
          seatR,
          seatVip,
        }),
      );
      // show_price 테이블에 데이터 저장
      const showPrice = await queryRunner.manager.save(
        this.showPriceRepository.create({
          showId: show.id,
          priceA,
          priceS,
          priceR,
          priceVip,
        }),
      );
      // show_time 테이블에 데이터 저장
      const showTimes = await queryRunner.manager.save(
        await Promise.all(
          times.map(async (time) => {
            return this.showTimeRepository.create({
              showId: show.id,
              showTime: time,
              show,
            });
          }),
        ),
      );
      // 출력 형식 지정
      const createdShow = {
        id: show.id,
        title: show.title,
        content: show.content,
        runningTime: show.runningTime,
        placeName: showPlace.placeName,
        totalSeat: showPlace.totalSeat,
        priceA: showPrice.priceA,
        priceS: showPrice.priceS,
        priceR: showPrice.priceR,
        priceVip: showPrice.priceVip,
        showTimes: showTimes.map((time) => time.showTime),
        createdAt: show.createdAt,
        updatedAt: show.updatedAt,
      };
      await queryRunner.commitTransaction();
      return createdShow;
    } catch (err) {
      await queryRunner.rollbackTransaction();
      throw new InternalServerErrorException('공연 등록에 실패했습니다.');
    } finally {
      await queryRunner.release();
    }
  }

✏️ 이미지를 AWS S3에 업로드

  • aws 라는 새로운 모듈의 Service에서 작업을 진행함

  • 생성자에서 AWS S3에 필요한 설정을 작성

  • 사용자에게 받은 파일 데이터에서 S3에 업로드될 파일명, 파일 원본, 확장자를 매개변수로 받아옴

  • AWS S3의 버킷 정보를 기입하고 해당 데이터를 S3 클라이언트에게 전달 후 만들어지는 URL를 반환함

// aws/aws.service.ts

@Injectable()
export class AwsService {
  s3Client: S3Client;

  constructor(private configService: ConfigService) {
    // AWS S3 클라이언트 초기화. 환경 설정 정보를 사용하여 AWS 리전, Access Key, Secret Key를 설정.
    this.s3Client = new S3Client({
      region: this.configService.get('AWS_S3_REGION'), // AWS Region
      credentials: {
        accessKeyId: this.configService.get('AWS_S3_ACCESS_KEY'), // Access Key
        secretAccessKey: this.configService.get('AWS_S3_SECRET_KEY'), // 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.s3Client.send(command);
      // 업로드된 이미지의 URL을 반환합니다.
      return `https://s3.${process.env.AWS_S3_REGION}.amazonaws.com/${process.env.AWS_BUCKET}/${fileName}`;
    } catch (err) {
      console.log(err);
      throw new InternalServerErrorException('관리자에게 문의해 주세요.');
    }
  }
}

✏️ AWS S3에 업로드 전 파일 가공 처리

  • 위에서 본 AWS S3에 이미지를 업로드하는 작업을 하기 전에 S3로 보낼 이미지 파일의 이름을 가공하기 위한 작업을 진행함

  • 지금은 이미지 데이터를 순차적으로 하나씩 업로드하고 있지만 속도 개선을 위해서 await this.awsService.imageUploadToS3를 비동기적으로 동작시킬 필요가 있음

// show.service.ts

  // 받아온 파일 데이터 가공해서 aws 서비스에 전달
  async imageUpload(files: Express.Multer.File[]) {
    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: string[] = [];

    for (const file of files) {
      // 임의번호 생성
      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 Error('확장자 에러');
      }

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

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

    return { imageUrls };
  }


📌 Tomorrow's Goal

✏️ 개인과제 공연 조회, 검색 관련 코드 구현

  • 공연 등록에서 이미지 URL를 데이터베이스 등록하는 로직이 아직 덜 구현되어서 마저 구현할 예정

  • 그리고 공연 목록을 조회하는 API를 구현할 예정

  • 단순한 목록 조회는 간단하기에 금방 구현될 예정

  • 공연 검색은 공연명으로 검색을 하는데 단순히 완벽히 같은 공연명으로 검색할지 아니면 공연명 일부라도 일치하면 검색되도록 할지는 구현하면서 생각해볼 예정



📌 Today's Goal I Done

✔️ 개인과제 공연 등록 기능 구현

  • 공연 등록하는 로직이 생각보다 길어져서 하루 종일 구현함

  • 특히 트랜젝션과 Multer & S3를 구현하는데 거의 모든 시간을 사용함

  • 사실 지금 Service에 있는 공연 등록 메서드가 너무 길어져서 각 테이블별로 모듈을 따로 만들어서 구현을 해야 할지 고민임

  • 가독성을 위해서는 따로 분리해서 작업하는게 맞는 것 같음

  • 하지만 과제 시간을 맞추기 위해서는 일단 구현에 집중하는게 맞을 것 같음



📌 ⚠️ 구현 시 발생한 문제

✔️ Error: resolved credential object is not valid

  • Aws S3를 이용한 Multer를 구현하던 도중 아래와 같은 에러가 발생함

  • 처음에는 도대체 어디가 문제인지 알 수가 없어서 일단 의심되는 aws의 Service 쪽을 콘솔로 찍어보니
// 생성된 명령을 S3 클라이언트에 전달하여 이미지 업로드를 수행합니다.
await this.s3Client.send(command);
  • 위 코드 기준으로 다음의 콘솔들이 찍히지 않고 에러가 발생함

  • 인터넷에 검색해보니 .env의 AWS S3 키 값이 잘못 설정되어 있다고 적혀 있음

  • 즉, AWS S3와 연결하기 위한 S3Client 설정의 문제 같아 보임

  constructor(private configService: ConfigService) {
    // AWS S3 클라이언트 초기화. 환경 설정 정보를 사용하여 AWS 리전, Access Key, Secret Key를 설정.
    this.s3Client = new S3Client({
      region: this.configService.get('AWS_S3_REGION'), // AWS Region
      credentials: {
        accessKeyId: this.configService.get('AWS_S3_ACCESS_KEY'), // Access Key
        secretAccessKey: this.configService.get('AWS_S3_SECRET_ACCESS_KEY'), // Secret Key
      },
    });
  }
  • 에러에서 말하는 credential object가 위의 코드에서 정의한 credentials 객체에 대한 에러를 말하는 것 같음

  • 약 2시간 정도의 대치 후 잘못된 곳을 발견함

  • .env에서 가져오기 위한 키의 이름이 잘못 설정되어 있었음

  • 즉, 단순한 오타였음....ㅠㅠ

  • 아래와 같이 .env의 키 이름에 맞도록 작성하니 정상적으로 동작함

  constructor(private configService: ConfigService) {
    // AWS S3 클라이언트 초기화. 환경 설정 정보를 사용하여 AWS 리전, Access Key, Secret Key를 설정.
    this.s3Client = new S3Client({
      region: this.configService.get('AWS_S3_REGION'), // AWS Region
      credentials: {
        accessKeyId: this.configService.get('AWS_S3_ACCESS_KEY'), // Access Key
        secretAccessKey: this.configService.get('AWS_S3_SECRET_KEY'), // Secret Key
      },
    });
  }

profile
조금씩 정리하자!!!

0개의 댓글