🗃️ 내가 다시 볼 것

락을 통해 동시성 문제 해결(TypeORM)

전호영 2024. 2. 8. 17:11

축구팀 관리 프로젝트에서 토너먼트를 구현중에 있다.

토너먼트의 경우 선착순으로 입력을 받도록 구현했다. 

여기서 걸리는 부분은 동시성 문제였다. 

여러 사람이 동시에 신청을 하게되면 다음과 같은 문제가 생길 것 같았다.

 

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 

https://velog.io/@bagt/Database-%EB%82%99%EA%B4%80%EC%A0%81-%EB%9D%BD-%EB%B9%84%EA%B4%80%EC%A0%81-%EB%9D%BD