본문 바로가기
백엔드

낙관적 락, 데드락, 비관적 락

by hseong 2023. 12. 10.

애니프렌즈 프로젝트는 봉사 모집 기능을 통해 봉사자들이 봉사 신청을 수행할 수 있습니다. 이 때, 각 봉사는 제한 인원이 정해져 있기 때문에 여러 인원이 한 번에 봉사 신청을 수행할 경우 제한 인원보다 많은 인원이 봉사 신청을 수행할 수 있습니다. 이러한 동시성 문제를 해결하기 위해서는 낙관적 락 또는 비관적 락을 이용해서 적절하게 처리해 줄 필요가 있습니다.

저희 팀은 봉사 신청이 몰리는 인기 보호소가 있을 것으로 가정하였습니다. 따라서 빈번한 트랜잭션 충돌이 발생할 것이고, 낙관적 락을 사용하는 경우 서로 다른 트랜잭션이 공유락과 배타락을 획득하는 과정에서 데드락이 발생할 것이다라고 예측하였습니다. 다만 아직 데드락이 발생하는 상황을 직접 확인해 본 적이 없었기에 직접 두 눈과 손으로 확인해보고자 낙관적 락과 비관적 락 모두 구현해보기로 하였습니다.

낙관적 락과 데드락

낙관적 락은 애플리케이션 단에서 처리하는 잠금입니다. 이전에 진행했던 프로젝트에서 라이더가 하나의 배차에 대해 동시에 배차 신청을 하는 상황을 해결하기 위해 낙관적 락을 적용한 바 있습니다. 낙관적 락과 테스트 방법에 대한 정리는 낙관적 락과 동시성 테스트하기를 통해 확인하실 수 있습니다.

낙관적 락을 이용하기 위해서는 봉사 엔티티인 Recruitment@Version을 가진 version 필드를 추가하고 엔티티 조회에 사용할 메서드에 @Lock 을 추가해주어야 합니다. 낙관적 락의 경우 엔티티가 변경된 경우 version이 자동으로 업데이트 됩니다. 그러나 현재의 로직에서는 조회한 Recruitment가 별도로 변경되지 않으므로 version을 강제로 업데이트하는 @Lock(LockModeType.OPTIMISTIC_FORCE_INCREMENT)을 이용하도록 하겠습니다.

@Transactional  
@DataIntegrityHandler(message = "이미 신청한 봉사입니다.", exceptionClass = ApplicantConflictException.class)  
public void registerApplicant(Long recruitmentId, Long volunteerId) {  
    Volunteer volunteer = getVolunteer(volunteerId);  
    while (true) {  
        try {  
            applicantLockService.registerApplicantOptimistic(volunteer, recruitmentId);  
            break;  
        } catch (ObjectOptimisticLockingFailureException e) {  
            log.info("충돌이 발생했습니다. 재시도합니다.");  
        }  
    }  
}

@Transactional(propagation = Propagation.REQUIRES_NEW)  
public void registerApplicantOptimistic(Volunteer volunteer, Long recruitmentId) {  
    Recruitment recruitment = getRecruitment(recruitmentId);  
    Applicant applicant = new Applicant(recruitment, volunteer);  
    applicantRepository.save(applicant);  
}  

private Recruitment getRecruitment(Long recruitmentId) {  
    return recruitmentRepository.findOptimistic(recruitmentId)  
        .orElseThrow(() -> new RecruitmentNotFoundException("못 찾음"));  
}

그러나 낙관적 락을 사용하는 봉사 신청 기능을 구현하고 테스트를 실행하였을 때 기대했던대로 데드락이 발생하였습니다.

@Test  
@DisplayName("성공: 2명 정원, 5명 동시 신청")  
void registerApplicantWhenRegisterWith5In2Capacity() throws InterruptedException {  
    //gvien  
    int capacity = 2;  
    int poolSize = 5;  
    List<Volunteer> volunteers = VolunteerFixture.volunteers(poolSize);  
    Recruitment recruitment = RecruitmentFixture.recruitment(shelter, capacity); 
    volunteerRepository.saveAll(volunteers);  
    recruitmentRepository.save(recruitment);  
    ExecutorService executorService = Executors.newFixedThreadPool(poolSize);  
    CountDownLatch latch = new CountDownLatch(poolSize);  

    //when  
    for (int i = 0; i < poolSize; i++) {  
        int finalI = i;  
        executorService.submit(() -> {  
            try {  
                applicantService.registerApplicant(shelter.getShelterId(),  
                    volunteers.get(finalI).getVolunteerId());  
            } finally {  
                latch.countDown();  
            }  
        });  
    }  
    latch.await();  

    //then  
    Recruitment findRecruitment = entityManager.find(Recruitment.class,  
        recruitment.getRecruitmentId());  
    List<Applicant> findApplicants = getApplicants(recruitment);  
    assertThat(findRecruitment.getApplicantCount()).isEqualTo(capacity);  
    assertThat(findApplicants).hasSize(capacity);  
}

