기존에 경기를 생성하고 참가 가능한 인원을 설정할 수 있습니다. 특정 회원 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--;
}
@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);
}
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
한 트랜잭션에서 사용하고 있는 데이터는 다른 트랜잭션에서 접근할 수 없는 것을 의미를 합니다.
마치 락을 생성한 것처럼 다른 트랜잭션은 제어권을 받기 이전에 실행을 할 수 없다.
데이터의 정합성에 뛰어나지만 성능적인 이슈가 발생을 할 수 있기 때문에 현업에서는 사용하지 않는다고 학습을 했습니다.
낙관적 락 & 비관적 락
기존에 프로젝트를 하면서 비관적 락을 사용한 경험이 있습니다.
당시에 데이터의 정합성이 제일 중요하다고 생각하여 비관적 락을 사용을 하였지만 낙관적 락은 충돌이 드물게 발생할 것으로 예상하면 낙관적 락을 사용하는게 적합하다.
이번 프로젝트에서는 충돌이 발생의 경우가 적고 충돌이 발생을 하여도 Facade 로직을 통하여 재요청을 하여 데이터의 정합성을 맞출 수 있다고 판단하여 낙관적 락을 사용을 하였습니다. 또한 비관적 락을 사용을 하면 잘못하면 데드락이 발생을 할 수 있기 때문에 결과적으로 낙관적 락을 사용을 하였습니다.
비관적, 낙관적 락 정리 및 비관적 락 문제 해결
https://pos04167.tistory.com/177
낙관적 락은 실제 DB에 존재하는 Lock이 아닙니다. 비관적 락은 실제로 락을 걸어서 제어권이 없는 트랜잭션이 접근을 못하게 막지만 낙관적 락은 Application에서 개발자가 유사한 동작을 하도록 구현을 하는 방식이빈다.
엔티티 조회
먼저, 엔티티를 데이터베이스에서 조회하여 영속성 컨텍스트에 올립니다. 이때, 조회한 엔티티의 Version 값을 함께 가져옵니다.
엔티티 수정
엔티티를 수정하는 동안 영속성 컨텍스트에서는 엔티티의 상태를 관리합니다. 이때, 다른 사용자가 같은 엔티티를 수정하지 않도록 Version 값을 검사합니다.
update set version = version + 1 where version = 1
수정 시도
엔티티를 수정한 후에는 해당 엔티티의 Version 값을 사용하여 데이터베이스에 UPDATE를 시도합니다.
버전 비교
UPDATE를 수행하기 전에 데이터베이스에 저장된 엔티티의 Version 값과 영속성 컨텍스트에 올라간 엔티티의 Version 값을 비교합니다.
충돌 감지:
만약 데이터베이스에 저장된 엔티티의 Version 값과 영속성 컨텍스트에 올라간 엔티티의 Version 값이 다르다면, 다른 사용자에 의해 해당 엔티티가 수정되었다는 의미입니다. 이 경우, 업데이트를 중단하고 충돌이 발생했다는 정보를 사용자에게 알립니다.
재시도:
충돌이 발생했을 경우, 사용자는 다시 수정을 시도할 수 있습니다. 이때, 수정 작업은 위의 단계를 다시 반복하게 됩니다.
낙관적 락은 충돌이 적은 상황에서 성능이 좋지만, 충돌이 발생하는 경우 재시도를 해야하는 단점이 있습니다. 하지만 일반적으로 충돌이 적은 경우가 많기 때문에 낙관적 락은 효과적인 동시성 제어 기법 중 하나입니다.
import javax.persistence.*;
@Version
private Long version;
@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을 통하여 문제를 해결을 하였습니다. 그런데 이 방식이 올바른 방식인지 아직 모르겠습니다. 이 부분에 대해서는 조금 더 찾아보고 비교를 해야되는 시간이 필요합니다. 혹시 이 부분에 대해서 아시는 분 있으시면 댓글을 달아주시면 감사하겠습니다.
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/