동시성 문제 해결하기 V1 - 낙관적 락(Optimistic Lock) feat.데드락 첫 만남

LJH·2022년 7월 25일
42
post-thumbnail

0. 이 글을 쓰는 이유

누구나 한 번쯤은 동시성에 대한 이야기를 공부하거나 들어봤을 것이다.
나 또한 그랬고, 이를 이론적으로만 학습했다.

YAPP 프로젝트를 진행하면서 동시성 문제가 발생할 수 있는 상황이 생겼고,
이를 해결하면서 이론적으로 학습했던 내용을 체화해보고자 한다.
현재는 단일 DB 환경이고 아래와 같은 과정을 진행해 볼 것이다.

첫 번째로, 낙관적 락(Optimistic Lock)을 통해 문제를 해결해본다.
두 번째로, 비관적 락(Pessimistic Lock)을 통해 문제를 해결해본다.
마지막으로, 분산 DB 환경이라고 가정하고 분산 락(distributed Lock)을 통해 문제를 해결해본다.

먼저 이 글에서는 낙관적 락(Optimistic Lock)을 통해 문제를 해결해본다.

  • 사용 환경은 다음과 같다.
    • Spring Boot : 2.6.7
    • hibernate-core : 5.6.8
    • Querydsl-core : 5.0.0
    • MySQL : 8.0.23
    • Junit5 : 5.8.2

1. 요구사항과 해결 방법

먼저 현재 상황(요구사항)을 간단히 살펴보고 동시성 문제를 낙관적 락(Optimistic Lock)으로 어떻게 해결 가능한지 살펴보자.

1-1. 요구사항

  • 모임이 존재하고, 각 모임은 제한된 인원만 참여할 수 있다.

  • 이미 인원이 다 찬 모임에 참여하려는 경우, 사용자에게 이미 마감된 모임이라는 메시지를 전달한다.

  • 한자리만 남은 모임에 여러명이 동시에 참여 요청해도, 가장 먼저 요청한 사용자만 모임에 참여해야 한다.

1-2. 낙관적 락(Optimistic Lock) 이란

  • 낙관적 락(Optimisstic Lock)은 DB의 Lock을 사용하지 않고 Version관리를 통해 애플리케이션 레벨에서 처리한다.

  • 대부분의 트랜잭션이 충돌하지 않는다고 가정하는 방법이다.

  • DB의 Lock 기능을 이용하지 않고, JPA가 제공하는 버전 관리 기능을 사용한다.

  • 트랜잭션 커밋 전에는 트랜잭션 충돌을 알 수 없다.

요구사항을 만족시킬수 있는지 살펴보면
낙관적 락(Optimistic Lock)은 version을 통해 관리되는데, 최초 하나의 요청만 성공하고 나머지
요청들은ObjectOptimisticLockingFailureException예외가 발생한다. 즉 최초 한명만 모임에 참여하고 해당 예외를 catch해서 사용자에게 이미 인원이 마감된 모임이라는 내용을 클라이언트로 전달할 수 있으니 요구사항을 만족할 수 있다.

1-3. 두 번의 갱신 분실 문제

  • 두 트랜잭션이 같은 데이터를 변경했을 때, 한 트랜잭션의 결과만 남는것을 두 번의 갱실문제라고 한다.

  • 두 번의 갱실 문제는 DB의 트랜잭션 범위를 벗어나는 문제이다.
    따라서 추가적인 처리가 필요한데, 이를 해결하기 위한 세 가지 방법이 있다.

  1. 마지막 커밋만 인정

    • 이전 커밋은 모두 무시하고, 가장 마지막 커밋만 반영한다.
    • 디폴트로 이 방법이 사용된다.
  2. 최초 커밋만 인정

    • 최초 커밋을 반영하고, 이후 커밋들은 오류를 발생시킨다.
    • JPA가 제공하는 버전 관리 기능을 이용하면 쉽게 구현할 수 있다.
  3. 충돌하는 내용 병합

    • 충돌난 내용을 합친다.
    • 애플리케이션 레벨에서 직접 구현해야 한다.
  • 두 번의 갱신 분실 문제를 어떻게 해결하는지는 아래에서 살펴보자.

