[2024.07.04 TIL] 내일배움캠프 56일차 (공연 예매 개인과제, 좌석 정보 추가, 좌석 예매 기능 구현)

My_Code·2024년 7월 4일
0

TIL

목록 보기
72/112
post-thumbnail

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


💻 TIL(Today I Learned)

📌 Today I Done

✏️ 좌석 정보에 대한 고민

  • 이 방법에 대해서 굉장히 고민을 많이 했음

  • 처음에는 빈 좌석 테이블에 사용자가 예매할 때마다 좌석의 정보를 추가하는 방식을 생각함

  • 하지만 다른 측면에서 생각해보면 나중에 공연을 예매할 때 좌석 정보들을 클라이언트에게 반환해야 함

  • 예매된 좌석 정보는 좌석 테이블에서 바로 가져갈 수 있지만 예매되지 않은 좌석들의 정보는 어떻게 해야 하는가?

  • 그냥 TypeScript 코드로 좌석 정보 객체를 만들어서 반환할까도 생각했지만 복잡한 방법으로 좌석 정보를 반환하는 것 같아서 포기함

  • 그 다음으로 생각한 것은 공연 정보를 등록할 때, 사용자에게 각 등급별 좌석의 수를 받아서 무식하지만 그냥 데이터베이스에 좌석 정보 데이터를 미리 추가해놓는 방법을 생각함

  • 근데 이 방법도 다르게 생각하면, 만약에 사용자가 1만석의 좌석이 있는 공연을 등록하면 이 때, 1만개의 좌석 데이터를 만들어서 반복적으로 INSERT하는 것임

  • 심지어 같은 공연이라도 날짜나 시간이 다르면 그 수만큼 좌석 데이터를 넣어야 함

  • 만약에 1만석짜리 공연이 하루에 2번, 3일 동안 하면 1만 X 2 X 3해서 공연 하나로 6만개의 레코드가 생성됨

  • 공연 등록시 데이터베이스에 너무 많은 INSERT가 발생하는게 괜찮을까가 걱정이었음

  • 하지만 다른 뚜렷한 방법이 없기에 일단은 구현에 도전함


✏️ 데이터베이스에 좌석 정보 추가하기

  • showPlace는 좌석 등급별 잔여 좌석의 수를 저장하기 위한 테이블임

  • 공연 하나당 시간대가 여러개면 그 시간대 수만큼 잔여 좌석의 수 데이터가 필요함

  • 그렇기에 잔여 좌석의 수는 각 시간의 공연마다 관리되어야 하기에 아래와 같이 반복문을 통해서 각 좌석 정보를 데이터베이스에 추가함

  • (물론, 코드가 굉장히 지저분하고 길기 때문에 리팩토링이 필요해 보임)

