혼자 고민해보기_ 개발/TIL (Today I Learned)

20231013(금)_ 티켓팅 프로젝트 (queryRunner 트랜잭션 구현)

nuri-story 2023. 10. 13. 20:11

금일 달성 항목

1)  공연 좌석 지정해서 예매
2) 예매 취소


문제 해결 과정 1 - queryRunner 트랜잭션 구현

[문제]

TypeORM의 트랜잭션은 express와 다른 부분이 있어서 개인적으로 공부가 필요했습니다.

공연 좌석을 지정해서 예매하고, 지정된 좌석 값을 user의 point에서 차감하는 과정도 동시에 필요했습니다.

 

[시도 및 해결]

reservation.entitiy.ts

import { Point } from 'src/apis/points/entities/point.entity';
import { SeatReservation } from 'src/apis/reservations/entities/seat-reservation.entity';
import { Show } from 'src/apis/shows/entities/show.entity';
import { User } from 'src/apis/users/entities/user.entity';
import {
  Column,
  CreateDateColumn,
  DeleteDateColumn,
  Entity,
  ManyToOne,
  OneToMany,
  PrimaryGeneratedColumn,
  UpdateDateColumn,
} from 'typeorm';

@Entity()
export class Reservation {
  @PrimaryGeneratedColumn('uuid')
  id: string;

  @Column({ default: false })
  isCanceled: Boolean;

  @CreateDateColumn()
  createdAt: Date;

  @UpdateDateColumn()
  updatedAt: Date;

  @DeleteDateColumn()
  deletedAt: Date;

  @ManyToOne(() => User, (user) => user.reservations, { onDelete: 'CASCADE' })
  user: User;

  @OneToMany(() => Point, (point) => point.reservation, { cascade: true })
  points: Point[];

  @ManyToOne(() => Show, (show) => show.reservations, { onDelete: 'CASCADE' })
  show: Show;

  @OneToMany(
    () => SeatReservation,
    (seatReservation) => seatReservation.reservation,
    { cascade: true },
  )
  seatReservations: SeatReservation[];
}

 

seat-reservation.entitiy.ts

import { Reservation } from 'src/apis/reservations/entities/reservation.entity';
import { Seat } from 'src/apis/seats/entities/seat.entity';
import { User } from 'src/apis/users/entities/user.entity';
import {
  CreateDateColumn,
  DeleteDateColumn,
  Entity,
  ManyToOne,
  PrimaryGeneratedColumn,
  UpdateDateColumn,
} from 'typeorm';

@Entity()
export class SeatReservation {
  @PrimaryGeneratedColumn('uuid')
  id: string;

  @CreateDateColumn()
  createdAt: Date;

  @UpdateDateColumn()
  updatedAt: Date;

  @DeleteDateColumn()
  deletedAt: Date;

  @ManyToOne(() => Reservation, (reservation) => reservation.seatReservations, {
    onDelete: 'CASCADE',
  })
  reservation: Reservation;

  @ManyToOne(() => Seat, (seat) => seat.seatReservations, {
    onDelete: 'CASCADE',
  })
  seat: Seat;

  @ManyToOne(() => User, (user) => user.seatReservations, {
    onDelete: 'CASCADE',
  })
  user: User;
}

seat.entitiy.ts

import { SeatReservation } from 'src/apis/reservations/entities/seat-reservation.entity';
import { Show } from 'src/apis/shows/entities/show.entity';
import {
  Column,
  CreateDateColumn,
  DeleteDateColumn,
  Entity,
  ManyToOne,
  OneToMany,
  PrimaryGeneratedColumn,
  UpdateDateColumn,
} from 'typeorm';

@Entity()
export class Seat {
  @PrimaryGeneratedColumn('uuid')
  id: string;

  @Column()
  seatNumber: number;

  @Column()
  grade: string;

  @Column()
  price: number;

  @CreateDateColumn()
  createdAt: Date;

  @UpdateDateColumn()
  updatedAt: Date;

  @DeleteDateColumn()
  deletedAt: Date;

  @ManyToOne(() => Show, (show) => show.seats, { onDelete: 'CASCADE' })
  show: Show;

  @OneToMany(() => SeatReservation, (seatReservation) => seatReservation.seat, {
    cascade: true,
  })
  seatReservations: SeatReservation;
}

 

 

shows.controller.ts

@Controller('api/shows')
export class ShowsController {
  constructor(private readonly showsService: ShowsService) {}

  // 공연 좌석 지정해서 예매
  @UseGuards(AccessAuthGuard)
  @Post(':showId/reservation')
  async seatReservation(
    @User() user: UserAfterAuth,
    @Param('showId') showId: string,
    @Body() createSeatDto: CreateSeatDto,
  ) {
    const seat = await this.showsService.seatReservation({
      userId: user.id,
      showId,
      createSeatDto,
    });
    return seat;
  }

  // 예매 취소
  @UseGuards(AccessAuthGuard)
  @Delete(':reservationId/cancel-reservation')
  async cancelReservation(
    @User() user: UserAfterAuth,
    @Param('seatReservationId') seatReservationId: string,
  ) {
    const reservation = await this.showsService.cancelReservation({
      userId: user.id,
      seatReservationId,
    });
    return reservation;
  }

 

manager를 통해 별도의 레파지토리를 불러오지 않고 바로 불러와서 해결할 수 있었습니다.

const manager = queryRunner.manager;

 

 

shows.service.ts

@Injectable()
export class ShowsService {
  constructor(
    @InjectRepository(Show)
    private readonly showsRepository: Repository<Show>,
    private readonly usersService: UsersService,
    private readonly dataSource: DataSource, // dataSource를 주입
  ) {}

