트랜잭션 동시성 문제 해결- Optimistic Lock과 AOP활용

taehee kim·2023년 4월 17일
3

트랜잭션 고립성

목록 보기
2/2

0. 배경

서로 다른 API요청이 거의 동시에 들어올 경우 동시성 문제가 발생할 수 있는 기능이 있어서 이를 해결하기 위한 부분을 고민하게 되었습니다.

0-1. 기능 설명

  • 해당 프로젝트는 사람들을 모집하는 기능을 제공하는 프로젝트입니다.
  • 그 방식중 하나로 글을 작성하고 이 글에 인원수를 지정해두면 다른 사용자들이 이를 보고 참여 버튼을 눌러서 모집에 참여할 수 있습니다.

0-2. 문제가 될 수 있는 시나리오

  • 만약 해당 글에 잔여 모집 인원수가 1명인 상황에서 거의 동시에 두 유저가 참여버튼을 누르게 되면 조금더 늦게 누른 유저는 참여가 되지 않아야 하지만, 두 유저가 모두 참여되는 문제가 발생합니다.

1. 문제 발생 원인

Mysql Default 격리수준인 Repetable Read에서 발생하는 Lost Update 이상현상

  • 해당 프로젝트에서는 관계형 데이터베이스로 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);
       
    }
  • 이때 participantNum을 읽고 변경하는 과정에서 Lost Update라는 이상현상이 발생할 수 있습니다. Lost Update는 간단히 말하면 앞에 Update가 발생하기는 하지만 이를 다른 트랜잭션이 읽지 않고 다시 수정하기 때문에 실질적으로 앞전의 Update가 효과를 발휘하지 못하고 덮어씌워지는 이상현상입니다.

2. 문제 발생 시나리오 테스트

문제가 발생하는 시나리오

  • 만약 해당 요청을 두명의 서로 다른 유저가 거의 동시에 실행하여 하나의 트랜잭션이 commit하기 전에 다른 트랜잭션이 시작했다고 가정합시다.
    • 이때 participantNum: 1, participantNumMax: 2 이므로 한명이 더 참여할 수 있습니다.
    1. 참여인원수를 확인하여 참여가 가능한지 확인. 에서 두 트랜잭션은 participantNum을 모두 1로 읽습니다 이 때문에 예외가 발생하지 않고 두 멤버가 모두 참여할 수 있게 됩니다.
  • 이후 this.participantNum++;을 두 경우 모두 실행하고 pariticipantNum은 2가 됩니다.
  • ArticleMember는 두 멤버가 모두 save되어 Article에 포함된 ArticleMember는 3명이 됩니다.

CountDownLatch를 활용한 테스트코드

  • CountDownLatch는 멀티 스레드 환경에서 스레드 실행을 동기화 시켜 동작시키기 위한 모듈이다.
  • count 값을 포함하고 있으며 count값이 0이 되기 전까지 await하고 있다가 0이 되면 실행된다. 이 기능을 활용하여 두 트랜잭션을 거의 동시에 실행할 수 있다.
@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);

    }

테스트 결과

  • participantNum이 2가 되고 하나의 트랜잭션에서 예외가 발생해야하지만 테스트가 통과해버린다.

3. 문제 해결 과정

3-1. Lock 활용.

Pessimistic Lock

비관적 락은 실제 DB에 존재하는 Lock으로 자원(특정 테이블의 Row, Gap Lock등 DBMS종류마다 다양함) 접근 시 Lock이 걸려 있을 경우 이를 획득해야만 접근가능하다.

  • Pessimistic Lock 분류
    • S-, W-lock: S-lock은 shared lock 으로 같은 shared lock을 여럿이서 걸 수 있다. 하지만 w-lock의 경우 이미 s-lock 혹은 w-lock을 다른 트랜잭션에서 걸어 놓은 경우 이들이 모두 반환될 때까지 대기해야만한다.
    • Mysql innodb에서는 Gap Lock, record lock, next key lock등 여러 대상에 대해 록을 걸 수 있다.
    • 대부분의 DBMS의 default격리 수준에서 update, insert등을 수행할 때에는 w-lock을 획득해야하며 이를 트랜잭션 종료시점에 반환한다.(기본적인 데이터 일관성 문제나 rollback시의 문제를 해결하기 위해서이다.)

Optimistic Lock

낙관적 락은 실제 DB에 존재하는 Lock이 아니라 Application Level에서 Lock과 유사한 동작을 하도록 논리적으로 구현한것을 말한다.

  • 낙관적 락의 동작 방식(Version을 활용한 방식): 조회 시점과 수정 시점의 version이 다르면 Rollback 하는 방식으로 구현한다.
    1. 영속성 컨텍스트에 존재하는 엔티티는 조회 시점에 버전 값(1)을 가지고 있다.
    2. 수정 시점에 update set version = version + 1 where version = 1 와 같은 쿼리가 보내진다.
    3. 만약 다른 트랜잭션에서 엔티티 조회 시점과 수정 사이에서 먼저 수정을 했다면 version 값이 1이 아니므로 update쿼리에 의해서 영향을 받는 row가 없을 것이다.(이 경우 이미 변경된 것으로 보고 OptimisticLockException을 발생 시키며 Rollback 시킨다.)
  • 위의 예시에서 본 것처럼 낙관적 락은 이름의 의미와 같게 충돌이 발생하지 않는 상황을 가정하고 로직을 진행한다.

