Skip to main content

2. Lock

락(Lock)이란 동일한 자원에 접근할 때, 데이터의 일관성과 무결성을 유지하기 위해 자원에 대한 접근을 제어하는 메커니즘이다.

락을 사용하지 않았을 때

락의 종류에 대해서 알아보기 전에, 락을 사용하지 않았을 때 동시성 문제가 발생할 수 있는 상황을 간단하게 재현해보자.

테스트 환경의 DB는 MySQL8.0을 사용하였다. 해당 글에서 사용되는 예시코드는 GitHub 에서도 확인할 수 있다.

coupon.entity.ts
@Entity()
export class Coupon {
@PrimaryGeneratedColumn()
id: number;

@Column()
code: string;

@Column({ default: false })
isRedeemed: boolean;

@ManyToOne(() => User, (user) => user.coupons, { nullable: true })
@JoinColumn({ name: "user_id", referencedColumnName: "id" })
user: User;

@Column({ name: "user_id", nullable: true })
userId: number;

@VersionColumn()
version: number;
}

쿠폰은 하나당 한 명의 유저에게만 발행될 수 있다. 유저가 등록(발행)된 쿠폰은 isRedeemed 칼럼이 true로 업데이트 된다.

  async assignCouponWithoutLock(
userId: number,
): Promise<{ readCoupon: Coupon; saveCoupon: Coupon }> {
const coupon = await this.couponRepository.findOne({
where: { isRedeemed: false },
order: { id: 'ASC' },
});

if (!coupon) {
throw new Error('No available coupons');
}

coupon.isRedeemed = true;
coupon.userId = userId;

return {
readCoupon: coupon,
saveCoupon: await this.couponRepository.save(coupon),
};
}

아직 등록되지 않은 쿠폰 (isRedeemed이 false인 쿠폰)을 1개 조회하여 요청한 유저에게 등록해주는 함수를 비동기로 동시에 요청했을 때, 발급될 수 있는 쿠폰은 하나뿐이기 때문에 하나의 요청은 실패해야하지만 마치 두 명의 유저에게 모두 쿠폰이 발급된 것처럼 동작하는 것을 볼 수 있다.

  • 실행 쿼리

      -- 먼저 실행된 요청의 조회 쿼리 (findOne)
    SELECT `Coupon`.`id` AS `Coupon_id`, `Coupon`.`code` AS `Coupon_code`, `Coupon`.`isRedeemed` AS `Coupon_isRedeemed`, `Coupon`.`user_id` AS `Coupon_user_id`, `Coupon`.`version` AS `Coupon_version` FROM `coupon` `Coupon` WHERE ((`Coupon`.`isRedeemed` = ?)) ORDER BY `Coupon`.`id` ASC LIMIT 1 -- PARAMETERS: [false]
    -- 먼저 실행된 요청의 조회 쿼리 (save)
    SELECT `Coupon`.`id` AS `Coupon_id`, `Coupon`.`code` AS `Coupon_code`, `Coupon`.`isRedeemed` AS `Coupon_isRedeemed`, `Coupon`.`user_id` AS `Coupon_user_id`, `Coupon`.`version` AS `Coupon_version` FROM `coupon` `Coupon` WHERE `Coupon`.`id` IN (?) -- PARAMETERS: [1]
    -- 먼저 실행된 요청의 트랜잭션 시작
    START TRANSACTION
    -- 두 번째로 실행된 요청의 조회 쿼리 (findOne)
    SELECT `Coupon`.`id` AS `Coupon_id`, `Coupon`.`code` AS `Coupon_code`, `Coupon`.`isRedeemed` AS `Coupon_isRedeemed`, `Coupon`.`user_id` AS `Coupon_user_id`, `Coupon`.`version` AS `Coupon_version` FROM `coupon` `Coupon` WHERE ((`Coupon`.`isRedeemed` = ?)) ORDER BY `Coupon`.`id` ASC LIMIT 1 -- PARAMETERS: [false]
    -- 먼저 실행된 요청의 업데이트 쿼리 (save)
    UPDATE `coupon` SET `isRedeemed` = ?, `user_id` = ?, `version` = `version` + 1 WHERE `id` IN (?) -- PARAMETERS: [1,1,1]
    -- 두 번째로 실행된 요청의 조회 쿼리 (save)
    SELECT `Coupon`.`id` AS `Coupon_id`, `Coupon`.`code` AS `Coupon_code`, `Coupon`.`isRedeemed` AS `Coupon_isRedeemed`, `Coupon`.`user_id` AS `Coupon_user_id`, `Coupon`.`version` AS `Coupon_version` FROM `coupon` `Coupon` WHERE `Coupon`.`id` IN (?) -- PARAMETERS: [1]
    -- 먼저 실행된 요청의 업데이트 후 조회 쿼리 (save)
    SELECT `Coupon`.`id` AS `Coupon_id`, `Coupon`.`version` AS `Coupon_version` FROM `coupon` `Coupon` WHERE `Coupon`.`id` = ? -- PARAMETERS: [1]
    -- 두 번째로 실행된 요청의 트랜잭션 시작
    START TRANSACTION
    -- 먼저 실행된 요청의 트랜잭션 커밋
    COMMIT
    -- 두 번째로 실행된 요청의 업데이트, 조회 쿼리 (save)
    UPDATE `coupon` SET `isRedeemed` = ?, `user_id` = ?, `version` = `version` + 1 WHERE `id` IN (?) -- PARAMETERS: [1,2,1]
    SELECT `Coupon`.`id` AS `Coupon_id`, `Coupon`.`version` AS `Coupon_version` FROM `coupon` `Coupon` WHERE `Coupon`.`id` = ? -- PARAMETERS: [1]
    -- 두 번째로 실행된 요청의 트랜잭션 커밋
    COMMIT

