이전에 동시성 처리를 위해서 트랜잭션을 적용해보고 isolation level도 공부해 보았지만 부족하다는 생각이 들었고, 동시성 처리 기법에 알아보니 Lock에 대해 알게되었다.
이번에는 Lock의 종류를 알아보고 상황에 맞게 적용 시켜볼려고 한다.
Lock은 크게 낙관적 락(optimistic-lock)과 비관적 락(pessimistic-lock)으로 나뉜다.
낙관적 락의 특징은 다음과 같다.
낙관적 락은 꽤 간단하다. 위의 특징과 같이 실제로 미리 데이터에 대해 다른 접근을 차단하는 것이 아니라 version을 통해 동시성 문제가 실제로 발생했을 경우에 그때 충돌여부를 확인해 처리하는 것이다.
비관적 락의 특징은 다음과 같다.
비관적 락은 트랜잭션 내부에서 특정 데이터에 대해 동시성 문제가 일어날 가능성이 높은 데이터를 읽을때 미리 lock을 걸어 트랜잭션이 커밋 되기 전까지 다른 접근을 모두 차단하기에 충돌을 방지하지만 동시성이 낮아진다는 단점이 있다. 따라서 필요 정도에 따라 방식을 유연하게 선택할 필요가 있다.
typeOrm에서 비관적 락을 설정할려고 할때 다음과 같은 방식들을 사용할 수 있다.
pessimistic_read (비관적인 읽기 락)
트랜잭션이 읽은 데이터를 해당 트랜잭션이 종료될 때까지 다른 트랜잭션이 읽는 것을 허용하면서, 쓰기 또는 수정하는 것을 방지합니다.
pessimistic_write (비관적인 쓰기 락)
트랜잭션이 읽은 데이터를 해당 트랜잭션이 종료될 때까지 다른 트랜잭션이 읽기, 쓰기, 수정하는 것을 방지하며 접근하지 못하도록 합니다.
dirty_read (더티 리드)
SERIALIZABLE isolation level에서 사용되는 Lock 모드로, 다른 트랜잭션이 커밋되지 않은 데이터를 읽는 것을 허용합니다.
다른 트랜잭션이 롤백할 경우, 읽은 데이터는 실제로 존재하지 않았던 것으로 간주됩니다.
pessimistic_partial_write (비관적인 부분 쓰기 락)
엔티티의 일부분에 대한 쓰기 작업에만 강력한 Lock을 적용합니다.
부분적인 쓰기 락을 설정하여 특정 필드만 다른 트랜잭션이 수정하지 못하도록 합니다.
pessimistic_write_or_fail (비관적인 쓰기 락 또는 실패)
쓰기 작업을 시도하고 실패할 경우 예외를 발생시킵니다.
다른 트랜잭션이 해당 데이터에 쓰기 작업을 수행 중이면 예외가 발생합니다.
for_no_key_update (키 업데이트 없음)
엔티티의 키에 대한 업데이트를 허용하지 않습니다.
키 필드가 변경되면 해당 엔티티의 저장이 실패합니다.
for_key_share (키 공유)
읽기 작업에 대한 Lock을 걸지 않고, 엔티티의 키만을 공유 Lock으로 설정합니다.
다른 트랜잭션이 해당 엔티티를 읽는 것을 허용하지만, 키에 대한 수정을 막습니다.
async bookWithSelect(userId: number, createBookDto: CreateBookWithSelect) {
const { showId, seatNumber } = createBookDto;
/// 트랜잭션
const queryRunner = this.dataSource.createQueryRunner();
await queryRunner.connect();
await queryRunner.startTransaction("REPEATABLE READ");
try {
// 사용자 확인
const user = await queryRunner.manager.findOne(User, {
where: { id: userId },
});
if (!user) {
throw new NotFoundException("존재하지 않는 유저입니다.");
}
// 공연 확인
const show = await queryRunner.manager.findOne(Show, {
where: { id: showId },
});
// 좌석 확인
const seat = await queryRunner.manager
.createQueryBuilder(Seat, "seat")
.setLock("pessimistic_write")
.leftJoinAndSelect("seat.show", "show")
.where("show.id = :showId", { showId })
.where("seat.seatNumber = :seatNumber", { seatNumber })
.getOne();
if (!seat) {
throw new NotFoundException("존재하지 않는 좌석입니다.");
}
// 사용자 소지금 차감
if (user.money < show.price) {
throw new ForbiddenException("소지금이 부족합니다.");
}
user.money = user.money - show.price;
await queryRunner.manager.save(user);
// 예약이 있는지 확인
const isBook = await queryRunner.manager
.createQueryBuilder(Book, "book")
.leftJoinAndSelect("book.show", "show")
.leftJoinAndSelect("book.seat", "seat")
.where("show.id = :showId", { showId })
.where("seat.id = :seatId", { seatId: seat.id })
.getOne();
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();
}
}
위는 좌석을 선택해 예매를 하는 코드이다. 대략 과정은 이러하다.
위 과정에서 좌석 정보를 읽어올때 만약 이미 다른 사용자가 해당 좌석을 예약할려 하고 있다면 충돌이 발생할 수 있기에 동시성 처리가 필요하다. 이 경우에 읽기, 쓰기, 수정 모두 방지해야 하므로 pessimistic_write을 선택했다.