동시성 문제 해결하기 V2 - 비관적 락(Pessimistic Lock)

LJH·2022년 7월 27일
18
post-thumbnail

0. 이 글을 쓰는 이유

이전 글에서 낙관적 락(Optimistic Lock)을 통해 동시성 문제를 해결해보려 했다.
하지만 데드락 이슈가 발생했고, 낙관적 락(Optimistic Lock)으로는 문제를 해결할 수 없었다.

그래서 이번에는 비관적 락(Pessimistic 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. 비관적 락(Pessimistic Lock)

  • 모든 트랜잭션은 충돌이 발생한다고 가정하고 우선 Lock을 거는 방법이다.

  • 낙관적 락(Optimistic Lock)과는 달리 DB의 Lock 기능을 이용한다. 주로 select for update 구문을 사용하고, 버전 정보는 사용하지 않는다. (사용하도록 할 수도 있다.)

  • 엔티티가 아닌 스칼라 타입(int, String 같은 타입)을 조회할 때도 사용할 수 있다.

  • 트랜잭션 커밋하기 전에, 데이터를 수정하는 시점에 미리 트랜잭션 충돌을 감지할 수 있다.

  • Lock을 획득할 때까지 트랜잭션은 대기하므로, Timeout을 설정할 수 있다.

1-1. 비관적 락(Pessimistic Lock)과 갭 락(Gap Lock)

이 글과는 크게 관련 없지만 알게된 내용이 있어서 써본다.

비관적 락을 사용하면 select ... for update 쿼리가 발생하는데, 이 때 갭락을 사용한다.

Where절에 고유한 검색 조건이 있는 경우에는 검색 조건에 명시한 row보다 앞에 있는 데이터에는 갭락을 사용하지 않지만, Where 절에 고유한 검색 조건이 없는 경우에는 갭락 또는 넥스트 키 락을 사용한다.

즉 비관적 락(Pessimistic Lock)으로 데이터를 조회하게 되면, 해당 트랜잭션이 끝나기 전까지는 비관적 락(Pessimistic Lock)을 사용한 데이터의 다음 데이터를 Insert할 수 없게 된다.

A테이블 1번 row에 비관적 락(Pessimistic Lock) → A테이블에 2번 row Insert 불가능 (갭락) 더 자세한 내용이 궁금하면 MySQL 문서를 확인해보자.

모임에 참여할 때, 모임 데이터에 비관적 락을 사용한다.
동시에 갭락을 사용하기 때문에 모임 데이터를 Insert할 수 없다.

즉 모임 참여 요청과 동시에 모임 생성 요청이 들어오면
모임 생성 요청은 갭락으로 인해 blocking되어 모임 참여 요청 트랜잭션이 끝날때까지
대기하는 상황이 발생할 것이다.

물론 blocking되는 시간은 그리 길지 않을 것 같다.


2. JPA의 Lock 사용

2-1. Lock 적용 범위

  • @Lock을 적용 가능한 범위는 아래와 같다.

    1. EntityManager.lock() , EntityManager.find(), EntityManager.refresh()
    2. Query.setLockMode()
    3. @NamedQuery
  • 아니 그럼 Querydsl과 같은 쿼리빌더로 작성한 코드로는 Lock을 사용할 수 없는건가? 싶었지만 Querydsl에서는 setLockMode(LockModeType lockMode) 메서드를 지원해준다.

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

락 모드타입설명
낙관적 락(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와 같음
  • 옵션에 대한 더 자세한 내용은 도서 ‘자바 ORM 표준 JPA 프로그래밍’를 참고하자.

3. 문제 상황 (데드락 발생 이유)

@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을 걸어버리면, 데드락이 발생하지 않을 것이다.

    1. 트랜잭션 A가 모임을 조회할 때 x-Lock을 건다.

    2. 트랜잭션 B가 같은 모임을 조회한다.

      • 트랜잭션 A가 이미 x-Lock을 걸어놨기 때문에조회하지 못하고 대기한다.
    3. 트랜잭션 A가 모든 작업을 마치고 commit 한다.

      • 트랜잭션이 commit되면 Lock또한 해제된다.
    4. 트랜잭션 B가 모임을 조회한다.

      • 마찬가지로 x-Lock을 걸고, 작업을 수행한다.
  • 이런식으로 각 트랜잭션들이 순차적으로 작업을 마칠때까지 다른 트랜잭이 접근하지 못하므로 데드락을 방지할 수 있는 것이다.


4. 비관적 락(Pessimistic Lock) 적용 후 테스트

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

@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 어노테이션을 이용하면 된다.

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);
}
  • 테스트 코드는 이전 글과 같다.

  • 인원제한인 2명인 모임에, 이미 1명(방장)이 참여중인 상황에서 10명이 동시에 참여 요청을 하는 테스트이다.

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