위처럼 동일한 자원에 대한 동시다발적인 요청이 있을 때 발생할 수 있는 문제를 방지하기 위한 방법으로 Lock을 사용할 수 있다.

낙관적 락 (Optimistic Lock)

낙관적 락(Optimistic Lock)은 충돌이 발생할 가능성이 낮다고 가정하고, 데이터베이스에 락을 설정하지 않고 데이터의 커밋 전 충돌을 감지하는 방식이다.

동작 방식

  1. 데이터 읽기 : 데이터를 읽을 때 버전 정보를 함께 읽는다. 버전 정보는 보통 업데이트될 때마다 증가하는 번호나 타임스탬프 등으로 관리된다.

  2. 데이터 수정 : 데이터를 수정할 때, 먼저 데이터의 버전 정보를 확인한다. 처음 조회한 데이터 버전 정보를 기반으로 현재 데이터베이스에 저장된 버전 정보가 일치하는지 비교한다.

    버전이 일치하는 경우, 데이터가 다른 트랜잭션에 의해 수정되지 않았음을 의미하며, 데이터를 수정하고 버전 정보를 갱신한다.

    버전이 불일치하는 경우, 데이터가 다른 트랜잭션에 의해 수정되었음을 의미하며, 충돌이 발생한 것으로 간주한다. 이 경우 트랜잭션을 롤백하거나 어플리케이션 단에서 충돌을 처리한다.

장점

  • 락 오버헤드 없음 : 낙관적 락은 데이터를 읽을 때 락을 걸지 않으므로 락을 관리하고 해제하는 오버헤드가 없어져 데이터베이스 성능이 향상된다.
  • 낮은 대기 시간 : 락을 기다릴 필요가 없기 때문에 데이터베이스 트랜잭션의 대기 시간이 짧다.
  • 데드락 없음 : 데이터를 수정하기 전에 락을 걸지 않기 때문에 데드락 상황이 방지된다.
  • 유연한 충돌 처리 : 충돌이 발생했을 때 롤백하거나 재시도하는 로직 등을 애플리케이션에서 유연하게 처리할 수 있으므로 다양한 충돌 처리 전략을 적용할 수 있다.