// 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 queryRunner = this.dataSource.createQueryRunner();
    await queryRunner.connect();
    await queryRunner.startTransaction();

    // 이미지 업로드 후 반환되는 이미지 URL 배열
    let images: string[];

    try {
      // show 테이블에 데이터 저장
      const show = this.showRepository.create({
        title,
        content,
        category,
        runningTime,
      });
      // 다른 테이블에서 show.id를 사용하기 때문에 따로 저장
      await queryRunner.manager.save(show);

      // 이미지 데이터를 업로드
      images = await this.awsService.imageUpload(files);

      // show_price 테이블에 데이터 저장
      const showPrice = this.showPriceRepository.create({
        showId: show.id,
        priceA,
        priceS,
        priceR,
        priceVip,
      });

      // show_time 테이블에 데이터 저장
      const showTimes = times.map((time) =>
        this.showTimeRepository.create({
          showId: show.id,
          showTime: time,
        }),
      );

      // show_image 테이블에 데이터 저장
      const showImages = images.map((image) =>
        this.showImageRepository.create({
          showId: show.id,
          imageUrl: image,
        }),
      );

      // queryRunner를 병렬로 처리해서 데이터베이스에 저장
      await Promise.all([
        queryRunner.manager.save(showPrice),
        queryRunner.manager.save(showTimes),
        queryRunner.manager.save(showImages),
      ]);

      // show_place 테이블에 데이터 저장
      const totalSeat: number = seatA + (seatS ?? 0) + (seatR ?? 0) + (seatVip ?? 0);
      const showPlace = showTimes.map((showTime) =>
        this.showPlaceRepository.create({
          showId: show.id,
          showTimeId: showTime.id,
          placeName,
          totalSeat,
          seatA,
          seatS,
          seatR,
          seatVip,
        }),
      );
      await queryRunner.manager.save(showPlace);

      // 장소(시간)별, 등급별 좌석 정보 추가하기
      for (let k = 0; k < showPlace.length; k++) {
        let seatNumber = 1;
        // A 좌석 데이터 저장
        for (let i = 1; i <= showPlace[k].seatA; i++) {
          await queryRunner.manager.save(
            this.seatRepository.create({
              showId: show.id,
              showTimeId: showPlace[k].showTimeId,
              seatNumber,
              grade: Grade.A,
              price: showPrice.priceA,
            }),
          );
          seatNumber++;
        }

        // S 좌석 데이터 저장
        for (let i = 1; i <= showPlace[k].seatS; i++) {
          await queryRunner.manager.save(
            this.seatRepository.create({
              showId: show.id,
              showTimeId: showPlace[k].showTimeId,
              seatNumber,
              grade: Grade.S,
              price: showPrice.priceS,
            }),
          );
          seatNumber++;
        }

        // R 좌석 데이터 저장
        for (let i = 1; i <= showPlace[k].seatR; i++) {
          await queryRunner.manager.save(
            this.seatRepository.create({
              showId: show.id,
              showTimeId: showPlace[k].showTimeId,
              seatNumber,
              grade: Grade.R,
              price: showPrice.priceR,
            }),
          );
          seatNumber++;
        }

        // Vip 좌석 데이터 저장
        for (let i = 1; i <= showPlace[k].seatVip; i++) {
          await queryRunner.manager.save(
            this.seatRepository.create({
              showId: show.id,
              showTimeId: showPlace[k].showTimeId,
              seatNumber,
              grade: Grade.VIP,
              price: showPrice.priceVip,
            }),
          );
          seatNumber++;
        }
      }

      // 출력 형식 지정
      const createdShow = {
        id: show.id,
        title,
        content,
        runningTime,
        placeName,
        totalSeat,
        priceA,
        priceS,
        priceR,
        priceVip,
        showTimes: showTimes.map((time) => time.showTime),
        showImages: showImages.map((image) => image.imageUrl),
        createdAt: show.createdAt,
        updatedAt: show.updatedAt,
      };

      await queryRunner.commitTransaction();
      return createdShow;
    } catch (err) {
      await queryRunner.rollbackTransaction();
      // 트랜젝션 실패 시 S3 이미지도 롤백
      await this.awsService.rollbackS3Image(images);
      throw new InternalServerErrorException('공연 등록에 실패했습니다.');
    } finally {
      await queryRunner.release();
    }
  }

✏️ 좌석 엔티티 설정

  • 좌석 엔티티에는 기본적인 좌석 정보들이 들어 있음

  • 공연과 공연 시간 ID를 외래키로 사용하는 이유는 나중에 예매하려는 좌석을 정확하게 확인하기 위해서 사용함