어째서 데드락이 걸리는지는 MySQL 공식 문서 15.7.3 Locks Set by Different SQL Statements in InnoDB를 참고하면 이해할 수 있었습니다.

해당 문서에서는 외래키 제약 조건이 정의되어 있는 경우, 제약 조건을 확인해야 하는 모든 insert, update, delete 작업에서는 제약 조건을 확인하기 위해 해당하는 레코드에 공유락을 설정한다고 설명합니다.

저의 구현에서는 recruitment를 참조하는 applicantinsert를 수행하며 공유락을 획득하고, recruitmentversion을 업데이트 하기 위해 배타락을 획득하려고 시도하는 상황입니다. 이러한 작업이 서로 다른 여러 개의 트랜잭션에서 동시에 이루어지면서 다른 트랜잭션이 외래키 제약 조건 확인을 위해 획득한 공유락이 해제되는 것을 기다리는 무한 대기 상태에 빠지게 되어 데드락이 발생하였습니다. 이를 그림으로 설명하면 다음과 같습니다.

또한 MySQL 터미널에서 다음 명령어를 사용하면 여러 쓰레드에서 동시에 낙관적 락을 적용한 봉사 신청 로직이 수행되는 과정에서 어떤 일이 벌어졌는지 확인할 수 있습니다.

show engine innodb status;

LATEST DETECTED DEADLOCK 부분을 확인하면 두개의 트랜잭션이 동시에 실행되는 과정에서 서로 공유락을 획득한 채로 배타락을 획득하기 위해 대기하면서 데드락이 발생했음을 확인할 수 있습니다.



비관적 락

비관적 락을 사용하기 위해서는 엔티티 조회 메서드에 @Lock(LockModeType.PESSIMISTIC_WRITE)을 추가해주기만 하면 됩니다. 콘솔을 통해 로그를 확인하면 select ... for update로 조회 쿼리가 나가는 것을 볼 수 있습니다. 이제 조회할 때부터 배타 잠금을 설정하므로 동시에 여러 개의 봉사 신청 트랜잭션이 실행되어도 무한 대기 상태에 빠지지 않을 것입니다.

@Lock(LockModeType.PESSIMISTIC_WRITE)  
@Query("select r from Recruitment r where r.recruitmentId = :recruitmentId")  
Optional<Recruitment> findByIdPessimistic(@Param("recruitmentId") Long recruitmentId);

@Transactional  
@DataIntegrityHandler(message = "이미 신청한 봉사입니다.", exceptionClass = ApplicantConflictException.class)  
public void registerApplicant(Long recruitmentId, Long volunteerId) {  
    Recruitment recruitmentPessimistic = getRecruitmentPessimistic(recruitmentId);  
    Volunteer volunteer = getVolunteer(volunteerId);  
    Applicant applicant = new Applicant(recruitmentPessimistic, volunteer);  
    recruitmentPessimistic.increaseApplicantCount();  
    applicantRepository.save(applicant);
}

2명 제한, 5명 동시 실행 테스트 뿐만 아니라 다양한 조건으로 수행해도 정상적으로 테스트가 수행되는 것을 확인할 수 있습니다.

@Test  
@DisplayName("성공: 5명 정원, 30명 동시 신청")  
void registerApplicantWhenRegisterWith5In30Capacity() throws InterruptedException {  
    //gvien  
    int capacity = 5;   
    int poolSize = 30;  
    ...

    //when  
    for (int i = 0; i < poolSize; i++) {  
        int finalI = i;  
        executorService.submit(() -> {  
            try {  
                applicantService.registerApplicant(shelter.getShelterId(),  
                    volunteers.get(finalI).getVolunteerId());  
            } finally {  
                latch.countDown();  
            }  
        });  
    }  
    latch.await();  

    //then  
    Recruitment findRecruitment = entityManager.find(Recruitment.class,  
        recruitment.getRecruitmentId());  
    List<Applicant> findApplicants = getApplicants(recruitment);  
    assertThat(findRecruitment.getApplicantCount()).isEqualTo(capacity);  
    assertThat(findApplicants).hasSize(capacity);  
}