단점

  • 충돌 처리의 복잡성 : 충돌이 발생했을 때 이를 처리하기 위한 추가 로직이 필요하다.
  • 충돌이 잦은 환경에서의 성능 저하 : 데이터 충돌이 자주 발생하는 환경에서는 낙관적 락의 이점이 감소할 수 있다. 충돌이 자주 발생하면 트랜잭션이 자주 롤백되거나 재시도되기 때문에 성능 저하가 발생할 수 있다.

Test With TypeORM

테스트에 사용된 Coupon 엔티티에 @VersionCoulumn 데코레이터를 통해 업데이트가 호출될 때마다 자동으로 값을 증분시켜주는 version 칼럼을 사용하였다.

  async assignCouponWithOptimisticLock(userId: number): Promise<{
readCoupon?: Coupon;
saveCoupon?: Coupon;
error?: string;
}> {
const coupon = await this.couponRepository.findOne({
where: { isRedeemed: false },
order: { id: 'ASC' },
});

if (!coupon) {
throw new Error('No available coupons');
}

const updateResult = await this.couponRepository.update(
{ id: coupon.id, version: coupon.version },
{ isRedeemed: true, userId },
);

if (updateResult.affected === 0) {
return {
readCoupon: coupon,
error: 'Optimistic lock version mismatch',
};
}

return {
readCoupon: coupon,
saveCoupon: await this.couponRepository.findOne({
where: { id: coupon.id },
}),
};
}
  • 실행 흐름

    1. 두 요청(Promise) 모두 버전 값이 같은, 동일한 ID의 쿠폰 레코드를 조회한다. 레코드 생성 후 업데이트한 적이 없기 때문에 version 값은 1이다.
    2. update 쿼리의 where 조건으로 version 1이 들어가므로, 두 요청 중에 먼저 실행된 update 쿼리만 레코드를 수정한다.
    3. 이미 update가 실행되어 version이 2로 증가한 경우, 뒤이어 실행된 update 쿼리에서는 where 조건의 version 1로 조회되는 쿠폰이 없기 때문에 쿠폰이 업데이트 되지 않는다.
    4. update 쿼리 결과의 affected(영향을 받은 레코드의 수)가 0인 경우 충돌이 발생한 상황이므로 그에 따른 예외처리를 한다.
  • 실행 쿼리

     -- 요청 1,2의 조회 쿼리 실행 (findOne)
    SELECT `Coupon`.`id` AS `Coupon_id`, `Coupon`.`code` AS `Coupon_code`, `Coupon`.`isRedeemed` AS `Coupon_isRedeemed`, `Coupon`.`user_id` AS `Coupon_user_id`, `Coupon`.`version` AS `Coupon_version` FROM `coupon` `Coupon` WHERE ((`Coupon`.`isRedeemed` = ?)) ORDER BY `Coupon`.`id` ASC LIMIT 1
    SELECT `Coupon`.`id` AS `Coupon_id`, `Coupon`.`code` AS `Coupon_code`, `Coupon`.`isRedeemed` AS `Coupon_isRedeemed`, `Coupon`.`user_id` AS `Coupon_user_id`, `Coupon`.`version` AS `Coupon_version` FROM `coupon` `Coupon` WHERE ((`Coupon`.`isRedeemed` = ?)) ORDER BY `Coupon`.`id` ASC LIMIT 1
    -- 요청 1의 업데이트 쿼리가 먼저 실행 됨 (update)
    UPDATE `coupon` SET `isRedeemed` = ?, `user_id` = ?, `version` = `version` + 1 WHERE (`id` = ? AND `version` = ?) -- PARAMETERS: [1,9,5,1]
    -- 이 때는 coupon의 버전이 2로 업데이트 된 이후이므로 레코드 수정이 이루어지지 않음 (update)
    UPDATE `coupon` SET `isRedeemed` = ?, `user_id` = ?, `version` = `version` + 1 WHERE (`id` = ? AND `version` = ?) -- PARAMETERS: [1,10,5,1]