// seat.entity.ts

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

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

  // 공연 시간 외래키 설정
  @Index()
  @Column({ type: 'int', name: 'show_time_id' })
  showTimeId: number;

  // 좌석 번호
  @Column({ type: 'int', nullable: false })
  seatNumber: number;

  // 좌석 등급
  @Column({ type: 'enum', enum: Grade, nullable: false })
  grade: Grade;

  // 좌석 가격
  @Column({ type: 'int', nullable: false })
  price: number;

  // 예약 여부
  @Column({ type: 'boolean', default: false })
  isReserved: boolean = false;

  @CreateDateColumn()
  createdAt: Date;

  @UpdateDateColumn()
  updatedAt: Date;

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

  // 공연 시간 엔티티와 관계 설정
  @ManyToOne(() => ShowTime, (showTime) => showTime.seats, { onDelete: 'CASCADE' })
  @JoinColumn({ name: 'show_time_id' })
  showTime: ShowTime;

  // 티켓 엔티티와 관계 설정
  @OneToOne(() => Ticket, (ticket) => ticket.seats)
  ticket: Ticket;
}

✏️ 사용자 입력값 설정

  • 좌석 예매할 때의 사용자 입력을 굉장히 간단함

  • 사용자가 실제로 예약 사이트에서 좌석을 선택하고 날짜를 선택한다고 생각했기 때문에 다음과 같이 작성함

// reserve-seat.dto.ts

export class ReserveSeatDto {
  // 좌석 번호
  @IsNumber()
  @IsNotEmpty({ message: '좌석 번호를 입력해 주세요.' })
  seatNumber: number;

  // 공연 날짜 ID
  @IsNumber()
  @IsNotEmpty({ message: '공연 시간 ID를 입력해 주세요.' })
  showTimeId: number;
}

✏️ 좌석 Controller의 좌석 예매 매서드

  • @Body를 통해서 DTO의 사용자 입력을 가져옴

  • 이 때, 가드를 통해서 JWT 토큰 인증이 된 사용자인지 파악함

  • 그리고 JWT 전략에 의해 생성된 사용자 데이터를 @UserInfo 커스텀 데코레이터를 통해서 가져옴

// seat.controller.ts

@Controller('seats')
export class SeatController {
  constructor(private readonly seatService: SeatService) {}

  @UseGuards(AuthGuard('jwt'))
  @Post('/:showId')
  async reserveSeat(
    @UserInfo() user: User,
    @Param('showId') showId: string,
    @Body() reserveSeatDto: ReserveSeatDto,
  ) {
    return await this.seatService.reserveSeat(user, +showId, reserveSeatDto);
  }
}

✏️ 좌석 Service의 좌석 예매 메서드

  • Controller에서 가져온 데이터를 기반으로 좌석 예매를 진행함

  • 우선 사용자에게 입력받은 좌석 번호와 공연 시간 ID를 통해 존재하는 좌석인지 체크하고 사용자의 잔여 포인트도 확인

  • 트랜젝션을 통해서 사용자 포인트 감소, 좌석의 예매 상태 변경, 해당 좌석의 잔여 수량 변경, 티켓 생성을 묶어서 진행