  // 공연 좌석 지정해서 예매
  async seatReservation({
    userId,
    showId,
    createSeatDto,
  }: IShowsServiceSeatReservationSeat) {
    const queryRunner = this.dataSource.createQueryRunner(); // queryRunner 생성
    await queryRunner.connect(); // 새로운 queryRunner를 연결
    await queryRunner.startTransaction(); // 새로운 트랜잭션을 시작한다는 의미의 코드

    try {
      const manager = queryRunner.manager;

      const user = await manager.findOne(User, { where: { id: userId } });
      if (!user) throw new NotFoundException('사용자를 찾을 수 없습니다.');

      const show = await manager.findOne(Show, { where: { id: showId } });
      if (!show) throw new NotFoundException('공연을 찾을 수 없습니다.');

      // 예약정보 생성하고 저장
      const reservation = await manager.save(Reservation, {
        user: { id: userId },
        show: { id: showId },
      });

      const userPoint = await manager.findOne(Point, {
        where: {
          user: { id: userId },
        },
      });

      const { price } = createSeatDto; // 가격
      let pointValue = userPoint.point; // 유저의 포인트

      // 유저 포인트가 있을 때 가격보다 적으면 부족하다고 반환
      if (userPoint) {
        if (pointValue < price) {
          await queryRunner.rollbackTransaction();
          throw new Error('사용자의 포인트가 부족합니다.');
        } else {
          // 포인트를 차감
          pointValue -= price;

          // 사용자의 포인트를 업데이트
          await manager.save(Point, userPoint);
        }
      }

      // 좌석 생성하고 저장
      const seat = await manager.save(Seat, {
        user: { id: userId },
        show: { id: showId },
        ...createSeatDto,
      });

      // Seat 엔티티에서 seatId와 일치하는 좌석을 검색
      // 검색된 좌석을 확인하고, 만약 좌석이 존재하지 않으면 롤백하고 예외
      const seatReservations = [];
      for (const seatId of createSeatDto.seatInfo) {
        const seats = await manager.find(Seat, { where: { id: seatId } });
        if (!seats) {
          await queryRunner.rollbackTransaction(); // 롤백
          throw new Error(`${seatId} 좌석 ID를 찾을 수 없습니다.`);
        }

        // 좌석 예약 정보를 생성하고 저장
        const seatReservation = await manager.save(SeatReservation, {
          reservation,
          seat,
          user,
        });
        seatReservations.push(seatReservation);
      }

      await queryRunner.commitTransaction(); // 모든 동작이 정상적으로 수행되었을 경우 커밋을 수행
      return { userPoint, seatReservations };
    } catch (error) {
      await queryRunner.rollbackTransaction();
      throw error; // 동작 중간에 에러가 발생할 경우엔 롤백
    } finally {
      await queryRunner.release(); // queryRunner는 생성한 뒤 반드시 release 해줘야함
    }
  }

  // 예매 취소
  async cancelReservation({
    userId,
    seatReservationId,
  }: IShowsServiceCancelReservationShow) {
    const queryRunner = this.dataSource.createQueryRunner();
    await queryRunner.connect();
    await queryRunner.startTransaction();

    try {
      const manager = queryRunner.manager;

      const seatReservation = await manager.findOne(SeatReservation, {
        where: { id: seatReservationId },
        relations: ['reservation', 'seat', 'user'],
      });

      if (!seatReservation)
        throw new NotFoundException('예약 내역을 찾을 수 없습니다.');

      if (seatReservation.reservation.isCanceled === true) {
        throw new BadRequestException('이미 취소된 예매 내역입니다.');
      }
      if (seatReservation.user.id !== userId) {
        throw new ForbiddenException('예매를 취소할 권한이 없습니다.');
      }

      // 포인트 반환하기
      const userPoint = await manager.findOne(Point, {
        where: { user: { id: userId } },
      });

      if (!userPoint) {
        throw new NotFoundException('사용자의 포인트 정보를 찾을 수 없습니다.');
      }
      const pointsToReturn = seatReservation.seat.price; // 좌석 가격
      userPoint.point += pointsToReturn; // 사용자 포인트 업데이트
      await manager.save(Point, userPoint);

      // 좌석 예약 정보를 업데이트하여 취소 표시
      await manager.remove(SeatReservation, seatReservation);

      seatReservation.reservation.isCanceled = true;
      await manager.save(Reservation, seatReservation.reservation);

      await queryRunner.commitTransaction(); // 모든 동작이 정상적으로 수행되었을 경우 커밋을 수행
      return {
        message: '예매가 취소되었습니다. 환불된 포인트: ' + pointsToReturn,
      };
    } catch (error) {
      await queryRunner.rollbackTransaction(); // 예외 발생 시 롤백
      throw error;
    } finally {
      await queryRunner.release();
    }
  }

 

 

[참고]
https://puleugo.tistory.com/157

 

[NestJS] TypeORM을 통한 트랜잭션 관리

서론 NestJS에서 관계가 묶여있는 여러 테이블에 INSERT를 해야하는 경우가 있습니다. 이 글에서는 TypeORM을 통해 트랜잭션을 관리해보겠습니다. 트랜잭션 관련 글 https://puleugo.tistory.com/142 데이터베

puleugo.tistory.com