위 테스트에서는 서비스 함수 내에서 이루어지는 DB 작업들을 트랜잭션으로 묶지 않았지만, 여러 엔티티에 대한 읽기/쓰기 작업이 일관성 있게 이루어져야 하는 복잡한 상황에서는 트랜잭션을 사용하여 충돌이 발생한 경우 롤백을 하는 식으로 처리할 수 있다.

비관적 락 (Pessimistic Lock)

비관적 락(Pessimistic Lock)은 충돌이 발생한다고 가정하고, 우선 Lock을 거는 방법이다. 낙관적 락과 달리 DB 차원의 락 기능을 활용한다. 락을 획득할 때까지 다른 트랜잭션은 대기하게 된다. 비관적 락에는 읽기 락(공유 락)과 쓰기 락(배타 락)이 있다.

비관적 락 vs 낙관적 락

비교 항목비관적 락 (Pessimistic Lock)낙관적 락 (Optimistic Lock)
성능자원 잠금 관리로 인해 오버헤드가 발생하여 성능이 저하될 수 있음락 관리 오버헤드가 없으나, 충돌이 잦을 경우 롤백이 많아져 성능이 저하될 수 있음
데이터 정합성트랜잭션 중 자원을 잠그므로, 데이터 정합성이 높은 수준으로 유지됨충돌이 발생할 경우 데이터 정합성 유지가 어려울 수 있음
사용 사례동시 접근이 빈번하고 데이터 무결성이 중요한 환경데이터 충돌 가능성이 낮고 성능이 중요한 환경

읽기 락 (Read Lock) & 공유 락 (Shared Lock)

  • 공유 락은 하나의 트랜잭션이 데이터를 읽는 동안 다른 트랜잭션이 해당 데이터를 읽을 수 있도록 허용하지만, 데이터를 수정하는 것은 막는 락이다.
  • 공유 락이 걸린 데이터에 대해서 다른 트랜잭션도 공유 락을 획득할 수 있으나 배타 락은 획득할 수 없다. 여러 트랜잭션이 동일한 자원에 대해 공유 락을 획득할 수 있으므로 읽기 작업 간의 동시성을 높여준다.
  • 공유 락을 사용하면 조회한 데이터가 트랜잭션 내내 변경되지 않음을 보장한다.

장점

  • 동시성 증가 : 여러 트랜잭션이 동시에 동일한 데이터에 대한 읽기 작업을 수행할 수 있다.
  • 데이터 일관성 유지 : 공유 락을 통해 데이터를 읽는 동안 다른 트랜잭션이 해당 데이터를 수정하지 못하도록 막기 때문에, 데이터의 일관성이 유지된다.

단점

  • 쓰기 작업의 지연 : 공유 락이 걸린 데이터에 대해 다른 트랜잭션이 배타 락을 걸어 데이터를 수정하려고 할 때, 공유 락이 해제될 때까지 대기해야 하므로 쓰기 작업의 지연을 초래할 수 있다.
  • 데드락 가능성 : 공유 락과 배타 락이 서로 충돌하는 상황이 발생할 수 있다. 예를 들어, 두 트랜잭션이 서로 다른 자원에 대해 공유 락을 획득한 상태에서 상대방이 가진 자원에 대해 배타 락을 요구할 경우, 데드락이 발생할 수 있다.
  • 락 유지 비용 : 여러 트랜잭션이 공유 락을 걸 수 있으므로, 시스템은 더 많은 락을 관리해야 한다. 이는 락을 유지하고 관리하는 오버헤드를 증가시킬 수 있다.

