누구나 한 번쯤은 동시성에 대한 이야기를 공부하거나 들어봤을 것이다.
나 또한 그랬고, 이를 이론적으로만 학습했다.
YAPP 프로젝트를 진행하면서 동시성 문제가 발생할 수 있는 상황이 생겼고,
이를 해결하면서 이론적으로 학습했던 내용을 체화해보고자 한다.
현재는 단일 DB 환경이고 아래와 같은 과정을 진행해 볼 것이다.
첫 번째로, 낙관적 락(Optimistic Lock)을 통해 문제를 해결해본다.
두 번째로, 비관적 락(Pessimistic Lock)을 통해 문제를 해결해본다.
마지막으로, 분산 DB 환경이라고 가정하고 분산 락(distributed Lock)을 통해 문제를 해결해본다.
먼저 이 글에서는 낙관적 락(Optimistic Lock)을 통해 문제를 해결해본다.
2.6.7
5.6.8
5.0.0
8.0.23
5.8.2
먼저 현재 상황(요구사항)을 간단히 살펴보고 동시성 문제를 낙관적 락(Optimistic Lock)으로 어떻게 해결 가능한지 살펴보자.
모임이 존재하고, 각 모임은 제한된 인원만 참여할 수 있다.
이미 인원이 다 찬 모임에 참여하려는 경우, 사용자에게 이미 마감된 모임이라는 메시지를 전달한다.
한자리만 남은 모임에 여러명이 동시에 참여 요청해도, 가장 먼저 요청한 사용자만 모임에 참여해야 한다.
낙관적 락(Optimisstic Lock)은 DB의 Lock을 사용하지 않고 Version관리를 통해 애플리케이션 레벨에서 처리한다.
대부분의 트랜잭션이 충돌하지 않는다고 가정하는 방법이다.
DB의 Lock 기능을 이용하지 않고, JPA가 제공하는 버전 관리 기능을 사용한다.
트랜잭션 커밋 전에는 트랜잭션 충돌을 알 수 없다.
요구사항을 만족시킬수 있는지 살펴보면
낙관적 락(Optimistic Lock)은 version을 통해 관리되는데, 최초 하나의 요청만 성공하고 나머지
요청들은ObjectOptimisticLockingFailureException
예외가 발생한다. 즉 최초 한명만 모임에 참여하고 해당 예외를 catch해서 사용자에게 이미 인원이 마감된 모임이라는 내용을 클라이언트로 전달할 수 있으니 요구사항을 만족할 수 있다.
두 트랜잭션이 같은 데이터를 변경했을 때, 한 트랜잭션의 결과만 남는것을 두 번의 갱실문제라고 한다.
두 번의 갱실 문제는 DB의 트랜잭션 범위를 벗어나는 문제이다.
따라서 추가적인 처리가 필요한데, 이를 해결하기 위한 세 가지 방법이 있다.
마지막 커밋만 인정
최초 커밋만 인정
충돌하는 내용 병합
@Version
을 사용해서 버전 관리 기능을 추가해야 한다.@Version
을 적용할 수 있는 데이터 타입은 아래와 같다.엔티티에 @Version
을 위한 필드를 추가하면, 엔티티를 수정할 때 마다 버전이 하나씩
자동으로 증가한다.
그리고 엔티티를 수정할 때 조회 시점의 버전과, 수정 시점의 버전이 다르면 예외가 발생한다.
이런 메커니즘 때문에 최초 커밋만 인정되는 방식을 구현할 수 있으므로, 두 번의 갱신 분실 문제를 방지할 수 있다.
임베디드 타입과 값 타입 컬렉션은 실제 DB에서는 다른 테이블이지만, JPA에서는 논리적인 개념해당 엔티티에 속한 값이므로 수정하면 엔티티의 버전이 증가한다.
버전은 JPA가 직접 관리하므로 개발자가 수정하면 안된다.
단 벌크연산시 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
예외를 발생시킨다.
@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)을 사용해야 한다. 우선은 실패하는 테스트를 하기 때문에 그냥 넘어가고 아래에서 다시 설명한다.
/*
* 원래 서로 다른 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개의 쓰레드가 동시에 수행되면서 마치 동시에 요청이 온 것 처럼 테스트할 수 있다.
@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
예외 발생시 인원 마감된 모임이라는 응답하도록 했다.
@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;
}
@Version
을 추가한다.@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)을 적용한다.
코드는 매우 간단하다. 이제 테스트를 통해 확인해보자.
/*
* 원래 서로 다른 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);
}
띠용 데드락이 발생했다. Deadlock found when trying to get lock; try restarting transaction
낙관적 락(Optimistic Lock)은 분명 DB의 Lock을 사용하지 않고, 애플리케이션 레벨에서 버전 관리 기능을 사용한다. 그런데도 데드락이 발생했다. 왜일까?? 내용이 길어져서 아래에서 이어서 설명한다.
우선 교착상태(Dead-Lock)이란 둘 이상의 프로세스(여기서는 트랜잭션)들이 자원을 점유(Lock을 획득)한 상태에서 서로 다른 프로세스(트랜잭션)가 점유하고 있는 자원(Lock)을 요구하며 무한정 기다리는 상황을 의미한다.
그렇다는 것은 Lock을 사용하고 있다는 뜻인데.. 데드락 문제를 확인해보자.
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이 발생했다.
s-Lock이란?
공유 락(Shared Lock)이라고 하며, 데이터를 읽을 때 사용하는 Lock이다.
다른 s-Lock과는 호환되지만, x-Lock과는 호환되지 않는다.
즉 여러 트랜잭션에서 동시에 하나의 데이터를 읽을 수 있다.
그러나 변경중인 리소스를 동시에 읽을 수는 없다.
x-Lock이란?
배타적 락(Exclusive Lock)이라고 하며, 데이터를 변경할 때 사용한다.
다른 Lock들과 호환되지 않는다. 즉 한 리소스에 하나의 x-Lock만 설정 가능하다.x-Lock은 동시에 여러 트랜잭션이 한 리소스에 엑세스할 수 없게 된다. 읽기도 안된다. 오직 하나의 트랜잭션만 해당 리소스를 점유할 수 있다.
나는 낙관적 락(DB Lock 사용 x)을 사용했을 뿐 DB Lock을 사용하지 않았다.
근데 왜 Lock이 사용됐을까?
MySQL 8.0 레퍼런스에는 안나와있고, 5.6 레퍼런스에서 관련내용을 찾을 수 있었다.
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이 걸린 것이다.
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이 걸린 것이다.
@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이 걸리는 것이다.
트랜잭션 A가 AccountClub 데이터를 insert한다.
트랜잭션 B가 AccountClub 데이터를 insert한다.
이 때 fk가 걸려있는 Club(모임) 레코드에 s-Lock을 건다.
s-Lock은 호환 가능하므로 서로 다른 트랜잭션이 같은 레코드에 Lock을 걸 수 있다.
트랜잭션 A가 모임을 인원 마감 상태로 변경한다.
이 때 update 쿼리가 발생하고 Club(모임) 레코드에 x-Lock을 걸려고 시도한다.
하지만 이미 트랜잭션 B에서 Club(모임) 레코드에 s-Lock을 걸어놨다. s-Lock과 x-Lock은 호환되지 않으므로 s-Lock이 풀릴때까지 대기한다.
트랜잭션 B가 모임을 인원 마감 상태로 변경한다.
이 때 update 쿼리가 발생하고 Club(모임) 레코드에 x-Lock을 걸려고 시도한다.
마찬가지로 이미 트랜잭션 A에서 Club(모임) 레코드에 s-Lock을 걸어놨다. s-Lock과 x-Lock은 호환되지 않으므로 s-Lock이 풀릴때까지 대기한다.
데드락 발생!
5-1에서 이야기했듯이 모임 인원이 마감되는 경우, 모임의 상태를 인원 마감 상태로 변경하는 if문을 추가했다.
여기서 모임의 상태값
+ update_at(수정시간)
이 변경되면서 update 쿼리가 발생하면서
s-lock과 x-lock이 걸리는데 4-3 테스트 할 때의 코드에는 해당 if문이 없었으므로, update 쿼리가 발생하지 않았던 것이다.
데드락이 발생하고 직접 처리해주지 않으면 영원히 해결되지 않기 때문에, lock timeout 시간이 지날때까지 트랜잭션이 끝나지 않으면 데드락이 발생했다고 간주하고 트랜잭션을 종료시켜 버린다고 알고있다. (DBMS가 데드락이 발생한걸 알고 lock timeout이 지나면 트랜잭션을 종료시키는 걸 수도 있다.) 궁금해서 default timeout 시간을 확인해보았다.
innodb는 50이라고 나오는데 아마 초단위 일 듯 하다. 즉 50초다.
아래는 MyISAM같은 다른 스토리지 엔진에서 쓰는값인가? 31536000초는 365일이다.
한번 데드락 걸리고 별도로 처리해주지 않으면 데드락이 1년동안 유지되는건가 싶다..
그냥 궁금했다. 중요한 내용은 아니니 마무리를 하자.
데드락 이슈로 인해 낙관적 락(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)이 만능은 아니다. 이에 관한 이야기는 다음 글에서 자세히 한다.
재밌는 글 감사합니다 :D!