4-3. 테스트 결과

  • 테스트가 성공했고, select for update 쿼리가 발생한 것을 볼 수 있다.

  • DB에도 원하는대로 2개의 데이터만 저장된걸 볼 수 있다.

  • 나머지 9개의 요청들은 if문에서 걸러져 응답되는걸 볼 수 있다.

  • 해당 모임은 인원 마감이라는 응답을 하게 되고, 프론트에서는 이를 통해 인원이 마감됐다는 사용자에게 보여줄 수 있으니 요구사항을 만족한다.


5. 비관적 락(Pessimistic Lock)은 데드락이 발생하지 않을까?

처음에는 단순하게 먼저 접근하는 트랜잭션이 row에 락을 걸어버리니까 데드락 문제가 발생할 수 없지 않을까라고 생각했다. 하지만 비관적락을 적용해도 상황에 따라 데드락 문제는 발생할 수 있다.

  1. 트랜잭션 A가 X테이블의 1번 데이터 row에 Lock을 건다.

  2. 트랜잭션 B가 Y테이블의 1번 데이터 row에 Lock을 건다.

  3. 트랜잭션 A가 Y테이블의 1번 데이터 row에 접근한다.

    • 트랜잭션 B가 이미 Lock을 걸어놔서 대기한다.
  4. 트랜잭션 B가 X테이블의 1번 데이터 row에 접근한다.

    • 트랜잭션 A가 이미 Lock을 걸어놔서 대기한다.

이렇게 되면 서로 다른 트랜잭션이 각자 자원을 점유하고, 상대방이 가진 자원을 얻기위해
무한히 대기하는 데드락이 발생한다.

이 서비스에서 비관적락을 사용할 때 나는 모임테이블의 특정 데이터 row에만 Lock을 사용했다.
그래서 별 문제없이 테스트를 성공했고, 서비스가 커져도 특정 모임에 많은 요청이 몰려서 문제를 야기할만한 케이스는 아니라고 생각한다. 하지만 다른 시스템인 경우 고려해야할만한 상황이 생기는데 아래에서 다시 이야기해보자.


6. 비관적 락(Pessimistic Lock)은 만능이 아니다. 어떻게 해결할까?

비관적 락(Pessimistic Lock)의 문제점을 살펴보자.

  1. 성능 문제
    • 처음에도 이야기했듯이 비관적 락(Pessimistic Lock)은 모든 트랜잭션에 대해 Lock을 사용한다. Lock이 필요하지 않은 상황에서도 Lock을 사용하기 때문에 트래픽이 많은 경우에는 O(N^2) 정도로 성능이 저하된다는 문제점이 있다. 즉 다른 요청들이 다 blocking 돼서 타임아웃 날 수 있다.
  2. 여전히 발생할 수 있는 데드락
    • 이 부분은 목차 5에서 이야기 했다.

예로 선착순 쿠폰 발급 시스템처럼 동시에 많은 트래픽이 몰리거나, 여러 테이블에 Lock을 걸면서 데드락 이슈가 발생하는 경우에는 비관적 락으로는 해결할 수 없다.

그러면 어떻게 해결할까? 아래 내용은 한 기업의 과제를 진행하면서 개인적으로 찾아본 내용이므로
그냥 이런게 있구나 하고 보면 될 것 같다.

  1. Redis Sorted Set 활용

  2. Redis의 Lua Script 활용

  3. Kafka와 같은 메시징 큐 도입

  4. API Gateway에서 처리율 제한 알고리즘 구현

  5. 처리율 제한기 미들웨어 도입

혹시 이와 관련된 자세한 내용이 궁금하다면
Redis의 Sorted Set을 활용한 내용은 우아한 테크톡의 영상을,
API Gateway, 처리율 제한기 미들웨어와 관한 내용은
”가상 면접 사례로 배우는 대규모 시스템 설계 기초” 책을 참고하면 된다.


7. 마치며

낙관적 락(Optimistic Lock)과 비관적 락(Pessimistic Lock)은 싱글 DB 환경인 경우에만 적용 가능한 개념이다. 샤딩 또는 Replication 등을 통해 DB가 분산되어있는 환경이라면 적용할 수 없다.

그래서 마지막으로 다음 글에서 분산 DB 환경이라고 가정하고 분산 락(distributed Lock)을 이용해 볼 것이다.


전체 코드 Repository

Ref

0개의 댓글