Test With TypeORM

  async assignCouponWithPessimisticReadLock(userId: number): Promise<{
readCoupon?: Coupon;
saveCoupon?: Coupon;
error?: string;
}> {
const queryRunner = this.dataSource.createQueryRunner();
await queryRunner.startTransaction();

let coupon;
let saveCoupon;
let error;

try {
coupon = await queryRunner.manager
.createQueryBuilder(Coupon, 'coupon')
.where('coupon.isRedeemed = :isRedeemed', { isRedeemed: false })
.setLock('pessimistic_read')
.orderBy('coupon.id', 'ASC')
.getOne();

if (!coupon) {
throw new Error('No available coupons');
}

coupon.isRedeemed = true;
coupon.userId = userId;

saveCoupon = await queryRunner.manager.save(coupon);

await queryRunner.commitTransaction();
} catch (e) {
await queryRunner.rollbackTransaction();
error = e.message;
} finally {
await queryRunner.release();

return {
readCoupon: coupon,
saveCoupon,
error,
};
}
}
  • 실행 흐름

    1. 공유 락은 다른 트랜잭션의 공유 락을 허용하기 때문에 두 요청 모두 쿠폰 조회가 가능하다.
    2. 업데이트를 위해 배타 락을 획득하려하지만, 두 트랜잭션에서 모두 공유 락이 걸려있기 때문에 서로의 락이 해제되기를 기다리는 데드락이 발생한다.
    3. 데드락 상황을 해결하기 위해 하나의 트랜잭션을 롤백 처리되고, 남은 트랜잭션이 업데이트 및 커밋에 성공한다.
  • 실행 쿼리

      -- 첫 번째 요청의 트랜잭션 시작
    START TRANSACTION
    -- 두 번째 요청의 트랜잭션 시작
    START TRANSACTION
    -- 첫 번째 트랜잭션에서 쿠폰을 조회하며 공유 락 설정 (getOne)
    SELECT `coupon`.`id` AS `coupon_id`, `coupon`.`code` AS `coupon_code`, `coupon`.`isRedeemed` AS `coupon_isRedeemed`, `coupon`.`user_id` AS `coupon_user_id`, `coupon`.`version` AS `coupon_version` FROM `coupon` `coupon` WHERE `coupon`.`isRedeemed` = ? ORDER BY `coupon`.`id` ASC FOR SHARE -- PARAMETERS: [false]
    -- 두 번째 트랜잭션에도 쿠폰을 조회하며 공유 락 설정 (getOne)
    SELECT `coupon`.`id` AS `coupon_id`, `coupon`.`code` AS `coupon_code`, `coupon`.`isRedeemed` AS `coupon_isRedeemed`, `coupon`.`user_id` AS `coupon_user_id`, `coupon`.`version` AS `coupon_version` FROM `coupon` `coupon` WHERE `coupon`.`isRedeemed` = ? ORDER BY `coupon`.`id` ASC FOR SHARE -- PARAMETERS: [false]
    -- 요청 1,2의 조회 쿼리 (save)
    SELECT `Coupon`.`id` AS `Coupon_id`, `Coupon`.`code` AS `Coupon_code`, `Coupon`.`isRedeemed` AS `Coupon_isRedeemed`, `Coupon`.`user_id` AS `Coupon_user_id`, `Coupon`.`version` AS `Coupon_version` FROM `coupon` `Coupon` WHERE `Coupon`.`id` IN (?) -- PARAMETERS: [2]
    SELECT `Coupon`.`id` AS `Coupon_id`, `Coupon`.`code` AS `Coupon_code`, `Coupon`.`isRedeemed` AS `Coupon_isRedeemed`, `Coupon`.`user_id` AS `Coupon_user_id`, `Coupon`.`version` AS `Coupon_version` FROM `coupon` `Coupon` WHERE `Coupon`.`id` IN (?) -- PARAMETERS: [2]
    -- 첫 번째 트랜잭션에서 쿠폰 업데이트 (save), 업데이트를 위해서는 배타 락을 획득해야하지만 두 번째 트랜잭션에서도 공유 락이 걸려있기 때문에 획득 불가
    UPDATE `coupon` SET `isRedeemed` = ?, `user_id` = ?, `version` = `version` + 1 WHERE `id` IN (?) -- PARAMETERS: [1,3,2]
    -- 두 번째 트랜잭션에서 쿠폰 업데이트 (save), 업데이트를 위해서는 배타 락을 획득해야하지만 첫 번째 트랜잭션에서 공유 락이 걸려있기 때문에 획득 불가
    UPDATE `coupon` SET `isRedeemed` = ?, `user_id` = ?, `version` = `version` + 1 WHERE `id` IN (?) -- PARAMETERS: [1,4,2]
    -- 두 트랜잭션에서 서로 배타 락을 획득하기 위해 무한대기하게 되므로 데드락 발생 후 하나의 트랜잭션 롤백 처리
    ROLLBACK
    -- 데드락 해소 후 남은 트랜잭션 조회 쿼리 (save)
    SELECT `Coupon`.`id` AS `Coupon_id`, `Coupon`.`version` AS `Coupon_version` FROM `coupon` `Coupon` WHERE `Coupon`.`id` = ? -- PARAMETERS: [2]
    -- 데드락 해소 후 남은 트랜잭션 커밋
    COMMIT