2. JPA의 버전관리 기능 - @Version

  • JPA의 낙관적 락(Optimisstic Lock)을 사용하기 위해서는 @Version을 사용해서 버전 관리 기능을 추가해야 한다.
  • @Version을 적용할 수 있는 데이터 타입은 아래와 같다.
    1. Long, long
    2. Integer, int
    3. Short, short
    4. Timestamp

2-1. 동작 방식

  • 엔티티에 @Version을 위한 필드를 추가하면, 엔티티를 수정할 때 마다 버전이 하나씩
    자동으로 증가한다.

  • 그리고 엔티티를 수정할 때 조회 시점의 버전과, 수정 시점의 버전이 다르면 예외가 발생한다.

    1. 트랜잭션1이 엔티티를 조회 → version 1
    2. 동시에 트랜잭션2가 같은 엔티티를 조회 후 수정 → version 2
    3. 트랜잭션1이 커밋 → version이 1이여야 하는데 2임 → 예외 발생
  • 이런 메커니즘 때문에 최초 커밋만 인정되는 방식을 구현할 수 있으므로, 두 번의 갱신 분실 문제를 방지할 수 있다.

2-2. 주의 사항

  • 임베디드 타입과 값 타입 컬렉션은 실제 DB에서는 다른 테이블이지만, JPA에서는 논리적인 개념해당 엔티티에 속한 값이므로 수정하면 엔티티의 버전이 증가한다.

  • 버전은 JPA가 직접 관리하므로 개발자가 수정하면 안된다.
    단 벌크연산시 JPA가 관리하지 않으므로 이 때는 직접 버전을 관리해줘야 한다.


3. JPA의 Lock 사용 방법

  • JPA를 사용할 때 추천하는 전략은 READ COMMITTED 격리 수준 + 낙관적 버전 관리 이다.
  • 참고로 MySQL InnoDB 스토리지 엔진의 기본 격리 수준은 REPEATABLE READ 이다.

3-1. Lock 적용 범위

  • @Lock을 적용 가능한 범위는 아래와 같다.
    1. EntityManager.lock() , EntityManager.find(), EntityManager.refresh()
    2. Query.setLockMode()
    3. @NamedQuery

3-2. JPA가 제공하는 Lock 옵션

락 모드타입설명
낙관적 락(Optimisstic Lock)OPTIMISTIC낙관적 락 사용
낙관적 락(Optimisstic Lock)OPTIMISTIC_FORCE_INCREMENT낙관적 락 + 버전 정보 강제 증가
비관적 락(Pessimistic Lock)PESSIMISTIC_READ비관적 락, 읽기 Lock 사용
비관적 락(Pessimistic Lock)PESSIMISTIC_WRITE비관적 락 쓰기 Lock 사용
비관적 락(Pessimistic Lock)PESSIMISTIC_FORCE_INCREMENT비관적 락 + 버전 정보 강제 증가
기타NONE엔티티에 @Version이 있으면 낙관적 Lock을 적용함
기타READ하위 호환을 위한 것으로 OPTIMISTIC와 같음
기타WRITE하위 호환을 위한 것으로 OPTIMISTIC_FORCE_INVREMENT와 같음
  • 여기서 사용할 OPTIMISTIC 옵션에 대해서 조금 더 자세히 살펴보자.

  • 한번 조회한 엔티티는 트랜잭션이 끝날때까지 다른 트랜잭션에서 변경되지 않음을 보장한다. 이로 인해 NON-REPEATABLE READ문제를 방지한다.

  • NON-REPEATABLE READ란 한 트랜잭션내에서 같은 쿼리를 두 번 수행했을 때, 결과가 다르게 나타나는 현상을 의미한다.

  • 1-2에서 이야기한 것 처럼, 트랜잭션 커밋 시점에 버전이 같지 않으면 ObjectOptimisticLockingException예외를 발생시킨다.


4. 낙관적 락(Optimistic Lock)을 사용하지 않고 테스트

4-1. 모임 참여 요청시 호출되는 메서드

