[Nest.js] Transaction에 Lock을 적용해보자

김지엽·2024년 1월 2일
2
post-thumbnail
post-custom-banner

1. 개요

이전에 동시성 처리를 위해서 트랜잭션을 적용해보고 isolation level도 공부해 보았지만 부족하다는 생각이 들었고, 동시성 처리 기법에 알아보니 Lock에 대해 알게되었다.

이번에는 Lock의 종류를 알아보고 상황에 맞게 적용 시켜볼려고 한다.

2. 낙관적 vs 비관적

Lock은 크게 낙관적 락(optimistic-lock)과 비관적 락(pessimistic-lock)으로 나뉜다.

- 낙관적 락(optimistic-lock)

낙관적 락의 특징은 다음과 같다.

  • 실제로 읽은 데이터에 대해 다른 접근을 차단하는 것이 아니다.
  • version을 통해 충돌 여부를 확인한다.
  • 동시성 문제가 일어날 가능성이 적은 경우에 사용한다.

낙관적 락은 꽤 간단하다. 위의 특징과 같이 실제로 미리 데이터에 대해 다른 접근을 차단하는 것이 아니라 version을 통해 동시성 문제가 실제로 발생했을 경우에 그때 충돌여부를 확인해 처리하는 것이다.

- 비관적 락(pessimistic-lock)

비관적 락의 특징은 다음과 같다.

  • 트랜잭션 내부에서 데이터를 읽을때 lock을 걸어 다른 접근을 차단한다.
  • 비관적 락에는 "비관적 읽기 락", "비관적 쓰기 락" 등 다양한 방식이 존재한다.
  • 동시성 문제가 일어날 가능성이 높은 경우에 사용한다.

비관적 락은 트랜잭션 내부에서 특정 데이터에 대해 동시성 문제가 일어날 가능성이 높은 데이터를 읽을때 미리 lock을 걸어 트랜잭션이 커밋 되기 전까지 다른 접근을 모두 차단하기에 충돌을 방지하지만 동시성이 낮아진다는 단점이 있다. 따라서 필요 정도에 따라 방식을 유연하게 선택할 필요가 있다.

3. TypeOrm의 비관적 락 방식들

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으로 설정합니다.
다른 트랜잭션이 해당 엔티티를 읽는 것을 허용하지만, 키에 대한 수정을 막습니다.

4. TypeOrm을 통해 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();
    }
}

위는 좌석을 선택해 예매를 하는 코드이다. 대략 과정은 이러하다.

  1. 유저 정보와 공연 정보를 읽어온다.
  2. 좌석 정보를 읽어온다. (동시성 처리 필요)
  3. 사용자의 소지금 여부를 확인한다.
  4. 예약 정보를 확인한다.
  5. 예약을 생성한 후 트랜잭션을 종료한다.

위 과정에서 좌석 정보를 읽어올때 만약 이미 다른 사용자가 해당 좌석을 예약할려 하고 있다면 충돌이 발생할 수 있기에 동시성 처리가 필요하다. 이 경우에 읽기, 쓰기, 수정 모두 방지해야 하므로 pessimistic_write을 선택했다.

참고

동시성 처리 기법

profile
욕심 많은 개발자
post-custom-banner

0개의 댓글