트랜잭션 동시성 문제 해결- Optimistic Lock

Mugeon Kim·2023년 7월 21일
0
post-thumbnail

1. 서론

  • 프로젝트를 진행을 하면서 특정 경기에 참가하는 인원을 멀티 스레드 환경에서 참가 요청 API를 요청을 하였을 때 데이터의 정합성이 맞지 않는 문제점이 발견을 하였습니다.

1-2. 문제 발생 이유

  • MySQL 기본 격리수준인 Repeatable Read에서 발생하는 Lost Update 문제 발생
    • 프로젝트에서 DB를 MySQL로 사용을 하였습니다. MySQL에서 기본적인 격리수준은 Repeatable Read 입니다. 이때 update를 발생하는 API를 동시에 여러명의 사람들이 요청을 한다면 이상현상으로 인하여 데이터의 정합성에 문제가 발생을 합니다.

1-3. 문제 발생 시나리오

  • 기존에 경기를 생성하고 참가 가능한 인원을 설정할 수 있습니다. 특정 회원 A가 경기 C에 참가를 하게 된다면 MemberCompetition 테이블에 Insert 되고 이에 따른 Competition의 참가 가능한 사용자의 수가 -1이 됩니다.

  • 이것을 코드로 살펴보겠습니다.

    @Override
    @Transactional
    public void joinCompetition(LoginUserDto loginUserDto, Long competitionId) {

        preventionDuplicateParticipation(loginUserDto, competitionId);

        Member member = memberRepository.findById(loginUserDto.getMemberId())
                .orElseThrow(() -> new NotFoundMemberId(loginUserDto.getMemberId()));

        Competition competition = competitionRepository.findById(competitionId)
                .orElseThrow(() -> new NotFoundCompetitionId(competitionId));

        decreaseParticipantsCountIfPossible(competition);

        MemberCompetition memberCompetition = MemberCompetition.builder()
                .member(member)
                .competition(competition)
                .build();

        memberCompetitionRepository.save(memberCompetition);
    }
    
    private void preventionDuplicateParticipation(LoginUserDto loginUserDto, Long competitionId) {
        boolean isMemberParticipating = memberCompetitionRepository.existsByMemberIdAndCompetitionId(
                loginUserDto.getMemberId(), competitionId);

        checkMemberParticipation(loginUserDto, isMemberParticipating);
    }

    private void decreaseParticipantsCountIfPossible(Competition competition) {
        if(competition.getParticipants() != 0){
            competition.decreaseParticipantsCount();
        } else {
            throw new ParticipantsWereInvitedParticipateException();
        }
    }
    
    public void decreaseParticipantsCount() {
        this.participants--;
    }
  • 해당 코드는 특정 회원을 찾은 다음에 경기에 참가를 시켜 MemberCompetition에 Insert 이후에 Competition의 참가 가능한 사용자의 수를 -1 하는 로직입니다. 이것을 테스트 코드로 살펴보면
 @Test
    @DisplayName("싱글 스레드 환경 테스트")
    public void CreateJoinMemberWithSingleThread() throws Exception {
        //given
        LoginUserDto userDto = LoginUserDto.builder()
                .memberId(1L)
                .build();
        //when
        facade.joinCompetition(userDto, 1L);

        //Then
        Competition competition = competitionRepository.findById(1L)
                .orElseThrow(RuntimeException::new);

        Assertions.assertThat(competition.getParticipants()).isEqualTo(99);
    }
  • 임의 사용자와 경기를 @BeforeEach로 생성한 이후 참가를 요청을 하였을 때 검증하는 로직을 테스트 코드로 작성을 하였습니다.

  • 위 사진과 같이 정상적으로 동작하는 방법을 찾을 수 있습니다.

  • 그러면 문제의 상황을 살펴보겠습니다.

  @Test
    @DisplayName("멀티쓰레드 환경에서 테스트")
    public void CreateJoinMemberWithMultiThread() throws Exception {

        LoginUserDto userDto = LoginUserDto.builder()
                .memberId(1L)
                .build();

        int threadCount = 100;

        ExecutorService executorService = Executors.newFixedThreadPool(32);

        CountDownLatch latch = new CountDownLatch(threadCount);

        for (int i = 0; i < threadCount; i++) {
            executorService.submit(() -> {
                try {
                    facade.joinCompetition(userDto, 1L);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                } finally {
                    latch.countDown();
                }
            });
        }
        latch.await();
        Competition competition = competitionRepository.findById(1L)
                .orElseThrow();

        assertThat(competition.getParticipants()).isEqualTo(0);
    }
  • 위에 테스트 코드는 100명이 참가 가능한 경기에서 100명이 멀티 스레드 환경에서 API 요청을 하였을 때 0을 기대를 하였지만 데이터의 정합성이 맞지 않는 모습을 볼 수 있습니다.
  • 이러한 동시성 문제의 원인은 MySQL Repeatable Read 격리수준에 이상현상 Lost Update가 발생을 하였다고 생각하여 MySQL 레벨에서 제어하기 위한 Lock을 선택을 하였습니다.

