서로 다른 API요청이 거의 동시에 들어올 경우 동시성 문제가 발생할 수 있는 기능이 있어서 이를 해결하기 위한 부분을 고민하게 되었습니다.
해당 프로젝트에서는 관계형 데이터베이스로 Mysql을 주 DB로 활용하고 있습니다. Mysql은 특별히 트랜잭션의 격리수준을 변경 하지 않는 다면 Repetable Read로 설정되어있고 이때, Lost Update, Write Skew등의 이상현상이 발생할 수 있습니다.(해당 부분은 시리즈의 이전 포스트에 자세하게 정리되어있습니다.)
https://velog.io/@xogml951/트랜잭션-Isolation총-정리
@Entity
public class Article extends BaseEntity{
//********************************* static final 상수 필드 *********************************/
@Builder.Default
@Column(nullable = false)
private Integer participantNum = 1;
@Column(nullable = false)
private Integer participantNumMax;
@Builder.Default
@OneToMany(mappedBy = "article", fetch = FetchType.LAZY, cascade = {CascadeType.REMOVE,
CascadeType.PERSIST})
private List<ArticleMember> articleMembers = new ArrayList<>();
}
Article Entity는 작성한 글을 의미하는 객체이며 participantNum(현채 참여한 멤버 수 ), participantNumMax(작성자가 지정한 최대 참여 인원), articleMembers(현재 참여한 멤버)에 대한 정보를 가지고 있습니다.
특정 멤버가 참여 버튼을 누르게 다음 과 같은 로직을 진행하게 됩니다.
public ArticleMember participateMember(Member member) {
verifyDeleted();
verifyCompleted();
//1. 참여인원수를 확인하여 참여가 가능한지 확인.
verifyParticipatedMember(member);
verifyFull();
ArticleMember participateMember = ArticleMember.of(member, false, this);
//2. 참여가 가능하다면 현재 참여자 수 증가
this.participantNum++;
return participateMember;
}
@Transactional
public ResponseWithAlarmEventDto<ArticleOnlyIdResponse> participateArticle(String username, String articleId) {
Article article = articleRepository.findEntityGraphArticleMembersByApiIdAndIsDeletedIsFalse(
articleId)
.orElseThrow(() -> new NoEntityException(ErrorCode.ENTITY_NOT_FOUND));
User user = getUserByUsernameOrException(username);
Member member = user.getMember();
ArticleMember participateMember = article.participateMember(member);
//3. article에 ArticleMember추가
articleMemberRepository.save(participateMember);
}
@Slf4j
public class WorkerWithCountDownLatch extends Thread {
private CountDownLatch latch;
private Runnable runnable;
public WorkerWithCountDownLatch(String name, CountDownLatch latch, Runnable actualWork ) {
this.latch = latch;
setName(name);
this.runnable = actualWork;
}
@Override
public void run() {
try {
log.info("{} created, blocked by the latch...", getName());
latch.await();
log.info("{} starts at: {}", getName(), Instant.now());
this.runnable.run();
} catch (InterruptedException e) {
// handle exception
}
}
public void setRunnable(Runnable runnable) {
this.runnable = runnable;
}
}
@Test
void participateArticle_whenMultiTransactionConcurrentlyStart_thenOptimisticLockExceptionOrDeadLockExceptionOccurAndRetryApplied() throws Exception{
CountDownLatch countDownLatch = new CountDownLatch(1);
//동시실행될 스레드 정의.
WorkerWithCountDownLatch sorkimParticipate = new WorkerWithCountDownLatch(
"sorkim participate", countDownLatch, () ->
{
articleService.participateArticle("sorkim",
articleOnlyIdResponse.getArticleId());
});
WorkerWithCountDownLatch hyenamParticipate = new WorkerWithCountDownLatch(
"hyenam participate", countDownLatch, () ->
{
articleService.participateArticle("hyenam",
articleOnlyIdResponse.getArticleId());
});
sorkimParticipate.start();
hyenamParticipate.start();
Thread.sleep(10);
log.info("-----------------------------------------------");
log.info(" Now release the latch:");
log.info("-----------------------------------------------");
//count 값을 내리면 await가 풀리면서 실행됨.
countDownLatch.countDown();
Thread.sleep(2000);
//then
//LostUpdate 발생 하지않는지
assertThat(
articleRepository.findByApiIdAndIsDeletedIsFalse(articleOnlyIdResponse.getArticleId()).get()
.getParticipantNum()).isEqualTo(3);
}
비관적 락은 실제 DB에 존재하는 Lock으로 자원(특정 테이블의 Row, Gap Lock등 DBMS종류마다 다양함) 접근 시 Lock이 걸려 있을 경우 이를 획득해야만 접근가능하다.
낙관적 락은 실제 DB에 존재하는 Lock이 아니라 Application Level에서 Lock과 유사한 동작을 하도록 논리적으로 구현한것을 말한다.
@Entity
public class Article{
@Version
private Long version;
}
/**
* @Transactional annotation보다 먼저 실행되어야함.
* Transactional annotation Default Order가 Integer.Max값임
* Transactional으로 생성되는 AOP 보다 바로 먼저 실행되어야 하므로 Order를 Integer.Max -1로 지정
*/
@Slf4j
@Order(Ordered.LOWEST_PRECEDENCE - 1)``
@Aspect
public class OptimisticLockAspect {
@Value("${retry.count}")
public Integer retryMaxCount;
@Value("${retry.sleep}")
public Integer retryInterval;
/**
* RETRY_MAX_COUNT만큼 반복하여 OptimisticLockException이 발생 하면 retry
* @param joinPoint
* @return
* @throws Throwable
*/
@Around("partner42.moduleapi.aop.PointCut.allPublicArticleService()")
public Object doOneMoreRetryTransactionIfOptimisticLockExceptionThrow(
ProceedingJoinPoint joinPoint) throws Throwable {
Exception exceptionHolder = null;
for (int retryCount = 0; retryCount <= retryMaxCount; retryCount++) {
try {
log.info("[RETRY_COUNT]: {}", retryCount);
return joinPoint.proceed();
} catch (OptimisticLockException | ObjectOptimisticLockingFailureException | CannotAcquireLockException e) {
log.error("{} 발생", e.getClass());
exceptionHolder = e;
//RETRY_WAIT_TIME ms 쉬고 다시 시도
//for loop에서 sleep busy waiting이 된다는 경고가 뜸 무슨의미일지 찾아보자.
//interval을 주는 다른 방법이 있을지 찾아 봐야함.
Thread.sleep(retryInterval);
}
}
//3번 retry했음에a도 실패하는 경우.
throw exceptionHolder;
}
}
public class PointCut {
@Pointcut("execution(public * partner42.moduleapi.service.article.ArticleService.*(..))")
public void allPublicArticleService() {
}
}
@Test
void participateArticle_whenMultiTransactionConcurrentlyStart_thenOptimisticLockExceptionOrDeadLockExceptionOccurAndRetryApplied() throws Exception{
CountDownLatch countDownLatch = new CountDownLatch(1);
//동시실행될 스레드 정의.
WorkerWithCountDownLatch sorkimParticipate = new WorkerWithCountDownLatch(
"sorkim participate", countDownLatch, () ->
{
articleService.participateArticle("sorkim",
articleOnlyIdResponse.getArticleId());
});
WorkerWithCountDownLatch hyenamParticipate = new WorkerWithCountDownLatch(
"hyenam participate", countDownLatch, () ->
{
articleService.participateArticle("hyenam",
articleOnlyIdResponse.getArticleId());
});
sorkimParticipate.start();
hyenamParticipate.start();
Thread.sleep(10);
log.info("-----------------------------------------------");
log.info(" Now release the latch:");
log.info("-----------------------------------------------");
//count 값을 내리면 await가 풀리면서 실행됨.
countDownLatch.countDown();
Thread.sleep(2000);
//then
//LostUpdate 발생 하지않는지
assertThat(
articleRepository.findByApiIdAndIsDeletedIsFalse(articleOnlyIdResponse.getArticleId()).get()
.getParticipantNum()).isEqualTo(3);
}