[Springboot] 봉사 신청에서 발생한 동시성 이슈 해결하기 (feat : 낙관적 락, 비관적 락)

일단 해볼게·2023년 12월 7일
1

Springboot

목록 보기
1/26
  • 봉사 모집글이 존재하고 봉사 신청자가 존재한다.
  • 봉사 모집글과 봉사 신청자는 1:N 관계이다.
  • 봉사 모집글에 봉사 신청을 하면 applicantCount가 1씩 증가한다.
  • 봉사 모집글의 봉사 신청을 동시에 요청해도, 요청한 순서대로 봉사 신청이 되어야한다.

처음에는 낙관적 락, 비관적 락, 분산 락을 고려했다. 그러나 이 프로젝트는 서버가 분산되지 않았고, DB 또한 분산 DB가 아니기 때문에 분산 락은 배제했다.


낙관적 락

낙관적 락이란?

  • 낙관적 락은 대부분의 트랜잭션이 충돌이 발생하지 않을 것이라고 낙관적으로 가정하는 방법이다.
  • 데이터베이스가 제공하는 락 기능을 사용하지 않고, 엔티티의 version 컬럼을 통해 동시성을 제어한다.
  • 낙관적 락 도중 충돌이 발생하면 개발자가 수동으로 롤백처리를 해줘야한다.
  • 엔티티를 수정할 때(update 쿼리가 날아갈 때) 버전이 증가한다.
    • 연관관계 필드의 경우 연관관계의 주인 필드를 변경할 때에만 버전이 증가한다.
  • 엔티티를 조회한 시점의 버전과 수정한 시점의 버전이 일치하지 않으면 예외가 발생한다. (ObjectOptimisticLockingFailureException)
  • @Version으로 추가한 버전 관리 필드는 JPA가 직접 관리하므로 임의로 수정해서는 안된다.
  • 임베디드 타입값 타입 컬렉션은 실제 DB에서는 다른 테이블이지만, JPA에서는 논리적인 개념해당 엔티티에 속한 값이므로 수정하면 엔티티의 버전이 증가
  • 벌크 연산의 경우 버전을 무시하므로 버전 필드를 증가하는 부분을 추가해야한다.
    update Recruitment p set p.applicant_count = p.applicant_count + 1, p.version = p.version + 1

낙관적 락의 version은 int, Integer, short, Short, long, Long, Timestamp 타입에서 적용할 수 있다. 또한 메서드, 필드에 적용할 수 있다.

설정 방법 - 낙관적 락 (데드락 발생)

public class Recruitment extends BaseTimeEntity {

    public static final boolean IS_CLOSED_DEFAULT = false;
    public static final int MAX_IMAGE_SIZE = 5;

    @Id
    @Column(name = "recruitment_id")
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long recruitmentId;

    @Version
    private Long version;

    @OneToMany(mappedBy = "recruitment", fetch = FetchType.LAZY, cascade = CascadeType.REMOVE)
    private List<Applicant> applicants = new ArrayList<>();

...
}

Recruitment 엔티티에 버전을 추가한다.

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

ApplicantService에 while문, try-catch문 안에 봉사 신청 로직을 추가하고 충돌 발생 여부를 로그로 확인한다.

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

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

OptimisticLockQuantityService에 registerApplicantOptimistic을 구현해 트랜잭션을 분리한다.

@Transactional(propagation = Propagation.REQUIRES_NEW)은 트랜잭션이 존재하는 경우 해당 트랜잭션을 잠시 보류시키고, 신규 트랜잭션을 생성한다. (트랜잭션을 분리하지 않을 경우 영속성 컨텍스트를 공유하므로 충돌로 인해 재시도를 할 때 갱신된 버전의 recruitment 가 아닌 1차 캐시에 존재하는 recruitment 를 불러오게 되어 정상적으로 동작하지 않는다.)

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