2. 본론

2-2. MySQL 격리수준

MySQL 격리수준

  • 격리 수준이란 여러 트랜잭션이 동시에 처리될 때 특정 트랜잭션이 다른 트랜잭션에서 변경하거나 조회하는 데이터를 볼 수 있게 허용할지 말지를 결정하는 것입니다.

Read Uncommitted

  • 트랜잭션 A가 COMMIT을 하지 않아도 변경된 데이터를 조회한다.

  • 이때 어떤 트랜잭션에서 처리한 작업이 완료되지 않았는데도 다른 트랜잭션에서 볼 수 있는 현상을 더티 리드라 한다.


Read Committed

  • Read Committed는 오라클에서 기본적으로 사용되는 격리 수준이다. 이 레벨은 기존의 Uncommitted의 문제인 더티 리드를 해결을 해준다. 위에 사진을 보면 Commit이 되어야지 변경된 데이터를 볼 수 있다.

  • 그러면 어떻게 이 문제점을 해결할까?

    • 이 문제를 해결을 하는 이유는 언두로그에 기존의 데이터를 저장하고 언두로그를 읽기 때문이다.
  • 이때 문제점이 있다. NON-REPEATABLE READ라는 부정합의 문제가 있다.

  • 트랜잭션을 공부하면 ACID를 찾을 수 있다. 트랜잭션의 특징에서 일관성이 있다. 일관성은 트랜잭션은 같은 쿼리를 실행을 하였을 때 항상 같은 결과를 받는다. 하지만 이 수준에서는 Commit이 되면 변경된 데이터를 받는다.

Repeatable Read

  • 트랜잭션 범위 내에서 조회한 데이터는 항상 동일한 데이터를 읽을 수 있도록 허용하는 것을 의미합니다. MySQL InnoDB에서 기본으로 사용되는 격리 수준입니다.

  • 이전의 NON-REPEATABLE READ의 문제를 제거합니다. 하지만 이전에 조회를 하였던 테이블에 변경이 있다면 데이터의 불일치가 있습니다.

  • NON-REPEATABLE READ을 해결할 수 있는 방법은 InnoDB 트랜잭션은 고유한 트랜잭션 번호를 가지며 언두 영역에 백업된 모든 레코드에 트랜잭션 번호가 포함되어 있어서 트랜잭션의 일관성을 유지할 수 있었다.

Serializable

  • 한 트랜잭션에서 사용하고 있는 데이터는 다른 트랜잭션에서 접근할 수 없는 것을 의미를 합니다.

  • 마치 락을 생성한 것처럼 다른 트랜잭션은 제어권을 받기 이전에 실행을 할 수 없다.

  • 데이터의 정합성에 뛰어나지만 성능적인 이슈가 발생을 할 수 있기 때문에 현업에서는 사용하지 않는다고 학습을 했습니다.

2-3. 문제 해결

낙관적 락 & 비관적 락

  • 기존에 프로젝트를 하면서 비관적 락을 사용한 경험이 있습니다.
    당시에 데이터의 정합성이 제일 중요하다고 생각하여 비관적 락을 사용을 하였지만 낙관적 락은 충돌이 드물게 발생할 것으로 예상하면 낙관적 락을 사용하는게 적합하다.

  • 이번 프로젝트에서는 충돌이 발생의 경우가 적고 충돌이 발생을 하여도 Facade 로직을 통하여 재요청을 하여 데이터의 정합성을 맞출 수 있다고 판단하여 낙관적 락을 사용을 하였습니다. 또한 비관적 락을 사용을 하면 잘못하면 데드락이 발생을 할 수 있기 때문에 결과적으로 낙관적 락을 사용을 하였습니다.

비관적, 낙관적 락 정리 및 비관적 락 문제 해결
https://pos04167.tistory.com/177

Optimistic Lock

