데이터베이스, ORM

동시성 문제 직접 테스트해보고 이해하자 - 비관적 락 / 낙관적 락

SparkIT 2025. 4. 14. 21:59

데이터베이스 동시성 관련 문제에 대해 직접 테스트 코드를 작성해보고 이를 해결한 경험을 공유하겠습니다.

 

동시성 문제(Concurrency problem)란?

데이터베이스 동시성 문제(concurrency issue)는 여러 작업(트랜잭션)이 동시에 같은 데이터에 접근하거나 수정할 때 발생할 수 있는 문제를 일컫습니다. 이를 제대로 다루지 않으면, 데이터 무결성에 심각한 오류가 생길 수 있습니다. 동시성 문제가 발생하는 예제는 다음과 같습니다.

  1. A와 B라는 사람이 동시에 사용하는 공동계좌가 존재. 이때 해당 계좌에 1,000원 존재
  2. A가 계좌에서 500원을 출금 시도
  3. 동시에 B도 계좌에서 500원을 출금 시도
  4. 일반적으로 생각했을 때 A가 출금한 500원과 B가 출금한 500원이 합쳐져 1,000 - 500 - 500 = 0으로 계좌에 0원이 존재해야할 것 같지만 실제로는 500원 존재하는 문제 발생

이런 문제가 왜 발생할까요? 이게 바로 동시성 문제입니다. 위에서 2,3번에 A와 B가 동시에 계좌에 작업을 시도했기 때문에 발생한 문제입니다. 만약 2번 3번이 순서대로 진행되었다면  1,000 - 500 = 500, 그리고 500 - 500 = 0이 되었을 것입니다. 하지만 2번 3번이 동시에 진행된다면 1,000 - 500 = 500, 1,000 - 500 = 500이 적용되기 때문에 공동계좌에는 0원이 아니라 500원이 존재하게됩니다.

 

 

NestJS에서 동시성 문제 테스트 코드 작성하기

먼저 NestJS에서 테스트를 진행했고, ORM은 TypeORM을 사용했습니다. 데이터베이스에는 test라는 테이블을 만들었고, 해당 테이블에는 id, cost, name 총 3개의 컬럼을 만들었습니다. 이때 특정 데이터를 선택한 후 cost 부분을 업데이트하는 코드를 작성해보았습니다.

// Test 엔티티

@Entity()
export class Test {
    @PrimaryGeneratedColumn({ type: 'int' })
    id: number;

    @Column({ type: 'int' })
    cost: number;

    @Column({ type: 'varchar', length: 255 })
    name: string;
}
// 서비스 코드

async updateTest() {
    const test = await this.testRepository.findOne({ where: { id: 1 } });
    test.cost += 50;
    await this.testRepository.save(test);
}

async concurrencyProblem() {
    await Promise.all([this.updateTest(), this.updateTest()]);
}

// 여기서 concurrencyProblem 메서드를 실행할 것임

해당 코드는 다음과 같습니다.  concurrencyProblem 메서드를 실행하면 updateTest 메서드를 비동기적으로 바로바로 실행합니다. updateTest 메서드는 먼저 id가 1인 데이터를 조회합니다. 그리고 해당 데이터의 cost를 +50한 후 데이터베이스에 반영합니다. 이때 동시성 문제가 발생합니다.

concurrencyProblem 메서드 실행 후 데이터베이스

  id cost name
실행 전 1 0 홍길동
⬇️
실행 후 1 50 (❗️동시성 문제 발생. 100이 아님) 홍길동

이를 해결하기 위해 아래와 같이 여러 방법을 시도해보았습니다.

 

방법1 ) 비관적 락 적용하기 - pessimistic_write

async updateTest() {
    await this.dataSource.transaction(async (manager) => {
        const test = await manager.findOne(Test, {
            where: { id: 1 },
            lock: { mode: 'pessimistic_write' }, // 비관적 락 적용하기
        });

        test.cost += 50;

        await manager.save(test);
    });
}

async concurrencyProblem() {
    await Promise.all([this.updateTest(), this.updateTest()]);
}

위와 같이 비관적 락을 적용하면 하나의 트랜잭션이 해당 데이터에 대한 작업을 진행할 때 또다른 트랜잭션은 데이터에 접근할 수 없게 막습니다. 정확히 말하자면 pessimistic_write을 사용했기 때문에 다른 트랜잭션의 접근을 모두 막습니다. pessimistic_wirte 사용 시 쿼리를 확인해보면 `SELECT ... FOR UPDATE` 문이 사용되어 exclusive lock이 걸리기 때문입니다. 즉, 처음 트랜잭션이 커밋되기 전까지 두번째 트랜잭션은 무조건 대기하게 되어 데드락을 피합니다.