@Transactional
public ClubParticipateResponse participateClub(Long clubId, Account loginAccount) {
    Club findClub = clubRepository.findClubDetailById(clubId).orElseThrow(EntityNotFoundException::new);
    List<Pet> findPets = petRepository.findPetsByAccountId(loginAccount.getId());

    ClubParticipateResponse response = validator.participationValidate(findClub, findPets, loginAccount);
    response.setClubId(findClub.getId());

    if (!response.isEligible()) {
        return response;
    }

    AccountClub accountClub = AccountClub.of(loginAccount, findClub);
    accountClubRepository.save(accountClub);

    findClub.addAccountClub(accountClub);

    return response;
}
  • 모임 참여 시 호출되는 메서드이다.

  • validator.participationValidate() 부분에서 모임에 참여하기 위한 조건을 만족하는지 검사한다. 이미 인원이 다 찬 모임이라면 이 메서드에서 필터링되고, 아래 if문에서 return된다.

  • 중요한건 메서드의 시작 부분을 보면 findClubDetailById()를 통해 모임을 조회한다.
    이따가 이 부분에서 버전관리 기능을 사용할 수 있도록 낙관적 락(Optimistic Lock)을 사용해야 한다. 우선은 실패하는 테스트를 하기 때문에 그냥 넘어가고 아래에서 다시 설명한다.

4-2. 테스트 코드

/*
* 원래 서로 다른 Account가 모임에 참여해야 하지만, 앱에서는 모임 참여 후 같은 모임에 다시 참여할 수 있는 방법이 없어서
* 같은 Account가 같은 모임에 참여하지 못하도록 하는 별도의 검증로직이 없습니다. (나중에는 추가 해야겠지만요)
* 따라서 테스트에서는 하나의 Account를 이용하고, 각 쓰레드가 서로 다른 Account 역할을 수행합니다.
* */
@Test
@DisplayName("인원 제한이 2명인 모임에 1명이 이미 참여중이고, 남은 1자리에 10명이 동시에 참여하는 상황")
void participateClub() throws InterruptedException {
    //given
    final int PARTICIPATION_PEOPLE = 10;
    final int CLUB_MAXIMUM_PEOPLE = 2;

    CountDownLatch countDownLatch = new CountDownLatch(PARTICIPATION_PEOPLE);

    List<ParticipateWorker> workers = Stream
            .generate(() -> new ParticipateWorker(account, countDownLatch))
            .limit(PARTICIPATION_PEOPLE)
            .collect(Collectors.toList());

    //when
    workers.forEach(worker -> new Thread(worker).start());
    countDownLatch.await();

    //then
    List<AccountClub> accountClubs = accountClubRepository.findAccountClubByClubId(CLUB_ID);
    long participationAccountCount = accountClubs.size();

    assertThat(participationAccountCount).isEqualTo(CLUB_MAXIMUM_PEOPLE);
}

private class ParticipateWorker implements Runnable {
    private Account account;
    private CountDownLatch countDownLatch;

    public ParticipateWorker(Account account, CountDownLatch countDownLatch) {
        this.account = account;
        this.countDownLatch = countDownLatch;
    }

    @Override
    public void run() {
        clubService.participateClub(CLUB_ID, account);
        countDownLatch.countDown();
    }
}
  • 인원제한인 2명인 모임에, 이미 1명(방장)이 참여중인 상황에서 10명이 동시에 참여 요청을 하는 테스트이다.

  • PARTICIPATION_PEOPLE 값이 쓰레드의 개수를 의미하고, 각 10개의 쓰레드가 동시에 수행되면서 마치 동시에 요청이 온 것 처럼 테스트할 수 있다.

4-3. 테스트 결과

  • 인원제한만큼인 2명이 참여해야 하지만, 10명이 모두 참여해서
    (기존인원 방장 1명 + 신규 인원 10명) → 11명이 모두 참여한 상태가 됐다.

  • DB에도 실제로 10명 데이터가 모두 들어가서 총 11개의 데이터가 insert 돼버렸다.
    (데이터 1개는 기존에 존재하던 방장)

5. 낙관적 락(Optimistic Lock) 적용

5-1. 모임 참여 요청시 호출되는 메서드