낙관적 락은 실제 DB에 존재하는 Lock이 아닙니다. 비관적 락은 실제로 락을 걸어서 제어권이 없는 트랜잭션이 접근을 못하게 막지만 낙관적 락은 Application에서 개발자가 유사한 동작을 하도록 구현을 하는 방식이빈다.

  1. 엔티티 조회
    먼저, 엔티티를 데이터베이스에서 조회하여 영속성 컨텍스트에 올립니다. 이때, 조회한 엔티티의 Version 값을 함께 가져옵니다.

  2. 엔티티 수정
    엔티티를 수정하는 동안 영속성 컨텍스트에서는 엔티티의 상태를 관리합니다. 이때, 다른 사용자가 같은 엔티티를 수정하지 않도록 Version 값을 검사합니다.

 update set version = version + 1 where version = 1  
  1. 수정 시도
    엔티티를 수정한 후에는 해당 엔티티의 Version 값을 사용하여 데이터베이스에 UPDATE를 시도합니다.

  2. 버전 비교
    UPDATE를 수행하기 전에 데이터베이스에 저장된 엔티티의 Version 값과 영속성 컨텍스트에 올라간 엔티티의 Version 값을 비교합니다.

  3. 충돌 감지:
    만약 데이터베이스에 저장된 엔티티의 Version 값과 영속성 컨텍스트에 올라간 엔티티의 Version 값이 다르다면, 다른 사용자에 의해 해당 엔티티가 수정되었다는 의미입니다. 이 경우, 업데이트를 중단하고 충돌이 발생했다는 정보를 사용자에게 알립니다.

  4. 재시도:
    충돌이 발생했을 경우, 사용자는 다시 수정을 시도할 수 있습니다. 이때, 수정 작업은 위의 단계를 다시 반복하게 됩니다.

낙관적 락은 충돌이 적은 상황에서 성능이 좋지만, 충돌이 발생하는 경우 재시도를 해야하는 단점이 있습니다. 하지만 일반적으로 충돌이 적은 경우가 많기 때문에 낙관적 락은 효과적인 동시성 제어 기법 중 하나입니다.

버전 추가

  • 해당 엔티티에 VERSION을 추가를 합니다.
 import javax.persistence.*;
 
 @Version
 private Long version;
  • 해당 DB에거 값을 불러오는 Repository에 @Lock 추가
    @Lock(LockModeType.OPTIMISTIC)
    @Query("select m from Member m where m.id = :memberId")
    Optional<Member> findByIdUpdateForVersion(@Param("memberId") Long memberId);

충돌이 발생을 하였을 때 재요청 로직

@Service
public class OptimisticFacade {
    private final MemberCompetitionService memberCompetitionService;

    public OptimisticFacade(MemberCompetitionService memberCompetitionService) {
        this.memberCompetitionService = memberCompetitionService;
    }

    public void joinCompetition(LoginUserDto loginUserDto, Long competitionId) throws InterruptedException {
        while (true) {
            try {
                memberCompetitionService.joinCompetition(loginUserDto, competitionId);
                break;
            } catch (OptimisticLockException | ObjectOptimisticLockingFailureException | CannotAcquireLockException e) {
                e.printStackTrace();
                Thread.sleep(50);
            }
        }
    }
}
  • 만약에 충돌이 발생하여 예외가 발생을 한다면 Thread Sleep을 하고 재요청을 하는 로직을 처리를 합니다.

충돌이 발생하면 로직
지금은 Thread Sleep을 통하여 문제를 해결을 하였습니다. 그런데 이 방식이 올바른 방식인지 아직 모르겠습니다. 이 부분에 대해서는 조금 더 찾아보고 비교를 해야되는 시간이 필요합니다. 혹시 이 부분에 대해서 아시는 분 있으시면 댓글을 달아주시면 감사하겠습니다.

Optimistic Lock 추가한 이후 데이터 정합성 Test Code를 통한 검증

  • 위에 사진과 아래의 결과를 보면 정상적으로 참가자의 수가 100명에서 0으로 줄어들고 버전이 100으로 update가 되어있는 것을 확인할 수 있습니다.

3. 결론

  • 동시성을 해결하기 위하여 비관적, 낙관적 락을 둘다 사용을 해보았다. 해당 로직 및 요구사항에 따라 선택적으로 사용을 해야되며 두 방식의 장단점을 체크를 해야된다.
  • 최근 대기업 게임회사에 면접을 갔었는데 이 부분에 대해서 질문이 들어왔다. 해당 내용을 설명하고 이야기를 하면서 현업에서는 비관적 락을 잘못하면 데드락이 많이 잡히기 때문에 나관적 락을 우선시 선택을 한다고 이야기를 들었다.

4. 참고

https://steemit.com/kr/@yjiq150/mysql-innodb-lock-and-deadlock

https://mangkyu.tistory.com/299

https://hudi.blog/transaction-isolation-level/

https://hudi.blog/jpa-concurrency-control-optimistic-lock-and-pessimistic-lock/

profile
빠르게 실패하고 자세하게 학습하기

0개의 댓글