본 내용은 내일배움캠프에서 활동한 내용을 기록한 글입니다.
이 방법에 대해서 굉장히 고민을 많이 했음
처음에는 빈 좌석 테이블에 사용자가 예매할 때마다 좌석의 정보를 추가하는 방식을 생각함
하지만 다른 측면에서 생각해보면 나중에 공연을 예매할 때 좌석 정보들을 클라이언트에게 반환해야 함
예매된 좌석 정보는 좌석 테이블에서 바로 가져갈 수 있지만 예매되지 않은 좌석들의 정보는 어떻게 해야 하는가?
그냥 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;
}
@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);
}
}
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();
}
}
내일은 해당 공연의 현재 예매 가능한 좌석의 정보를 반환하는 기능을 만들 예정
예매 가능한 좌석 뿐만 아니라 프론트에서 회색 표시로 보이기 위해 이미 예매된 좌석의 정보도 같이 반환할 예정
좌석 정보 확인 구현이 끝나면 예매 취소 기능과 시간과 관련된 예외 처리를 할 예정
시간과 관련된 예외 처리는 공연 시작 3시간 전까지만 예매를 취소할 수 있는 것과 오늘을 기점으로 이전 날짜의 좌석 정보는 반환하지 않도록 하는 것을 구현할 예정
오늘은 좌석 정보 생성과 지정 좌석 예매에 대한 기능을 구현함
위에서도 말했지만 좌석 정보 생성은 특히나 고민을 많이 했음
사실 개발에는 정답이 없지만 조금 더 좋은 코드가 되도록 할 수는 있음
그래서 처음에 ERD에서의 관계라든지, 컬럼 설정 등을 굉장히 고민을 많이 했음
하지만 튜터님께서 일단 동작하게끔 만드는게 가장 중요하다고 하셨기에 일단 생각한 방법으로 도전해서 동작하게 만듦
지정 좌석 예매 기능은 생각보다 좌석과 공연 사이에 관계설정이 굉장히 어려웠음
공연 하나에는 여러 시간대가 있기 때문에 시간대 별 레코드 데이터가 필요하고 그 시간대별 데이터에는 좌석의 수를 관리하는 테이블이 필요함
그리고 좌석 정보는 공연, 시간에 따라서 좌석의 정보가 필요하기에 그에 대한 관계를 설정하느라 엔티티를 많이 수정함
그래도 구현해서 제대로 동작하는 것을 보니 방향은 맞았구나 라는 생각이 듦
이후에는 코드가 조금 더 가독성 좋게 만드는 방법을 생각해 봐야 겠음