쓰기 락 (Write Lock) & 배타 락 (Exclusive Lock)

  • 배타 락은 자원에 대한 접근을 독점하며, 다른 트랜잭션에 해당 자원에 대해 읽기나 쓰기 작업을 할 수 없도록 차단한다.
  • 배타 락에 걸린 자원은 해당 트랜잭션이 완료될 때까지 다른 트랜잭션이 접근하지 못한다. 즉, 배타 락이 걸린 동안 다른 트랜잭션은 해당 자원에 대해 어떤 작업도 수행할 수 없다.

장점

  • 데이터 일관성 보장 : 배타 락은 트랜잭션 간의 경쟁 조건을 방지하여 데이터의 무결성과 일관성을 유지한다. 여러 트랜잭션이 동시에 동일한 데이터를 수정하려고 할 때 발생할 수 있는 충돌을 방지한다.
  • 안전한 쓰기 작업 : 쓰기 작업 중 다른 트랜잭션의 간섭을 방지하여 안정적으로 데이터를 수정할 수 있다. 이는 특히 중요한 금융 거래나 재고 관리와 같은 상황에서 유용하다.

단점

  • 동시성 저하 : 배타 락이 걸린 데이터는 다른 트랜잭션이 접근할 수 없기 때문에 시스템의 동시 처리 능력이 저하될 수 있다. 많은 트랜잭션이 동일한 데이터를 다루려고 할 경우, 대기 시간이 늘어나면서 전체 시스템 성능이 저하될 수 있다.
  • 데드락 가능성 : 여러 트랜잭션이 서로 다른 리소스에 대해 배타 락을 걸려고 할 때, 각 트랜잭션이 상대방의 락 해제를 기다리면서 교착 상태에 빠질 수 있다.

Test With TypeORM