@Transactional
public ClubParticipateResponse participateClub(Long clubId, Account loginAccount) {
    ClubParticipateResponse response = null;

    try {
        // 모임 조회
        Club findClub = clubRepository.findClubDetailByIdWithLock(clubId).orElseThrow(EntityNotFoundException::new); // 1
        
		List<Pet> findPets = petRepository.findPetsByAccountId(loginAccount.getId());

		// 사용자가 해당 모임에 참여 가능한지 검증
        response = validator.participationValidate(findClub, findPets, loginAccount); // 2
        response.setClubId(findClub.getId());

        if (!response.isEligible()) {
            return response;
        }

        AccountClub accountClub = AccountClub.of(loginAccount, findClub);
        accountClubRepository.save(accountClub);

        findClub.addAccountClub(accountClub); 

		// 모임 인원 마감 시, 모임 상태 값 변경
        if (isFullClub(findClub)) { 
            findClub.updateStatus(ClubStatus.PERSONNEL_FULL);
        }

		// 버전이 다른 트랜잭션, 즉 모임에 참여하지 못한 사용자들의 요청은
		// ObjectOptimisticLockingFailureException 예외가 발생하므로
		// 인원이 이미 다 찬 모임이라는 응답을 전달하고
		// 이를 통해 사용자에게 인원 마감된 모임이라는 메시지를 전달할 수 있다.
    } catch (ObjectOptimisticLockingFailureException e) {
        return ClubParticipateResponse.of(false, ClubParticipateRejectReason.FULL);
    }

    return response;
}
  • 사용자가 마지막으로 참여해서, 인원이 마감된 경우 모임의 상태값을 변경하도록
    마지막 부분에 if문을 추가했다.

  • 그리고 예외를 처리하기 위해서 try-catch문을 사용했고, ObjectOptimisticLockingFailureException 예외 발생시 인원 마감된 모임이라는 응답하도록 했다.

5-2. Version 추가

@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Table(indexes = {
        @Index(name = "idx_latitude", columnList = "latitude"),
        @Index(name = "idx_longitude", columnList = "longitude")
})
public class Club extends BaseEntity {

    ...

    @Version
    private Integer version;
	
}
  • Lock을 사용할 데이터가 존재하는 엔티티에 version을 위한 필드를 추가하고 해당 필드에 @Version을 추가한다.

5-3. 버전 관리 적용

@Override
public Optional<Club> findClubDetailByIdWithLock(Long clubId) {
    AccountClub findAccountClub = queryFactory
            .selectFrom(accountClub)
            .innerJoin(accountClub.account, account).fetchJoin()
            .innerJoin(accountClub.club, club).fetchJoin()
            .innerJoin(club.eligiblePetSizeTypes).fetchJoin()
            .leftJoin(club.eligibleBreeds).fetchJoin()
            .where(club.id.eq(clubId))
            .setLockMode(LockModeType.OPTIMISTIC) // 낙관적 락 적용!
            .fetchFirst();

    return Optional.ofNullable(findAccountClub.getClub());
}
  • 5-1 코드에서 맨 처음에 모임을 조회할 때 호출되는 쿼리이자 메서드이다.

  • 트랜잭션을 시작하면서, 해당 모임에 버전 관리를 수행하도록 setLockMode(LockModeType.OPTIMISTIC)을 통해 낙관적 락(Optimistic Lock)을 적용한다.

  • 코드는 매우 간단하다. 이제 테스트를 통해 확인해보자.


6. 낙관적 락(Optimistic Lock) 테스트

6-1. 테스트 코드

/*
* 원래 서로 다른 Account가 모임에 참여해야 하지만, 앱에서는 모임 참여 후 같은 모임에 다시 참여할 수 있는 방법이 없어서
* 같은 Account가 같은 모임에 참여하지 못하도록 하는 별도의 검증로직이 없습니다. (나중에는 추가 해야겠지만요)
* 따라서 테스트에서는 하나의 Account를 이용하고, 각 쓰레드가 서로 다른 Account 역할을 수행합니다.
* */
@Test
@DisplayName("인원 제한이 2명인 모임에 1명이 이미 참여중이고, 남은 1자리에 10명이 동시에 참여하는 상황")
void participateClub() throws InterruptedException {
    //given
    final int PARTICIPATION_PEOPLE = 10;
    final int CLUB_MAXIMUM_PEOPLE = 2;

    CountDownLatch countDownLatch = new CountDownLatch(PARTICIPATION_PEOPLE);

    List<ParticipateWorker> workers = Stream
            .generate(() -> new ParticipateWorker(account, countDownLatch))
            .limit(PARTICIPATION_PEOPLE)
            .collect(Collectors.toList());

    //when
    workers.forEach(worker -> new Thread(worker).start());
    countDownLatch.await();

    //then
    List<AccountClub> accountClubs = accountClubRepository.findAccountClubByClubId(CLUB_ID);
    long participationAccountCount = accountClubs.size();

    assertThat(participationAccountCount).isEqualTo(CLUB_MAXIMUM_PEOPLE);
}
  • 테스트 코드는 4-2와 똑같다.

