혼자 고민해보기_ 개발/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