이전 글에서 낙관적 락(Optimistic Lock)을 통해 동시성 문제를 해결해보려 했다.
하지만 데드락 이슈가 발생했고, 낙관적 락(Optimistic Lock)으로는 문제를 해결할 수 없었다.
그래서 이번에는 비관적 락(Pessimistic Lock)을 통해 동시성 문제를 해결해보고자 한다.
이전 글을 보지 않더라도 이해할 수 있도록, 중복된 내용이 다소 포함되어 있다.
모든 트랜잭션은 충돌이 발생한다고 가정하고 우선 Lock을 거는 방법이다.
낙관적 락(Optimistic Lock)과는 달리 DB의 Lock 기능을 이용한다. 주로 select for update
구문을 사용하고, 버전 정보는 사용하지 않는다. (사용하도록 할 수도 있다.)
엔티티가 아닌 스칼라 타입(int, String 같은 타입)을 조회할 때도 사용할 수 있다.
트랜잭션 커밋하기 전에, 데이터를 수정하는 시점에 미리 트랜잭션 충돌을 감지할 수 있다.
Lock을 획득할 때까지 트랜잭션은 대기하므로, Timeout을 설정할 수 있다.
이 글과는 크게 관련 없지만 알게된 내용이 있어서 써본다.
비관적 락을 사용하면 select ... for update
쿼리가 발생하는데, 이 때 갭락을 사용한다.
Where절에 고유한 검색 조건이 있는 경우에는 검색 조건에 명시한 row보다 앞에 있는 데이터에는 갭락을 사용하지 않지만, Where 절에 고유한 검색 조건이 없는 경우에는 갭락 또는 넥스트 키 락을 사용한다.
즉 비관적 락(Pessimistic Lock)으로 데이터를 조회하게 되면, 해당 트랜잭션이 끝나기 전까지는 비관적 락(Pessimistic Lock)을 사용한 데이터의 다음 데이터를 Insert할 수 없게 된다.
A테이블 1번 row에 비관적 락(Pessimistic Lock) → A테이블에 2번 row Insert 불가능 (갭락) 더 자세한 내용이 궁금하면 MySQL 문서를 확인해보자.
모임에 참여할 때, 모임 데이터에 비관적 락을 사용한다.
동시에 갭락을 사용하기 때문에 모임 데이터를 Insert할 수 없다.
즉 모임 참여 요청과 동시에 모임 생성 요청이 들어오면
모임 생성 요청은 갭락으로 인해 blocking되어 모임 참여 요청 트랜잭션이 끝날때까지
대기하는 상황이 발생할 것이다.
물론 blocking되는 시간은 그리 길지 않을 것 같다.
@Lock
을 적용 가능한 범위는 아래와 같다.
아니 그럼 Querydsl과 같은 쿼리빌더로 작성한 코드로는 Lock을 사용할 수 없는건가? 싶었지만 Querydsl에서는 setLockMode(LockModeType lockMode)
메서드를 지원해준다.
락 모드 | 타입 | 설명 |
---|---|---|
낙관적 락(Optimisstic Lock) | OPTIMISTIC | 낙관적 Lock 사용 |
낙관적 락(Optimisstic Lock) | OPTIMISTIC_FORCE_INCREMENT | 낙관적 Lock + 버전 정보 강제 증가 |
비관적 락(Pessimistic Lock) | PESSIMISTIC_READ | 비관적 Lock, 읽기 Lock 사용 |
비관적 락(Pessimistic Lock) | PESSIMISTIC_WRITE | 비관적 Lock, 쓰기 Lock 사용 |
비관적 락(Pessimistic Lock) | PESSIMISTIC_FORCE_INCREMENT | 비관적 Lock + 버전 정보 강제 증가 |
기타 | NONE | 엔티티에 @Version이 있으면 낙관적 Lock을 적용함 |
기타 | READ | 하위 호환을 위한 것으로 OPTIMISTIC와 같음 |
기타 | WRITE | 하위 호환을 위한 것으로 OPTIMISTIC_FORCE_INVREMENT와 같음 |
@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;
}
첫 번째 줄을 보면 Club(모임)을 조회한다.
그리고 나서 AccountClub을 저장 + Club의 상태를 변경하면서 조회한 모임에 s-Lock과 x-Lock이 걸리면서 데드락이 발생한다. (s-Lock과 x-Lock은 호환되지 않기 때문에)
Lock을 직접 사용하지 않았다. MySQL InnoDB 스토리지 엔진을 사용하는 경우, fk 제약조건과 Update 쿼리로 인해 자동적으로 Lock을 사용한다.
자 그러면 처음부터 모임을 조회할 때 x-Lock을 걸어버리면, 데드락이 발생하지 않을 것이다.
트랜잭션 A가 모임을 조회할 때 x-Lock을 건다.
트랜잭션 B가 같은 모임을 조회한다.
트랜잭션 A가 모든 작업을 마치고 commit 한다.
트랜잭션 B가 모임을 조회한다.
이런식으로 각 트랜잭션들이 순차적으로 작업을 마칠때까지 다른 트랜잭이 접근하지 못하므로 데드락을 방지할 수 있는 것이다.
@Override
public Optional<Club> findClubDetailById(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.PESSIMISTIC_WRITE) // Lock 적용!
.fetchFirst();
return Optional.ofNullable(findAccountClub.getClub());
}
위에서 모임을 조회할 때 사용되는 메서드이자 쿼리이다.
Querydsl을 사용중이라면 setLockMode()
메서드를 사용하면 된다.
LockModeType.PESSIMISTIC_WRITE
은 쓰기 락을 사용한다는 의미이다.
적용 방법은 매우 간단하다. 이게 끝이다. 만약 네임드 쿼리를 사용한다면 @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);
}
테스트 코드는 이전 글과 같다.
인원제한인 2명인 모임에, 이미 1명(방장)이 참여중인 상황에서 10명이 동시에 참여 요청을 하는 테스트이다.
PARTICIPATION_PEOPLE
값이 쓰레드의 개수를 의미하고, 각 10개의 쓰레드가 동시에 수행되면서 마치 동시에 요청이 온 것 처럼 테스트할 수 있다.
select for update
쿼리가 발생한 것을 볼 수 있다.나머지 9개의 요청들은 if문에서 걸러져 응답되는걸 볼 수 있다.
해당 모임은 인원 마감이라는 응답을 하게 되고, 프론트에서는 이를 통해 인원이 마감됐다는 사용자에게 보여줄 수 있으니 요구사항을 만족한다.
처음에는 단순하게 먼저 접근하는 트랜잭션이 row에 락을 걸어버리니까 데드락 문제가 발생할 수 없지 않을까라고 생각했다. 하지만 비관적락을 적용해도 상황에 따라 데드락 문제는 발생할 수 있다.
트랜잭션 A가 X테이블의 1번 데이터 row에 Lock을 건다.
트랜잭션 B가 Y테이블의 1번 데이터 row에 Lock을 건다.
트랜잭션 A가 Y테이블의 1번 데이터 row에 접근한다.
트랜잭션 B가 X테이블의 1번 데이터 row에 접근한다.
이렇게 되면 서로 다른 트랜잭션이 각자 자원을 점유하고, 상대방이 가진 자원을 얻기위해
무한히 대기하는 데드락이 발생한다.
이 서비스에서 비관적락을 사용할 때 나는 모임테이블의 특정 데이터 row에만 Lock을 사용했다.
그래서 별 문제없이 테스트를 성공했고, 서비스가 커져도 특정 모임에 많은 요청이 몰려서 문제를 야기할만한 케이스는 아니라고 생각한다. 하지만 다른 시스템인 경우 고려해야할만한 상황이 생기는데 아래에서 다시 이야기해보자.
비관적 락(Pessimistic Lock)의 문제점을 살펴보자.
예로 선착순 쿠폰 발급 시스템처럼 동시에 많은 트래픽이 몰리거나, 여러 테이블에 Lock을 걸면서 데드락 이슈가 발생하는 경우에는 비관적 락으로는 해결할 수 없다.
그러면 어떻게 해결할까? 아래 내용은 한 기업의 과제를 진행하면서 개인적으로 찾아본 내용이므로
그냥 이런게 있구나 하고 보면 될 것 같다.
Redis Sorted Set 활용
Redis의 Lua Script 활용
Kafka와 같은 메시징 큐 도입
API Gateway에서 처리율 제한 알고리즘 구현
처리율 제한기 미들웨어 도입
혹시 이와 관련된 자세한 내용이 궁금하다면
Redis의 Sorted Set을 활용한 내용은 우아한 테크톡의 영상을,
API Gateway, 처리율 제한기 미들웨어와 관한 내용은
”가상 면접 사례로 배우는 대규모 시스템 설계 기초” 책을 참고하면 된다.
낙관적 락(Optimistic Lock)과 비관적 락(Pessimistic Lock)은 싱글 DB 환경인 경우에만 적용 가능한 개념이다. 샤딩 또는 Replication 등을 통해 DB가 분산되어있는 환경이라면 적용할 수 없다.
그래서 마지막으로 다음 글에서 분산 DB 환경이라고 가정하고 분산 락(distributed Lock)을 이용해 볼 것이다.