6-2. 결과 - 교착상태(Dead-Lock) 발생

  • 띠용 데드락이 발생했다. Deadlock found when trying to get lock; try restarting transaction

  • 낙관적 락(Optimistic Lock)은 분명 DB의 Lock을 사용하지 않고, 애플리케이션 레벨에서 버전 관리 기능을 사용한다. 그런데도 데드락이 발생했다. 왜일까?? 내용이 길어져서 아래에서 이어서 설명한다.


7. 교착상태(Dead-Lock) 발생 원인 파악

  • 우선 교착상태(Dead-Lock)이란 둘 이상의 프로세스(여기서는 트랜잭션)들이 자원을 점유(Lock을 획득)한 상태에서 서로 다른 프로세스(트랜잭션)가 점유하고 있는 자원(Lock)을 요구하며 무한정 기다리는 상황을 의미한다.

  • 그렇다는 것은 Lock을 사용하고 있다는 뜻인데.. 데드락 문제를 확인해보자.

7-1. 데드락 History 확인

  • show engine innodb status 명령을 통해 데드락 History를 확인할 수 있다.

  • HOLDS THE LOCK(S) → s-Lock을 획득

  • WAITING FOR THIS LOCK TO BE GRANTED → Lock 획득을 기다리고 있다. 그리고 아래 보면 대기하는 Lock은 x-Lock이다.

  • 즉 같은 레코드(데이터)에 s-Lock과 x-Lock이 발생했다.

7-2. s-Lock 과 x-Lock

  • s-Lock이란?

    • 공유 락(Shared Lock)이라고 하며, 데이터를 읽을 때 사용하는 Lock이다.

    • 다른 s-Lock과는 호환되지만, x-Lock과는 호환되지 않는다.

      • “호환된다” 라는 말은 한 리소스에 두 개 이상의 Lock을 동시에 설정할 수 있음을 뜻한다.
    • 즉 여러 트랜잭션에서 동시에 하나의 데이터를 읽을 수 있다.
      그러나 변경중인 리소스를 동시에 읽을 수는 없다.

  • x-Lock이란?

    • 배타적 락(Exclusive Lock)이라고 하며, 데이터를 변경할 때 사용한다.

    • 다른 Lock들과 호환되지 않는다. 즉 한 리소스에 하나의 x-Lock만 설정 가능하다.x-Lock은 동시에 여러 트랜잭션이 한 리소스에 엑세스할 수 없게 된다. 읽기도 안된다. 오직 하나의 트랜잭션만 해당 리소스를 점유할 수 있다.

7-3. 저는 DB Lock을 사용한적이 없는데요!?

  • 나는 낙관적 락(DB Lock 사용 x)을 사용했을 뿐 DB Lock을 사용하지 않았다.
    근데 왜 Lock이 사용됐을까?

  • MySQL 8.0 레퍼런스에는 안나와있고, 5.6 레퍼런스에서 관련내용을 찾을 수 있었다.

7-3-1. 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 하는 쿼리는 제약조건을 확인하기 위해 shared lock(s-Lock)을 설정한다고 한다.

  • 모임에 참여하게 되면 AccountClub이라는 테이블에 데이터가 insert되는데 이 때
    Club(모임)의 id를 fk로 가지고 있기 때문에 Club(모임) 데이터에 s-Lock이 걸린 것이다.

7-3-2. 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.

  • 요약하면 Update 쿼리에 사용되는 모든 레코드에 exclusive lock(x-Lock)을 설정한다고 한다.

  • 인원이 마감된 경우 모임의 상태값을 변경하는 update 쿼리가 발생하면서 x-Lock이 걸린 것이다.