어떤 Lock을 사용해야할까

  • 만약 충돌이 드물게 발생할 것으로 예상된다면 낙관적 락 방식이 적합하다.
    • 낙관적 락의 경우 DB에 존재하는 Lock이 아니기 때문에 DeadLock이 발생하지 않으며 충돌이 발생하지 않는 경우에는 Lock이 없는 것처럼 거의 성능 저하없이 진행이 가능하다.
    • 하지만 충돌이 자주 발생하는 상황(선착순 서비스)이라면 충돌이 매우 자주 발생해 Rollback이 계속해서 발생하므로 오히려 비효율적일 수 있다.
  • 충돌이 자주 발생한다면 명시적으로 비관적 락을 활용하자.
    • 단, 이경우 DeadLock발생을 유의해야한다.
    • s-lock, w-lock중 s-lock은 읽기는 동시에 수행할 수 있다는 장점이 있어서 좋아 보이지만 DeadLock 발생이 더 쉬울 수 있으므로 적절히 선택해야한다.

본인의 경우에서는 낙관적 락이 적합하다고 판단하여 낙관적 락을 선택하였습니다.

  • JPA에서는 @Version Annotation을 활용하면 간단하게 낙관적 락을 구현할 수 있습니다.
@Entity
public class Article{
    @Version
    private Long version;
}

3-2. Retry 횡단 관심사 분리(AOP활용)

낙관적락 충돌로 인한 Rollback시 자동 Retry

  • 낙관적 락을 활용하여 충돌이 발생하는 경우를 방지하게 되면 데이터처리 관점에서는 문제가 없지만 사용자 입장에서는 500에러를 받으며 프로그램의 신뢰성과 사용성이 낮다고 판단하게 될 것입니다.
  • 이를 위해서 Retry로직을 구현해야합니다.

Retry는 횡단 관심사이다.

  • Retry는 핵심 비지니스 로직이 아닙니다.(핵심 비지니스 로직은 방에 참여하고 참여가 가능한지등을 검증하는 로직이라고 볼 수 있습니다.)
  • 또한 Retry로직은 여러 API 혹은 메서드에 대해서 낙관적 락이 발생한다면 공통적으로 적용되는 로직입니다.
  • 이렇게 Retry로직 처럼 핵심 비지니스 로직은 아니면서 여러 메서드에 걸쳐서 공동으로 사용될 수 있는 로직을 Crousscutting Concerns(횡단 관심사)라고 하며 Spring framework에서는 AOP라는 기술을 통해 핵심 비지니스 로직과 분리하여 코드를 작성할 수 있습니다.

AOP 적용 원리

  • Spring AOP는 AOP를 적용할 대상 빈 객체와 같은 타입(혹은 부모 타입의 서브 타입)의 프로시 객체를 CGLIB, JDK 동적프록시 등의 런타임에 바이너리 코드를 조작할 수 있는 기술을 통해 생성하고 이를 빈 후처리기를 통해 Bean등록 시점에 프록시 객체를 대체 등록하는 방식을 통해 구현됩니다.
  • 프록시 객체는 원본 객체에 연관관계를 가지고 있어 본인의 로직과 함께 원본 객체의 로직을 호출할 수 있고 디자인 패턴중에는 데코레이터 패턴이나 프록시 패턴을 활용하여 구현됩니다.
  • AOP를 통해 적용하려는 공통 관심사와 그 대상을 Aspect라고 하며 대상을 특정하는 부분은 PointCut, 공통화하려는 로직은 Advice라고 합니다.

Service 객체의 @Transactional로 생성되는 로직보다 바로 앞에서 Retry가 적용되도록 구현

  • @Transactional또한 AOP가 적용된 대표적인 기술입니다. 이때 @Order을 통해 Advice가 적용될 순서를 지정할 수 있는데 @Transactional 은 Order가 가장 낮은 순위로 되어있기 때문에 항상 가장 마지막에 적용됩니다.
  • Retry AOP는 @Transactional보다 바로 앞에서 먼저 적용되어야 합니다.

구현

  • @Around를 통해 Pointcut을 지정해주고 @Aspect내에 Advice 코드를 작성해준다.
  • ProceedingJoinPoint는 원본 객체를 말한다. proceed()를 통해 원본 객체의 로직을 진행시킨다.
  • @Order는 Ordered.LOWEST_PRECEDENCE - 1 로 값을 주었는데 @Transactional보다 하나더 작은 값으로 항상 바로 앞에서 먼저 실행된다.
  • Retry 로직 횟수를 지정해야 무한하게 Retry되는 경우를 방지할 수 있다.
/**
 * @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() {

    }
}

4. 낙관적 락과 Retry Aspect 적용 후 테스트

@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);

    }

  • 낙관적 락에 의해서 뒤에 update를 하는 트랜잭션은 Rollback되고 다시 Retry를 하여 테스트가 성공하는 것을 확인할 수 있습니다.
profile
Fail Fast

0개의 댓글