RecruitmentRepository에 findOpti를 구현해 낙관적 락을 건다. OPTIMISTIC_FORCE_INCREMENT은 낙관적 락을 사용하면서 version을 강제로 증가하는 옵션이다.(따로 해주지 않아도 동작함)

@Test
@DisplayName("성공: 30명 정원, 30명 동시 신청")
void registerApplicantWhenRegisterWith30In30CapacityV2() throws InterruptedException {
    //given
    int capacity = 30;
    List<Volunteer> volunteers = VolunteerFixture.volunteers(capacity);
    Recruitment recruitment = RecruitmentFixture.recruitment(shelter, capacity);
    volunteerRepository.saveAll(volunteers);
    recruitmentRepository.save(recruitment);

    int poolSize = 30;
    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.registerApplicantWithOptimisticLockV2(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);
}

“30명 정원, 30명 동시 신청”하는 테스트 코드를 작성해 실행했다.

  • ExecutorService
    • 등록된 작업(Runnable)을 실행하기 위한 인터페이스
    • 작업 실행만을 책임진다.
    • 비동기 작업의 진행을 추적할 수 있도록 Future를 반환한다. 반환된 Future들은 모두 실행된 것이므로 반환된 isDone은 true이다.
    • submit()
      • 실행할 작업들을 추가하고, 작업의 상태와 결과를 포함하는 Future를 반환
      • Future의 get을 호출하면 성공적으로 작업이 완료된 후 결과를 얻을 수 있음
  • Executors
    • 쓰레드를 다루는 것을 도와주는 팩토리 클래스
    • newFixedThreadPool()
      • 고정된 쓰레드 개수를 갖는 쓰레드 풀을 생성함
      • ExecutorService 인터페이스를 구현한 ThreadPoolExecutor 객체가 생성됨
  • CountDownLatch
    • 어떤 쓰레드가 다른 쓰레드에서 작업이 완료될 때 까지 기다릴 수 있도록 해주는 클래스
    • countDownLatch의 쓰레드가 완료된 후 메인 쓰레드가 실행된다.
    • latch.countDown()
      • Latch의 숫자가 1씩 감소
    • latch.await()
      • Latch의 숫자가 0이 될 때까지 기다림
Deadlock found when trying to get lock; try restarting transaction

데드락이 걸린다.

데드락은 왜 발생할까?

MySQL에 접속해서 show engine innodb status; 로 확인해봤다.

2023-12-06 23:34:02 0x16d96b000
*** (1) TRANSACTION:
TRANSACTION 3032073, ACTIVE 0 sec starting index read
mysql tables in use 1, locked 1
LOCK WAIT 7 lock struct(s), heap size 1128, 3 row lock(s), undo log entries 1
MySQL thread id 424, OS thread handle 10909495296, query id 8778 localhost 127.0.0.1 root updating
update recruitment set applicant_count=1,content='recruitmentContent',capacity=30,deadline='2024-01-05 14:34:02.525494',end_time='2024-01-06 16:34:02.525494',is_closed=0,start_time='2024-01-06 14:34:02.525494',shelter_id=4,title='recruitmentTitle',updated_at='2023-12-06 14:34:02.672198',version=1 where recruitment_id=4 and version=0

*** (1) HOLDS THE LOCK(S):
RECORD LOCKS space id 10256 page no 4 n bits 72 index PRIMARY of table `anifriends`.`recruitment` trx id 3032073 lock mode S locks rec but not gap
Record lock, heap no 5 PHYSICAL RECORD: n_fields 15; compact format; info bits 0
...

*** (1) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 10256 page no 4 n bits 72 index PRIMARY of table `anifriends`.`recruitment` trx id 3032073 lock_mode X locks rec but not gap waiting
Record lock, heap no 5 PHYSICAL RECORD: n_fields 15; compact format; info bits 0
...