concurrencyProblem 메서드 실행 후 데이터베이스 : 성공

  id cost name
실행 전 1 0 홍길동
⬇️
실행 후 1 100 홍길동

 

 

방법2 ) 비관적 락 적용하기 - pessimistic_read

async updateTest() {
    await this.dataSource.transaction(async (manager) => {
        const test = await manager.findOne(Test, {
            where: { id: 1 },
            lock: { mode: 'pessimistic_read' }, // 비관적 락 적용하기
        });

        test.cost += 50;

        await manager.save(test);
    });
}

async concurrencyProblem() {
    await Promise.all([this.updateTest(), this.updateTest()]);
}

만약 pessimistic_write를 사용하지 않고 pessimistic_read를 사용하면 어떻게 될까요? 몇번의 테스트 결과 가끔 이런 데드락이 발생하는 것을 확인할 수 있었습니다.

concurrencyProblem 메서드 실행 후 에러 로그 : 실패

[Nest] 62629  - 04/14/2025, 8:02:21 PM   ERROR [ExceptionsHandler] Deadlock found when trying to get lock; try restarting transaction
QueryFailedError: Deadlock found when trying to get lock; try restarting transaction

 pessimistic_read 사용 시 쿼리를 확인해보면 `SELECT ... FOR SHARE` 문이 사용되어 shared lock이 걸립니다. 즉, 다른 트랜잭션에서 조회만 허용하고 이외의 수정, 삭제는 허용하지 않는 것입니다. 이때 아래와 같은 문제 상황이 발생할 수 있습니다.

  1. 첫번째 트랜잭션이 row를 조회
  2. 두번째 트랜잭션도 row를 조회 (첫번째 트랜잭션이 shared lock 이기 때문에 조회 가능)
  3. 첫번째 트랜잭션이 수정을 시도함. 이때 shared lock이 아니라 exclusive lock이 필요. 그럼 두번째 트랙잭션의 shared lock이 해제되길 기다림
  4. 두번째 트랙잭션이 수정을 시도함. 이때 shared lock이 아니라 exclusive lock이 필요. 그럼 첫번째 트랙잭션의 shared lock이 해제되길 기다림
  5. 서로 계속 기다림 --> 데드락 발생

 

 

방법 3 ) 낙관적 락 적용하기

먼저 낙관적 락을 사용하기 위해 데이터베이스 테이블에 @VersionColumn이라는 컬럼을 추가합니다.

// 버전 추가된 엔티티

@Entity()
export class Test {
	@PrimaryGeneratedColumn({ type: 'int' })
	id: number;

	@Column({ type: 'int' })
	cost: number;

	@Column({ type: 'varchar', length: 255 })
	name: string;

	@VersionColumn() // 버전 컬럼 추가
	version: number;
}
async updateTest() {
    while (true) {
        try {
            await this.dataSource.transaction(async (manager) => {
                const test = await manager.findOne(Test, {
                    where: { id: 1 },
                });
                const currentVersion = test.version;

                test.cost += 50;

                const result = await manager.save(test);

                if (currentVersion + 1 !== result.version) {
                    throw new Error('version conflict');
                }
            });
            break;
        } catch (error) {
            console.log(error.message);
        }
    }
}

async concurrencyProblem() {
    await Promise.all([this.updateTest(), this.updateTest()]);
}

낙관적 락 방법은 데이터 자체에 버전이라는 정보를 추가하여, 해당 트랜잭션이 작업하는 동안 버전이 유지되었는지를 체크하는 방법입니다. 즉 A 트랜잭션이 작업한 후 해당 내용을 DB에 반영하려고 보니, 그 사이에 B 트랜잭션이 작업해 DB 내용에 작업하여 버전 정보가 달라져있을 수 있습니다. 이럴 경우 트랜잭션 내에서 에러를 발생시킵니다. 그러면 자동으로 해당 트랜잭션은 롤백됩니다.

그리고 버전이 다를 경우 롤백만 할 것이 아니라 작업을 재시도하려는 상황이 많을 것입니다. 이때 다시 작업을 진행하려는 로직은 직접 작성해줘야합니다. 위 예시 코드에서는 while 문으로 버전에 문제가 없을 때까지 무한히 시도하게 했습니다.(실제 비즈니스 상황에서는 위험한 작업일 수 있음)

concurrencyProblem 메서드 실행 후 데이터베이스 : 성공

  id cost name version
실행 전 1 0 홍길동 1
⬇️  
실행 후 1 100 홍길동 3