[Nest.js] TypeORM을 통해Transaction을 적용하자

김지엽·2023년 12월 26일
0
post-thumbnail

1. 개요

이번 프로젝트에서는 공연의 예매를 하는 기능을 구현하게 되었다.

구현에 필요하는 것은 다음과 같다.
1. 만약 사용자의 소지금, 좌석 문제등으로 에러가 발생할때 전체 동작을 취소하는 것
2. 한 사용자가 이 좌석을 예약할때 다른 사용자가 추가로 접근을 할 수 없게하는 것

위와같은 과정에서 예매하는 좌석에 대해 동시성 처리가 이루어져야 했는데 이를 위해 트랜잭션을 적용하기로 결정했다.

2. queryRunner를 이용한 Transaction

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문을 이용한 예외처리를 통해서 진행했던 동작을 모두 취소한다.

3. 불완전한 동시성 처리

트랜잭션을 통해 예외가 발생했을때 해당 작업을 취소하는 것까지는 완료했지만, 아직 만족스러운 동시성 처리를 하지는 못했다.

만약에 저렇게 트랜잭션을 적용했어도 한 유저가 좌석을 예매하는 도중에 다른 유저가 데이터에 간섭을 한다면?

이러한 문제를 해결하는 방법을 찾아본 결과 isolation level을 알게되었다.

4. isolation level

Isolation level은 데이터베이스에서 여러 트랜잭션이 동시에 실행될 때 어떻게 서로 간섭을 허용할지를 제어하는 역할을 한다. 다음과 같은 level들이 있다.

READ UNCOMMITTED

  • 하나의 트랜잭션이 아직 커밋되지 않은 다른 트랜잭션의 변경사항을 읽을 수 있음.
  • Dirty Read(더티 리드), Non-repeatable Read(논-리피터블 리드), Phantom Read(팬텀 리드)가 발생할 수 있음.

READ COMMITTED

  • 트랜잭션이 커밋된 데이터만 읽을 수 있음.
  • Non-repeatable Read, Phantom Read가 발생할 수 있음.

REPEATABLE READ

  • 트랜잭션이 실행 중에 읽은 데이터는 다른 트랜잭션이 수정할 수 없음.
  • Phantom Read가 발생할 수 있음.

SERIALIZABLE

  • 트랜잭션이 실행 중에 읽거나 수정한 데이터는 다른 트랜잭션에서 접근할 수 없음.
  • Dirty Read, Non-repeatable Read, Phantom Read가 발생하지 않음.

발생하는 문제들의 특징은 다음과 같다.

Dirty Read

  • 정의: 하나의 트랜잭션이 아직 커밋되지 않은 다른 트랜잭션의 변경된 데이터를 읽는 것을 의미합니다.
  • 문제점: 다른 트랜잭션이 롤백될 경우, 읽은 데이터는 사실상 존재하지 않았던 것이 되어버립니다.

Non-repeatable Read

  • 정의: 한 트랜잭션에서 동일한 쿼리를 실행할 때, 다른 트랜잭션이 중간에 값을 변경하면서 값이 변경되는 현상을 의미합니다.
  • 문제점: 동일한 쿼리를 두 번 실행했을 때 결과가 다르게 나올 수 있습니다.

Phantom Read

  • 정의: 한 트랜잭션이 동일한 쿼리를 실행할 때, 다른 트랜잭션이 새로운 데이터를 삽입하거나 삭제하여 결과 집합이 변경되는 현상을 의미합니다.
  • 문제점: 동일한 쿼리를 두 번 실행했을 때 결과 집합이 다를 수 있습니다.

5. transaction에 isolation level 적용

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");

참고

nestJs typeorm 공식문서

profile
욕심 많은 개발자

0개의 댓글