7-3-3. 코드에서 Lock 걸리는 원인 확인

@Transactional
public ClubParticipateResponse participateClub(Long clubId, Account loginAccount) {
    Club findClub = clubRepository.findClubDetailByIdWithLock(clubId).orElseThrow(EntityNotFoundException::new);
    List<Pet> findPets = petRepository.findPetsByAccountId(loginAccount.getId());

    ClubParticipateResponse response = validator.participationValidate(findClub, findPets, loginAccount);
    response.setClubId(findClub.getId());

    if (!response.isEligible()) {
        return response;
    }

    AccountClub accountClub = AccountClub.of(loginAccount, findClub);
    accountClubRepository.save(accountClub); // s-Lock이 걸리는 원인

    findClub.addAccountClub(accountClub);

    if (isFullClub(findClub)) {
        findClub.updateStatus(ClubStatus.PERSONNEL_FULL); // x-Lock이 걸리는 원인
    }

    return response;
}
  • Account - AccountClub -Club 의 형태로 1:N:1 연관관계를 가지고 있다.

  • 그래서 AccountClub에는 Club의 id를 fk로 사용하고 있고, AccountClub이 insert 되는 시점에 Club 데이터에 s-Lock이 걸리는 것이다.

  • 그리고 아래 if문에서 인원이 마감되고, Club의 상태값을 변경하면서 update 쿼리가 발생하고 x-Lock이 걸리는 것이다.

7-4. 그래서 데드락이 발생 원인을 정리하면

  1. 트랜잭션 A가 AccountClub 데이터를 insert한다.

    • 이 때 fk가 걸려있는 Club(모임) 레코드에 s-Lock을 건다.
  2. 트랜잭션 B가 AccountClub 데이터를 insert한다.

    • 이 때 fk가 걸려있는 Club(모임) 레코드에 s-Lock을 건다.

    • s-Lock은 호환 가능하므로 서로 다른 트랜잭션이 같은 레코드에 Lock을 걸 수 있다.

  3. 트랜잭션 A가 모임을 인원 마감 상태로 변경한다.

    • 이 때 update 쿼리가 발생하고 Club(모임) 레코드에 x-Lock을 걸려고 시도한다.

    • 하지만 이미 트랜잭션 B에서 Club(모임) 레코드에 s-Lock을 걸어놨다. s-Lock과 x-Lock은 호환되지 않으므로 s-Lock이 풀릴때까지 대기한다.

  4. 트랜잭션 B가 모임을 인원 마감 상태로 변경한다.

    • 이 때 update 쿼리가 발생하고 Club(모임) 레코드에 x-Lock을 걸려고 시도한다.

    • 마찬가지로 이미 트랜잭션 A에서 Club(모임) 레코드에 s-Lock을 걸어놨다. s-Lock과 x-Lock은 호환되지 않으므로 s-Lock이 풀릴때까지 대기한다.

  5. 데드락 발생!

    • 서로 다른 트랜잭션이 같은 자원에 대해 Lock을 가지고 있으며, 서로 다른 트랜잭션이 Lock을 해제할때까지 둘다 기다리고 있으니.. 무한히 대기 상태에 빠지는 데드락이 발생한다.

7-5. 4-3 테스트 결과에서는 왜 데드락 발생 안했나요??

  • 5-1에서 이야기했듯이 모임 인원이 마감되는 경우, 모임의 상태를 인원 마감 상태로 변경하는 if문을 추가했다.

  • 여기서 모임의 상태값 + update_at(수정시간)이 변경되면서 update 쿼리가 발생하면서
    s-lock과 x-lock이 걸리는데 4-3 테스트 할 때의 코드에는 해당 if문이 없었으므로, update 쿼리가 발생하지 않았던 것이다.

7-6. 데드락 timeout 시간은?