async assignCouponWithPessimisticWriteLock(userId: number): Promise<{
readCoupon?: Coupon;
saveCoupon?: Coupon;
error?: string;
}> {
const queryRunner = this.dataSource.createQueryRunner();
await queryRunner.startTransaction();

let coupon;
let saveCoupon;
let error;

try {
coupon = await queryRunner.manager
.createQueryBuilder(Coupon, 'coupon')
.where('coupon.isRedeemed = :isRedeemed', { isRedeemed: false })
.setLock('pessimistic_write')
.orderBy('coupon.id', 'ASC')
.getOne();

if (!coupon) {
throw new Error('No available coupon');
}

coupon.isRedeemed = true;
coupon.userId = userId;

saveCoupon = await queryRunner.manager.save(coupon);

await queryRunner.commitTransaction();
} catch (e) {
await queryRunner.rollbackTransaction();
error = e.message;
} finally {
await queryRunner.release();

return {
readCoupon: coupon,
saveCoupon,
error,
};
}
}
  • 실행 흐름

    1. 첫 번째 트랜잭션이 쿠폰을 조회함과 동시에 쿠폰에 대한 배타 락을 획득한다. 배타 락으로 인해, 다른 트랜잭션이 동일한 쿠폰에 접근하거나 수정할 수 없게 된다.
    2. 두 번째 트랜잭션도 동일한 쿠폰을 조회하려고 시도하지만, 첫 번째 트랜잭션이 이미 배타 락을 획득한 상태이므로 대기 상태에 들어간다.
    3. 첫 번째 트랜잭션에서 쿠폰 업데이트가 완료된 후 트랜잭션이 커밋되고 락이 해제된다.
    4. 두 번째 트랜잭션이 다시 시도되며 쿠폰을 조회한다. 그러나 이 시점에서 쿠폰은 이미 할당된 상태 (isRedeemed이 true)이므로, 조회된 쿠폰이 없어 사용할 수 있는 쿠폰이 없다는 오류를 반환하고 롤백된다.
  • 실행 쿼리

      -- 첫 번째 요청의 트랜잭션 시작
    START TRANSACTION
    -- 두 번째 요청의 트랜잭션 시작
    START TRANSACTION
    -- 첫 번째 트랜잭션에서 쿠폰을 조회하며 배타 락 획득 (getOne)
    SELECT `coupon`.`id` AS `coupon_id`, `coupon`.`code` AS `coupon_code`, `coupon`.`isRedeemed` AS `coupon_isRedeemed`, `coupon`.`user_id` AS `coupon_user_id`, `coupon`.`version` AS `coupon_version` FROM `coupon` `coupon` WHERE `coupon`.`isRedeemed` = ? ORDER BY `coupon`.`id` ASC FOR UPDATE -- PARAMETERS: [false]
    -- 두 번째 트랜잭션에서 쿠폰을 조회하며 배타 락을 획득하려하지만 이미 배타 락이 걸려있으므로 대기
    SELECT `coupon`.`id` AS `coupon_id`, `coupon`.`code` AS `coupon_code`, `coupon`.`isRedeemed` AS `coupon_isRedeemed`, `coupon`.`user_id` AS `coupon_user_id`, `coupon`.`version` AS `coupon_version` FROM `coupon` `coupon` WHERE `coupon`.`isRedeemed` = ? ORDER BY `coupon`.`id` ASC FOR UPDATE -- PARAMETERS: [false]
    -- 첫 번째 트랜잭션의 쿠폰 업데이트 및 조회 (save)
    SELECT `Coupon`.`id` AS `Coupon_id`, `Coupon`.`code` AS `Coupon_code`, `Coupon`.`isRedeemed` AS `Coupon_isRedeemed`, `Coupon`.`user_id` AS `Coupon_user_id`, `Coupon`.`version` AS `Coupon_version` FROM `coupon` `Coupon` WHERE `Coupon`.`id` IN (?) -- PARAMETERS: [3]
    UPDATE `coupon` SET `isRedeemed` = ?, `user_id` = ?, `version` = `version` + 1 WHERE `id` IN (?) -- PARAMETERS: [1,5,3]
    SELECT `Coupon`.`id` AS `Coupon_id`, `Coupon`.`version` AS `Coupon_version` FROM `coupon` `Coupon` WHERE `Coupon`.`id` = ? -- PARAMETERS: [3]
    -- 첫 번째 트랜잭션 커밋
    COMMIT
    -- 첫 번째 트랜잭션의 락이 해제되어 두 번째 트랜잭션이 실행되지만 조회된 쿠폰이 없기 때문에 롤백
    ROLLBACK