// seat.service.ts

  // 좌석 예매
  async reserveSeat(user: User, showId: number, reserveSeatDto: ReserveSeatDto) {
    const { seatNumber, showTimeId } = reserveSeatDto;

    // 존재하는 좌석인지 체크
    const seat = await this.seatRepository.findOne({
      where: { seatNumber, showTimeId },
    });
    if (_.isNil(seat)) {
      throw new NotFoundException('등록된 좌석이 없습니다.');
    }
    if (seat.isReserved) {
      throw new UnauthorizedException('이미 예매된 좌석입니다.');
    }
    // 사용자 잔여 포인트 확인
    if (user.point < seat.price) {
      throw new UnauthorizedException('포인트가 부족합니다.');
    }

    const queryRunner = this.dataSource.createQueryRunner();
    await queryRunner.connect();
    await queryRunner.startTransaction();

    try {
      // 사용자 포인트 감소
      const updatedUser = this.userRepository.merge(user, { point: user.point - seat.price });
      // 좌석이 예약됨으로 변경
      const updatedSeat = this.seatRepository.merge(seat, { isReserved: true });
      // 해당 등급 좌석의 잔여 수량 변경
      const place = await this.showPlaceRepository.findOne({
        where: { showTimeId },
      });

      // 어떤 좌석의 수를 변경할지에 대한 객체 생성
      const condition = {};
      if (seat.grade === Grade.A) {
        condition['seatA'] = place.seatA - 1;
      } else if (seat.grade === Grade.S) {
        condition['seatS'] = place.seatS - 1;
      } else if (seat.grade === Grade.R) {
        condition['seatR'] = place.seatR - 1;
      } else {
        condition['seatVip'] = place.seatVip - 1;
      }

      // 잔여 좌석 수 변경
      const updatedPlace = this.showPlaceRepository.merge(place, condition);

      // 티켓 생성
      const show = await this.showRepository.findOne({
        where: { id: showId },
      });
      const showTime = await this.showTimeRepository.findOne({
        where: { id: showTimeId },
      });
      const newTicket = this.ticketRepository.create({
        userId: user.id,
        seatId: seat.id,
        title: show.title,
        runningTime: show.runningTime,
        date: showTime.showTime,
        userName: user.nickname,
        seatNumber: seat.seatNumber,
        grade: seat.grade,
        price: seat.price,
        place: place.placeName,
      });

      await Promise.all([
        queryRunner.manager.save(updatedUser),
        queryRunner.manager.save(updatedSeat),
        queryRunner.manager.save(updatedPlace),
        queryRunner.manager.save(newTicket),
      ]);

      await queryRunner.commitTransaction();

      return newTicket;
    } catch (err) {
      await queryRunner.rollbackTransaction();
    } finally {
      await queryRunner.release();
    }
  }


📌 Tomorrow's Goal

✏️ 공연 좌석 예매 정보 확인 구현하기

  • 내일은 해당 공연의 현재 예매 가능한 좌석의 정보를 반환하는 기능을 만들 예정

  • 예매 가능한 좌석 뿐만 아니라 프론트에서 회색 표시로 보이기 위해 이미 예매된 좌석의 정보도 같이 반환할 예정

  • 좌석 정보 확인 구현이 끝나면 예매 취소 기능과 시간과 관련된 예외 처리를 할 예정

  • 시간과 관련된 예외 처리는 공연 시작 3시간 전까지만 예매를 취소할 수 있는 것과 오늘을 기점으로 이전 날짜의 좌석 정보는 반환하지 않도록 하는 것을 구현할 예정



📌 Today's Goal I Done

✔️ 좌석 정보 생성 및 지정 좌석 예매 기능 구현

  • 오늘은 좌석 정보 생성과 지정 좌석 예매에 대한 기능을 구현함

  • 위에서도 말했지만 좌석 정보 생성은 특히나 고민을 많이 했음

  • 사실 개발에는 정답이 없지만 조금 더 좋은 코드가 되도록 할 수는 있음

  • 그래서 처음에 ERD에서의 관계라든지, 컬럼 설정 등을 굉장히 고민을 많이 했음

  • 하지만 튜터님께서 일단 동작하게끔 만드는게 가장 중요하다고 하셨기에 일단 생각한 방법으로 도전해서 동작하게 만듦

  • 지정 좌석 예매 기능은 생각보다 좌석과 공연 사이에 관계설정이 굉장히 어려웠음

  • 공연 하나에는 여러 시간대가 있기 때문에 시간대 별 레코드 데이터가 필요하고 그 시간대별 데이터에는 좌석의 수를 관리하는 테이블이 필요함

  • 그리고 좌석 정보는 공연, 시간에 따라서 좌석의 정보가 필요하기에 그에 대한 관계를 설정하느라 엔티티를 많이 수정함

  • 그래도 구현해서 제대로 동작하는 것을 보니 방향은 맞았구나 라는 생각이 듦

  • 이후에는 코드가 조금 더 가독성 좋게 만드는 방법을 생각해 봐야 겠음


profile
조금씩 정리하자!!!

0개의 댓글