데드락이 발생하고 직접 처리해주지 않으면 영원히 해결되지 않기 때문에, lock timeout 시간이 지날때까지 트랜잭션이 끝나지 않으면 데드락이 발생했다고 간주하고 트랜잭션을 종료시켜 버린다고 알고있다. (DBMS가 데드락이 발생한걸 알고 lock timeout이 지나면 트랜잭션을 종료시키는 걸 수도 있다.) 궁금해서 default timeout 시간을 확인해보았다.

  • innodb는 50이라고 나오는데 아마 초단위 일 듯 하다. 즉 50초다.

  • 아래는 MyISAM같은 다른 스토리지 엔진에서 쓰는값인가? 31536000초는 365일이다.
    한번 데드락 걸리고 별도로 처리해주지 않으면 데드락이 1년동안 유지되는건가 싶다..

  • 그냥 궁금했다. 중요한 내용은 아니니 마무리를 하자.


8. 마치며

데드락 이슈로 인해 낙관적 락(Optimistic Lock)으로는 문제를 해결할 수 없다.
이를 해결하기 위해 다음 글에서는 비관적 락(Pessimistic Lock)을 통해 문제를 해결 해 볼 것이다. 마지막으로 더 나아가 다다음 글에서 분산 DB 환경이라고 가정하고, 분산 락(Distributed Lock)을 활용해 볼 것이다.

여기서 잠깐 데드락을 논외로 하고, 분산 서버 환경 관점에서의 낙관적 락(Optimistic Lock)을 살펴보자. 아래에서 이야기 하는 내용은 모두 싱글 DB임을 가정해야 한다. 처음에는 애플리케이션 레벨에서 관리되므로 분산 서버 환경이면 각 서버마다 버전이 다르면 제어가 안된다고 생각했다. 하지만 잘 생각해보면 각 분산된 서버의 버전값이 달라도, 실제 비교할 버전의 값은 DB에 저장되어있기 때문에 동시성을 제어할 수 있다.

단 비즈지스 성격에 따라 아래와 같은 부분을 고려해야 한다. (싱글 서버인 경우에도 마찬가지다) 예로 선착순 100명에게 쿠폰을 발급하는 이벤트를 생각해보자. 동시에 100명이 요청했다면 100명은 모두 쿠폰을 발급받는걸 기대하겠지만, 낙관적 락(Optimistic Lock) 메커니즘상 최초의 커밋만 인정하기 때문에 100개의 쿠폰이 있어도 최초 요청한 1명에게만 쿠폰이 발급된다. 그럼 나머지 99명은 다시 발급 요청을 해야한다. 또 99명중 1명, 98명중 1명 … 마지막에 쿠폰을 발급받는 1명은 100번의 요청을 해야 쿠폰을 발급받는 상황이 생긴다. 또한 DB에는 부하가 가지 않겠지만 애플리케이션 서버는 재시도하는 요청만큼 부하를 받게 된다. 게다가 이렇게 동작하기 위해서는 재시도에 대한 처리와, 재시도 실패에 대한 처리도 구현해야 하므로 코드의 복잡성이 증가할 수 있다.

정리하면 현재 구현하는 서비스의 비즈니스 성격 + 애플리케이션 부하 + 재시도에 대한 별도 처리 등을 고려해야 한다. 예시로 들은 쿠폰 발급 같은 경우 비관적 락(Pessimistic Lock)을 사용하면 별도 처리에 대한 고민하지않고 + 지속적인 요청 없이 동작할 수 있다.. 물론 비관적 락(Pessimistic Lock)이 만능은 아니다. 이에 관한 이야기는 다음 글에서 자세히 한다.


Ref

5개의 댓글

comment-user-thumbnail
2022년 7월 28일

재밌는 글 감사합니다 :D!

1개의 답글
comment-user-thumbnail
2022년 7월 30일

덕분에 많이 배웠습니다.. 정말 감사합니다!

1개의 답글
comment-user-thumbnail
2023년 6월 24일

안녕하세요. 코드에서 궁금한 점이 있습니다.
5-1 낙관적락 적용 코드에서 "findClub.addAccountClub(accountClub);" 부분에서도 낙관적락을 통한 version 체크가 이루어지기(엔티티 업데이트가 발생하니) 때문에, 남은 자리가 한개가 아니더라도(2명이상 신청가능) 2명의 사용자가 동시에 신청할 경우 2번째 사용자는 "인원수 마감"의 응답을 받게 되지 않나요?! 혹시 의도한 건가요?!

답글 달기