축구팀 관리 프로젝트에서 토너먼트를 구현중에 있다.
토너먼트의 경우 선착순으로 입력을 받도록 구현했다.
여기서 걸리는 부분은 동시성 문제였다.
여러 사람이 동시에 신청을 하게되면 다음과 같은 문제가 생길 것 같았다.
1. 동시에 참가하는 경우
- 두 팀이 거의 동시에 신청을 하고, 토너먼트에 자리가 하나만 남아있다면, 신청된 팀의 수가
토너먼트 참가 제한 수보다 많아질 수 있다.
2. 동시에 취소하는 경우
- 동시에 여러 팀이 참가를 취소하는 경우, 참가 팀의 수가 올바르게 바뀌지 않을 수 있다.
3. 동시에 토너먼트 정보 수정
- 현재 우린 어드민만 수정을 할 수 있도록 변경했다. 만약 여러 어드민이 토너먼트 정보를 수정할 경우, 마지막 쓰기만
반영이 되고, 이전 수정 사항은 무시될 수 있다.
동시성 문제를 해결하기 위한 방법으로 Lock이 있고, 이를 적용해 동시성 문제를 처리하려 한다.
(코드 예시는 동시에 참가하는 경우에만 적용시켰다.)
먼저 Lock에는 두 가지가 있다.
1. 비관적 락(Pessimistic lock)
- 충돌이 자주 발생하는 경우 사용
- 데이터 쓰기, 수정하기 전 lock을 획득해 다른 사용자의 접근 제어. -> 데이터 일관성 보장
- 트랜잭션이 시잘될 때 락을 걸고 시작함.
- 자원에 lock을 걸고 이를 유지하는 동안 리소스가 더 쓰인다.
- 데드락에 걸릴 수 있음.
- 응답 시간이 늦어져, 사용자 경험이 안좋을 수 있음.
코드 예시)
async applyTournament(tournamentId: number, teamId: number) {
// 토너먼트 정보 가져오기
return await this.entityManager.transaction(async (manager) => {
const tournament = await manager
.createQueryBuilder(TournamentModel, 'tournament')
.setLock('pessimistic_write')
.leftJoinAndSelect('tournament.teams', 'teams')
.where('tournament.id = :id', { id: tournamentId })
.getOne();
if (!tournament) {
await this.loggingService.warn(
`존재하지 않는 토너먼트 아이디 ${tournamentId}에 신청`,
);
return '토너먼트가 존재하지 않습니다.';
}
// 신청 마감일 확인
if (tournament.registerDeadline < new Date()) {
return '신청 마감일이 지났습니다.';
}
// 신청 가능한지 확인
if (tournament.isFinished) {
return '신청이 마감 된 토너먼트입니다.';
}
// 여석이 있는지 확인
if (tournament.teams.length >= tournament.teamLimit) {
tournament.isFinished = true;
await this.tournamentRepository.save(tournament);
return '신청이 마감 된 토너먼트입니다.';
}
// 팀이 이미 신청했는지 확인
if (tournament.teams.find((team) => team.id === teamId)) {
return '이미 신청한 팀입니다.';
}
// 신청하기
const team = await this.teamRepository.findOne({
where: { id: teamId },
});
if (!team) {
throw new Error('존재하지 않는 팀입니다.');
}
tournament.teams.push(team);
await this.tournamentRepository.save(tournament);
return '신청이 완료되었습니다.';
});
}
2. 낙관적 락 (Optimistic lock)
- 기본적으로 데이터가 동시에 수정되지 않는다고 가정
- 접근을 막는 것은 아님
- 동시성 문제가 발생할 가능성이 적은 경우에 사용
- 낙관적 락은 충돌이 자주 발생하지 않는 환경에 유리
- 락을 장시간 사용하지 않으므로, 시스템 성능에 큰 문제를 주진 않는다.
- 충돌이 자주 발생하는 경우, 사용자 경험이 저하될 수 있음.
- TypeORM에선 version 을 통해 확인
- typeorm의 경우 save() 메서드를 사용하면 엔티티의 버전을 확인하기에, 다른 트랜젝션이 이미 실행되어 버전이 변경된 경우,
에러를 반환함.
코드 예시)
async applyTournament(tournamentId: number, teamId: number) {
// 토너먼트 정보 가져오기
const tournament = await this.tournamentRepository.findOne({
where: { id: tournamentId },
relations: ['teams'],
});
if (!tournament) {
await this.loggingService.warn(`존재하지 않는 토너먼트 아이디 ${tournamentId}에 신청`);
return '토너먼트가 존재하지 않습니다.';
}
try {
// 신청 마감일 확인
if (tournament.registerDeadline < new Date()) {
return '신청 마감일이 지났습니다.';
}
// 신청 가능한지 확인
if (tournament.isFinished) {
return '신청이 마감 된 토너먼트입니다.';
}
// 여석이 있는지 확인
if (tournament.teams.length >= tournament.teamLimit) {
tournament.isFinished = true;
await this.tournamentRepository.save(tournament);
return '신청이 마감 된 토너먼트입니다.';
}
// 팀이 이미 신청했는지 확인
if (tournament.teams.find((team) => team.id === teamId)) {
return '이미 신청한 팀입니다.';
}
// 신청하기
const team = await this.teamRepository.findOne({
where: { id: teamId },
});
tournament.teams.push(team);
await this.tournamentRepository.save(tournament);
return '신청이 완료되었습니다.';
} catch (error) {
if (error instanceof OptimisticLockVersionMismatchError) {
return '다시 시도해주세요.';
}
return '서버가 힘들어요 :( 다시 시도해주세요.';
}
}
현재 동시성 처리를 하려는 곳은 선착순 신청에 관한 부분이다.
선착순 신청은 당연히 동시 수정이 많이 발생하므로, 비관적 락을 사용해 동시성 문제를 해결했다.
참고
https://orkhan.gitbook.io/typeorm/docs/select-query-builder#lock-modes