NOWAIT 옵션 설정시

  async assignCouponWithPessimisticWriteLockNoWait(userId: number): Promise<{
readCoupon?: Coupon;
saveCoupon?: Coupon;
error?: string;
}> {
const queryRunner = this.dataSource.createQueryRunner();
await queryRunner.startTransaction();

let coupon;
let saveCoupon;
let error;

try {
coupon = await queryRunner.manager
.createQueryBuilder(Coupon, 'coupon')
.where('coupon.isRedeemed = :isRedeemed', { isRedeemed: false })
.setLock('pessimistic_write')
.setOnLocked('nowait')
.orderBy('coupon.id', 'ASC')
.getOne();

if (!coupon) {
throw new Error('No available coupon');
}

coupon.isRedeemed = true;
coupon.userId = userId;

saveCoupon = await queryRunner.manager.save(coupon);

await queryRunner.commitTransaction();
} catch (e) {
await queryRunner.rollbackTransaction();
error = e.message;
} finally {
await queryRunner.release();

return {
readCoupon: coupon,
saveCoupon,
error,
};
}
}
  • 실행 흐름

    1. 첫 번째 트랜잭션이 쿠폰을 조회함과 동시에 쿠폰에 대한 배타 락을 획득한다. 배타 락으로 인해, 다른 트랜잭션이 동일한 쿠폰에 접근하거나 수정할 수 없게 된다.
    2. 두 번째 트랜잭션도 동일한 쿠폰을 조회하려고 시도하지만, 첫 번째 트랜잭션이 이미 배타 락을 획득한 상태이고 NOWAIT 옵션이 설정되어 있으므로 즉시 오류가 발생하고 트랜잭션이 롤백된다.
    3. 첫 번째 트랜잭션에서 쿠폰 업데이트가 완료된 후 트랜잭션이 커밋되고 락이 해제된다.
  • 실행 쿼리

      -- 첫 번째 요청의 트랜잭션 시작
    START TRANSACTION
    -- 두 번째 요청의 트랜잭션 시작
    START TRANSACTION
    -- 첫 번째 트랜잭션에서 쿠폰을 조회하며 배타 락 획득 (getOne)
    SELECT `coupon`.`id` AS `coupon_id`, `coupon`.`code` AS `coupon_code`, `coupon`.`isRedeemed` AS `coupon_isRedeemed`, `coupon`.`user_id` AS `coupon_user_id`, `coupon`.`version` AS `coupon_version` FROM `coupon` `coupon` WHERE `coupon`.`isRedeemed` = ? ORDER BY `coupon`.`id` ASC FOR UPDATE NOWAIT -- PARAMETERS: [false]
    -- 두 번째 트랜잭션에서 쿠폰을 조회하며 배타 락을 획득하려하지만 이미 배타 락이 걸려있고 `NOWAIT` 옵션이 설정되어 있으므로 즉시 오류 발생
    SELECT `coupon`.`id` AS `coupon_id`, `coupon`.`code` AS `coupon_code`, `coupon`.`isRedeemed` AS `coupon_isRedeemed`, `coupon`.`user_id` AS `coupon_user_id`, `coupon`.`version` AS `coupon_version` FROM `coupon` `coupon` WHERE `coupon`.`isRedeemed` = ? ORDER BY `coupon`.`id` ASC FOR UPDATE NOWAIT -- PARAMETERS: [false]
    -- 첫 번째 트랜잭션의 조회 쿼리 (save)
    SELECT `Coupon`.`id` AS `Coupon_id`, `Coupon`.`code` AS `Coupon_code`, `Coupon`.`isRedeemed` AS `Coupon_isRedeemed`, `Coupon`.`user_id` AS `Coupon_user_id`, `Coupon`.`version` AS `Coupon_version` FROM `coupon` `Coupon` WHERE `Coupon`.`id` IN (?) -- PARAMETERS: [4]
    -- 두 번째 트랜잭션의 롤백 (첫 번째 트랜잭션의 락 해제를 기다리지 않고 바로 오류가 발생했으므로 롤백)
    ROLLBACK
    -- 첫 번째 트랜잭션의 업데이트 및 조회 쿼리 (save)
    UPDATE `coupon` SET `isRedeemed` = ?, `user_id` = ?, `version` = `version` + 1 WHERE `id` IN (?) -- PARAMETERS: [1,7,4]
    SELECT `Coupon`.`id` AS `Coupon_id`, `Coupon`.`version` AS `Coupon_version` FROM `coupon` `Coupon` WHERE `Coupon`.`id` = ? -- PARAMETERS: [4]
    -- 첫 번째 트랜잭션 커밋
    COMMIT