이번 프로젝트에서는 공연의 예매를 하는 기능을 구현하게 되었다.
구현에 필요하는 것은 다음과 같다.
1. 만약 사용자의 소지금, 좌석 문제등으로 에러가 발생할때 전체 동작을 취소하는 것
2. 한 사용자가 이 좌석을 예약할때 다른 사용자가 추가로 접근을 할 수 없게하는 것
위와같은 과정에서 예매하는 좌석에 대해 동시성 처리가 이루어져야 했는데 이를 위해 트랜잭션을 적용하기로 결정했다.
nestJs에서 트래잭션을 사용할때는 typeorm 패키지에서 DataSource 객체를 주입받아 queryRunner 클래스를 생성해서 이를 통해 진행한다.
예매의 코드는 다음과 같다.
constructor(
@InjectRepository(Book)
private readonly bookRepository: Repository<Book>,
...
private readonly dataSource: DataSource,
) {}
위와 같이 컨트롤러 혹은 서비스의 생성자에서 DataSource를 주입받는다.
async bookWithSelect(userId: number, createBookDto: CreateBookWithSelect) {
const { showId, seatNumber } = createBookDto;
/// 트랜잭션
const queryRunner = this.dataSource.createQueryRunner();
await queryRunner.connect();
await queryRunner.startTransaction(); // 트랜잭션 시작
try {
const user = await this.userService.findUserById(userId);
if (!user) {
throw new NotFoundException("존재하지 않는 유저입니다.");
}
const show = await this.showService.findShowById(showId);
const showData = await this.showService.findShowDetail(showId);
if (!showData) {
throw new NotFoundException("존재하지 않는 공연입니다.");
}
if (!showData.isBookPossible) {
throw new UnprocessableEntityException(
"이미 예약이 다 찼습니다.",
);
}
const seat = await this.seatService.findSeatByCondition(
showId,
seatNumber,
);
if (!seat) {
throw new NotFoundException("존재하지 않는 좌석입니다.");
}
if (user.money < showData.price) {
throw new ForbiddenException("소지금이 부족합니다.");
}
user.money = user.money - showData.price;
await queryRunner.manager.save(user);
const isBook = await this.findBookByCondition(showId, seat.id);
if (isBook) {
throw new ConflictException("이미 예약되어 있습니다.");
}
const book = this.bookRepository.create({
seat,
show,
user,
});
await queryRunner.manager.save(Book, book);
await queryRunner.commitTransaction(); // 트랜잭션 커밋
return {
bookId: book.id,
date: show.date,
time: show.time,
place: show.place,
price: show.price,
seat: seatNumber,
};
} catch (e) {
await queryRunner.rollbackTransaction(); // 트랜잭션 롤백
throw e;
} finally {
await queryRunner.release(); // 쿼리 러너 종료
}
}
위와 같은 예매 코드 처럼 트랜잭션은 다음과 같은 흐름에 따라 이루어진다.
따라서 예약 도중 소비자의 소지금이 부족한 경우, 이미 좌석이 예매 되어 있을 경우, 예상치 못한 에러가 발생할 경우 등에서 try-catch문을 이용한 예외처리를 통해서 진행했던 동작을 모두 취소한다.
트랜잭션을 통해 예외가 발생했을때 해당 작업을 취소하는 것까지는 완료했지만, 아직 만족스러운 동시성 처리를 하지는 못했다.
만약에 저렇게 트랜잭션을 적용했어도 한 유저가 좌석을 예매하는 도중에 다른 유저가 데이터에 간섭을 한다면?
이러한 문제를 해결하는 방법을 찾아본 결과 isolation level을 알게되었다.
Isolation level은 데이터베이스에서 여러 트랜잭션이 동시에 실행될 때 어떻게 서로 간섭을 허용할지를 제어하는 역할을 한다. 다음과 같은 level들이 있다.
READ UNCOMMITTED
READ COMMITTED
REPEATABLE READ
SERIALIZABLE
발생하는 문제들의 특징은 다음과 같다.
Dirty Read
Non-repeatable Read
Phantom Read
queryRunner를 통해 transaction에 isolation level을 적용하는 것은 매우 간단하다.
queryRunner에 startTransaction 메서드의 매개변수를 통해 넣을 수 있다. 다음 해당 메서드의 구조이다.
export interface QueryRunner {
...
startTransaction(isolationLevel?: IsolationLevel): Promise<void>;
...
}
IsolationLevel 타입은 다음과 같은 유니온 타입으로 이루어져 있다.
export type IsolationLevel = "READ UNCOMMITTED" | "READ COMMITTED" | "REPEATABLE READ" | "SERIALIZABLE";
위의 예매 서비스 로직에서는 삽입, 삭제등은 이루어지지 않으므로 수정만을 제한하기 위해 isolation level을 REPEATABLE READ로 설정하기로 했다.
/// 트랜잭션
const queryRunner = this.dataSource.createQueryRunner();
await queryRunner.connect();
await queryRunner.startTransaction("REPEATABLE READ");