*** (2) TRANSACTION:
TRANSACTION 3032096, ACTIVE 0 sec starting index read
mysql tables in use 1, locked 1
LOCK WAIT 7 lock struct(s), heap size 1128, 3 row lock(s), undo log entries 1
MySQL thread id 426, OS thread handle 10911723520, query id 8805 localhost 127.0.0.1 root updating
update recruitment set applicant_count=1,content='recruitmentContent',capacity=30,deadline='2024-01-05 14:34:02.525494',end_time='2024-01-06 16:34:02.525494',is_closed=0,start_time='2024-01-06 14:34:02.525494',shelter_id=4,title='recruitmentTitle',updated_at='2023-12-06 14:34:02.67714',version=1 where recruitment_id=4 and version=0

*** (2) HOLDS THE LOCK(S):
RECORD LOCKS space id 10256 page no 4 n bits 72 index PRIMARY of table `anifriends`.`recruitment` trx id 3032096 lock mode S locks rec but not gap
Record lock, heap no 5 PHYSICAL RECORD: n_fields 15; compact format; info bits 0
lock mode S locks rec but not gap Record lock
lock_mode X locks rec but not gap waiting Record lock

S-lock

  • 공유락(Shared Lock)이라고 하며, 다른 트랜잭션이 읽을 수는 있지만 쓸 수는 없다.
  • 한 리소스에 여러 s-lock을 설정할 수 있다.

X-lock

  • 배타 락(Exclusive Lock)이라고 하며, 다른 트랜잭션은 읽을 수도 쓸 수도 없다.
  • 한 리소스에 하나의 x-lock만 설정 가능하다.

DB 락이 걸린 이유

MySQL 8.0 공식 문서에 답이 나와있다.

s-lock

💡 If a `FOREIGN KEY` constraint is defined on a table, any insert, update, or delete that requires the constraint condition to be checked sets shared record-level locks on the records that it looks at to check the constraint. `InnoDB` also sets these locks in the case where the constraint fails.
  • fk가 있는 테이블에서, fk를 포함한 데이터를 insert, update, delete 하는 쿼리는 제약조건을 확인하기 위해 s-Lock을 설정한다.

x-lock

💡 [UPDATE ... WHERE ...] sets an exclusive next-key lock on every record the search encounters. However, only an index record lock is required for statements that lock rows using a unique index to search for a unique row.
  • 데이터를 수정할 때 x-lock을 획득한다. version이 변경되는 update 쿼리로 인해 x-lock이 발생한다.

핵심은 이 부분이다.

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

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

트랜잭션 1과 트랜잭션 2가 동시에 실행된다고 가정한다.

  1. 트랜잭션 1 : applicantRepository.save(applicant)에서 Recruitment에 s-lock 을 획득한다.
  2. 트랜잭션 2 : Recruitment에 s-lock이 걸린 상태에서 applicantRepository.save(applicant)를 실행해 Recruitment에 s-lock을 획득한다.
  3. 트랜잭션 1 : save 후 version을 update하는 쿼리에서 x-lock을 획득하려고 시도했지만 트랜잭션 2에 s-lock이 걸려있어 대기한다.
  4. 트랜잭션 2 : save 후 version을 update하는 쿼리에서 x-lock을 획득하려고 시도했지만 트랜잭션 1에 s-lock이 걸려있어 대기한다.

비관적 락

  • 비관적 락은 대부분의 트랜잭션이 충돌이 발생할 것 으로 비관적으로 가정하는 방법이다.
  • 데이터베이스가 제공하는 락 기능을 사용한다. (엔티티의 version 컬럼을 사용하지 않는다.)
  • 충돌이 발생하면 트랜잭션이 롤백된다.
  • select ~ for update 쿼리가 실행된다.
    • 동시성 제어를 위해 특정 데이터에 x-lock을 건다.
    • 해당 작업이 수행되는 동안 나머지 트랜잭션은 어떠한 작업도 수행할 수 없다.

설정 방법 - 비관적 락 (채택한 방법)

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

