데이터베이스 동시성 관련 문제에 대해 직접 테스트 코드를 작성해보고 이를 해결한 경험을 공유하겠습니다.
동시성 문제(Concurrency problem)란?
데이터베이스 동시성 문제(concurrency issue)는 여러 작업(트랜잭션)이 동시에 같은 데이터에 접근하거나 수정할 때 발생할 수 있는 문제를 일컫습니다. 이를 제대로 다루지 않으면, 데이터 무결성에 심각한 오류가 생길 수 있습니다. 동시성 문제가 발생하는 예제는 다음과 같습니다.
- A와 B라는 사람이 동시에 사용하는 공동계좌가 존재. 이때 해당 계좌에 1,000원 존재
- A가 계좌에서 500원을 출금 시도
- 동시에 B도 계좌에서 500원을 출금 시도
- 일반적으로 생각했을 때 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이 걸립니다. 즉, 다른 트랜잭션에서 조회만 허용하고 이외의 수정, 삭제는 허용하지 않는 것입니다. 이때 아래와 같은 문제 상황이 발생할 수 있습니다.
- 첫번째 트랜잭션이 row를 조회
- 두번째 트랜잭션도 row를 조회 (첫번째 트랜잭션이 shared lock 이기 때문에 조회 가능)
- 첫번째 트랜잭션이 수정을 시도함. 이때 shared lock이 아니라 exclusive lock이 필요. 그럼 두번째 트랙잭션의 shared lock이 해제되길 기다림
- 두번째 트랙잭션이 수정을 시도함. 이때 shared lock이 아니라 exclusive lock이 필요. 그럼 첫번째 트랙잭션의 shared lock이 해제되길 기다림
- 서로 계속 기다림 --> 데드락 발생
방법 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 |
'데이터베이스, ORM' 카테고리의 다른 글
RDB에서 외래키 사용하지 않는 이유? (0) | 2025.03.17 |
---|---|
[Redis] Master/Slave 구조와 사용 방법 (0) | 2024.11.02 |