3주 차 기업 과제에서 내 담당은 게임 시작 기능을 구현하는 것이었다. 요구사항으로는 여러 사람이 동시에 게임을 시작할 수 없고 동시 요청이 있는 경우에도 단 한 사람만 게임을 시작할 수 있다는 것이었다. 또한 어떤 유저가 게임을 진행 중인 경우에도 게임을 시작할 수 없도록 해야 했다.
동시성에 대해선 약간의 개념만을 갖고 있는 상태였고 실제 프로젝트를 통해서 동시성 처리를 해본 적이 없었다. 이번 과제에서 여러 유저가 동시에 게임에 입장하려고 해도 요구사항과 같이 단 한 명만 게임에 입장하도록 해야 했기 때문에 동시성 처리가 필수적이었다.
워낙 잘 알려진 주제라 다른 사람이 쓴 글 을 참고했다. 덕분에 동시성에 대한 개념이 명확해진 거 같다.
** 참고
비관적 잠금과 낙관적 잠금
Solve Database Concurrency Issues with TypeOrm
JPA 비관적 잠금(Pessimistic Lock)
결과적으론 동시성 처리를 위해 DB 데이터에 Lock을 거는 방식으로 동시성을 처리했다. 우선 게임 시작 기능 트랜잭션의 로직을 요약하면 다음과 같다.
async startRaid(userId: number, level: number) {
await queryRunner.startTransaction(); // 트랜잭션 시작
let result;
try {
result = await queryRunner.manager
.createQueryBuilder(RaidRecord, 'raidRecord')
.setLock('pessimistic_read')
.leftJoinAndSelect('raidRecord.user', 'user')
.orderBy('id', 'DESC')
.getOne() // 입장 가능 여부 조회 쿼리에 비관적 락을 건다.
.then(async (record) => {
const { canEnter } = await RaidStatusDto.of(record);
if (!canEnter)
throw new ConflictException(
{ isEntered: false },
'레이드에 입장할 수 없습니다.',
); // 게임 시작이 불가능한 경우 예외 처리
const raidRecord = new RaidRecord();
queryRunner.manager.save(raidRecord);
// 레이드 엔터티 생성 및 DB에 저장
return {
isEntered: true,
raidRecordId: createdRecord.id,
}; // 결과값 반환
});
await queryRunner.commitTransaction(); // 커밋
} catch (err) {
// 동시 요청 중 어떤 A요청이 락을 걸면
// 이후의 요청 B, C, D ... 등이 데드락에 걸리는 경우가 있었음
await queryRunner.rollbackTransaction();
throw new ConflictException(
{ isEntered: false },
'레이드에 입장할 수 없습니다.',
);
}
return result;
}
먼저 레이드(게임) 시작 메서드가 실행되면 입장 가능 여부에 대해 쿼리를 날리는데 이 경우 락을 걸지 않으면 여러 시작 요청이 DB에 Insert 되는 것을 확인할 수 있었다. 이는 여러 사람이 게임을 시작하게 된 경우이므로 그렇기 때문에 A 요청이 먼저 락을 잡고 트랜잭션을 끝낼 때까지 다른 요청들이 락을 얻지 못하도록 했다. 이 경우 크게 두 가지 상황을 마주할 수 있었다.
데드락이 발생하지 않은 경우
데드락이 발생한 경우
때문에 두 가지 경우 모두 동시 사용자 중 한 명만 게임을 시작할 수 있도록 결과를 얻을 수 있었다.
동시성 처리에 대한 경험을 할 수 있어서 너무 좋았다. 추후에 좋아요 기능, 조회 수 기능과 같은 기능에도 적용할 수 있을 거 같다. 다만 데드락에 대해 예방할 수 있는 방법을 조금 더 고민해 보고 싶다. 트랜잭션 내에서 에러 처리를 통해 애플리케이션이 무너지진 않았지만 데드락이 발생한다는 것이 좋은 현상만은 아닌 것 같다. 또 처음 동시성 처리를 위해 트랜잭션 격리 단계를 Serializable로 상향하려 했었는데 이 경우도 한 번 테스트해 봐야겠다. 또 Lock과 트랜잭션 격리 단계의 명확한 차이를 공부해야겠다. 둘의 개념이 명확히 구분되지 않은 상태이다.