RecruitmentRepository에 비관적 락을 적용한다.

PESSIMISTIC_WRITE : 데이터베이스에 쓰기 락

public Applicant(
        Recruitment recruitment,
        Volunteer volunteer
) {
    validateRecruitment(recruitment);
    validateVolunteer(volunteer);
    validateApplicantCount(recruitment);
    recruitment.increaseApplicantCount();
    this.recruitment = recruitment;
    this.volunteer = volunteer;
    recruitment.addApplicant(this);
    volunteer.addApplicant(this);
}

비관적 락은 increaseApplicantCount()를 Applicant 생성자에 넣었다.

@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);
    applicantRepository.save(applicant);
    
    if (recruitmentPessimistic.isFullApplicants()) {
        shelterNotificationRepository.save(
            makeClosedRecruitmentNotification(recruitmentPessimistic));
    }
}

private Recruitment getRecruitmentPessimistic(Long recruitmentId) {
    return recruitmentRepository.findByIdPessimistic(recruitmentId)
        .orElseThrow(() -> new RecruitmentNotFoundException("존재하지 않는 봉사 모집글입니다."));
}

OptimisticLockQuantityService의 getRecruitmentPessimistic()이 실행될 때 x-lock이 걸린 상태에서 기능이 수행되고 난 뒤에 Applicant의 생성자에 있는 increaseApplicantCount()가 실행된다.

@Test
@DisplayName("성공: 10명 정원, 30명 동시 신청")
void registerApplicantWhenRegisterWith30In10Capacity() throws InterruptedException {
    //gvien
    int capacity = 10;
    List<Volunteer> volunteers = VolunteerFixture.volunteers(capacity);
    Recruitment recruitment = RecruitmentFixture.recruitment(shelter, capacity);
    volunteerRepository.saveAll(volunteers);
    recruitmentRepository.save(recruitment);

    int poolSize = 30;
    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);
}

“10명 정원, 30명 동시 신청”하는 테스트 코드에서 registerApplicant() 함수로 바꿔 실행했다.

성공!

낙관적 락이 걸려 select ~ for update 쿼리를 볼 수 있다.

50명 정원, 60명 동시 신청인 경우 ngrinder로 테스트했다.

약 1.8초가 걸린다. (AWS EC2 t2.small, AWS RDS t1.micro)

유명인이 다녀간 보호소인 경우 봉사 신청이 몰리는 케이스를 있기 때문에 비관적 락을 채택했다. “트래픽이 적은 보호소의 봉사 신청은 낙관적 락을 이용하고 트래픽이 몰리는 보호소의 봉사 신청은 비관적 락으로 적용하면 되지 않을까?” 생각이 든다. 트래픽을 마주했을 때 팀원들과 고민해봐야겠다.


트러블 슈팅

Connection is not available, request timed out after 30000ms.

DB 커넥션 풀 디폴트 값이 10이기 때문에 타임아웃이 발생한다. 커넥션 풀을 늘려서 해결하면 된다.

spring:
  datasource:
    hikari:
      maximum-pool-size: 80

참고

적용한 프로젝트(브랜치 dev, test/optimistic-lock, test/optimistic-deadlock) : https://github.com/Anifriends/Anifriends-Backend

ExecutorService : https://mangkyu.tistory.com/259

CountDownLatch : https://imasoftwareengineer.tistory.com/100

트랜잭션 옵션 : https://velog.io/@stpn94/JPA에서-Transaction의-전파

s-lock, x-lock : https://jaeseongdev.github.io/development/2021/06/16/Lock의-종류-(Shared-Lock,-Exclusive-Lock,-Record-Lock,-Gap-Lock,-Next-key-Lock)/

https://www.letmecompile.com/mysql-innodb-lock-deadlock/

https://dev.mysql.com/doc/refman/8.0/en/innodb-locks-set.html

profile
시도하고 More Do하는 백엔드 개발자입니다.

0개의 댓글