Yeji's Tech Notes
article thumbnail
반응형

요구사항

지난 번 동시성 처리하기 방식으로 synchronized를 사용했습니다. 이번 글에서는 데이터베이스에서 제공하는 Lock을 이용해 동시성 처리를 해보겠습니다.

 

 

쿠폰 발급 서비스 동시성 처리하기 1 - (1/3) (feat. synchronized)

요구사항 멀티스레드 환경에서 쿠폰 발급 요청 시 동시성 처리하기 위한 방법으로 synchronized를 사용해보았습니다. Synchronized의 용도 멀티스레드 환경에서 여러 스레드가 하나의 공유 자원에 동

yejipro.tistory.com

 

데이터베이스 Lock 방식 (MYSQL 기준)

Pessimistic Lock (비관적 락)

트랜잭션 충돌이 발생한다고 가정하고 우선 락을 거는 방법입니다. 트랜잭션 안에서 서비스 로직이 진행되어야 합니다. 결국 실제로 데이터에 Lock을 걸어서 정합성을 맞추는 방법 exclusive lock을 걸게되면 다른 트랜잭션에서는 lock이 해제되기 전 데이터를 가져갈 수 없게됩니다. 데드락이 걸릴 수 있기 때문에 주의하여 사용해야됩니다.

Optimistic Lock (낙관적 락)

데이터 갱신시 충돌이 발생하지 않을 것으라고 보고(낙관적) 잠금을 거는 방법입니다. 결국 실제로 Lock을 이용하지 않고 버전을 이용함으로써 정합성을 맞추는 방법입니다. 먼저 데이터를 읽은 후에 update를 수행할 때 내가 읽은 버전이 맞는 지 확인 후 업데이트를 수행합니다. 내가 읽은 버전에서 수정사항이 생겼을 경우에는 application에서 다시 읽은 후 에 작업을 수행해야 합니다

동일한 데이터에 대한 여러 업데이트가 서로 간섭하지 않도록 방지하는 version이라는 속성을 사용합니다. 해당 설정은 @Version 어노테이션을 이용해 간단하게 구현할 수 있습니다.

Pessimistic Lock (비관적 락)

- LockModeType.PESSIMISTIC_READ

반복 읽기만하고 수정하지 않는 용도록 락을 걸 때 사용. 다른 트랜잭션에서 읽기는 가능함

- LockModeType.PESSIMISTIC_WRITE

일반적인 옵션 데이터베이스 쓰기 락 다른 트랜잭션에서 읽지도 쓰지도 못함

- LockModeType.PESSINISTIC_FORCE_INCREMENT

Version 정보를 사용하는 비관적 락

요구사항


1. 쿠폰은 선착순 이벤트 행사를 통해 발급됩니다.
2. 다운로드 가능수량은 총 100장입니다.

PESSIMISTIC_WRITE를 이용한 동시성 처리

CouponRepository

public interface CouponRepository extends JpaRepository<CouponEntity, UUID> {
	
    @Lock(LockModeType.PESSIMISTIC_WRITE)
    @Query("select c from CouponEntity c where c.id = :id")
    Optional<CouponEntity> findByIdWithOptimisticLock(UUID id);
    
}

 

CouponService

@Component
@RequiredArgsConstructor
public class CreateCouponPortAdapter implements CreateCouponPort {
	
    /**
     * 쿠폰 발급 동시성 처리
     */
    @Transactional
    @Override
    public CouponResponse issuanceCouponWithConcurrent(String id) {
        Coupon coupon = couponPort.issuanceCouponWithDatabaseLock(id);
        return CouponResponse.from(coupon);
    }

}

 

Coupon발급 테스트

@SpringBootTest
class CouponLockServiceTest {

	@Test
    @DisplayName("쿠폰 발급 동시성 테스트")
    void test_case_1() throws Exception {
        // given
        List<Coupon> coupons = createCouponPortAdapter.getCoupons();
        if (coupons.isEmpty()) {
            throw new IllegalStateException();
        }
        int threadCount = 100;
        UUID couponId = coupons.get(0)
                               .getCouponId();
        // when
        ExecutorService executor = Executors.newFixedThreadPool(5);
        CountDownLatch countDownLatch = new CountDownLatch(threadCount);
        // then
        for (int i = 0; i < threadCount; i++) {
            executor.submit(() -> {
                try {
                    CouponResponse couponResponse = couponLockService.issuanceCouponWithConcurrent(couponId.toString());
                    System.out.println(couponResponse.getNumberOfCoupons());
                } finally {
                    countDownLatch.countDown();
                }

            });
        }
        countDownLatch.await();

        Optional<Coupon> optionalCoupon = createCouponPortAdapter.findById(couponId.toString());
        if (optionalCoupon.isEmpty()) {
            throw new IllegalStateException();
        }

        Coupon coupon = optionalCoupon.get();
        assertEquals(900, coupon.getNumberOfCoupons());
    }
}

Coupon발급 테스트 (PessimisticLock) 결과

Optimistic Lock (낙관적 락)

Coupon Entity (Version 추가)

@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Table(name = "coupon")
@Entity
public class CouponEntity extends UpdatedEntity {

	// ...
    
    @Version
    private Long version;
    
}

CreateCouponPortAdapter

@Transactional
@Override
public Coupon issuanceCouponWithOptimisticLock(String couponId) throws InterruptedException {
    while (true) {
        try {
            CouponEntity couponEntity = couponRepository.findByIdWithOptimisticLock(UUID.fromString(couponId))
                                                        .orElseThrow(() -> new IllegalArgumentException("쿠폰 값을 찾을 수 없습니다."));
            couponEntity.issuanceCoupon();
            return couponEntity.convertToCoupon();
        } catch (Exception e) {
            System.err.println("오류 !!!" + e.getMessage());
            Thread.sleep(500000);
        }
    }
}

CouponRepository

public interface CouponRepository extends JpaRepository<CouponEntity, UUID> {
	
    @Lock(value = LockModeType.OPTIMISTIC)
    @Query("select c from CouponEntity c where c.id = :id")
    Optional<CouponEntity> findByIdWithOptimisticLock(UUID id);

}

Coupon발급 테스트 - OptimisticLock

    @Test
    @DisplayName("쿠폰 발급 동시성 테스트 - optimisticLock")
    void test_case_2() throws Exception {
        // given
        List<Coupon> coupons = createCouponPortAdapter.getCoupons();
        if (coupons.isEmpty()) {
            throw new IllegalStateException();
        }
        int threadCount = 100;
        UUID couponId = coupons.get(0)
                               .getCouponId();
        // when
        ExecutorService executor = Executors.newFixedThreadPool(32);
        CountDownLatch countDownLatch = new CountDownLatch(threadCount);
        // then
        for (int i = 0; i < threadCount; i++) {
            executor.submit(() -> {
                try {
                    couponLockService.issuanceCouponWithOptimisticLock(couponId.toString());
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                } finally {
                    countDownLatch.countDown();
                }

            });
        }
        countDownLatch.await();

        Optional<Coupon> optionalCoupon = createCouponPortAdapter.findById(couponId.toString());
        if (optionalCoupon.isEmpty()) {
            throw new IllegalStateException();
        }

        Coupon coupon = optionalCoupon.get();
        assertEquals(900, coupon.getNumberOfCoupons());
    }

Coupon발급 테스트 (OptimisticLock) 결과

Optimistic Lock처리과정에서의 문제

테스트 코드에서 결과 값을 보니 실제 결과 값과 기대 값이 다른 것을 확인할 수 있었습니다. 이에 대한 문제를 보니

CreateCouponPortAdapter에서 문제가 있음을 확인할 수 있었습니다. 현재 같은 쿠폰을 조회할 때의 과정에서 Exception이 발생해야되는데 실제 테스트코드를 확인해보면 Exception이 발생하지 않고 있습니다. 이는 Transactional이 원인으로 파악되어 확인해보았습니다.

 

Transactional은 충돌 났을 경우 rollback을 시키는데 해당 과정에서 Exception으로 넘어가지않고 rollback되어 재시도를 하지 못하는 문제가 발생했습니다. 그러므로 rollback을 시키기 전에 처리해야되는 과정이 필요했습니다. 그러므로 Transactional이 에러가 발생해 rollback 되기 전 처리해야되는 프로세스그 필요해 해당 문제를 AOP를 이용해 해결하였습니다.

 

DatabaseLockProperties 추가

재시도 횟수가 시간을 설정하기위한 properties 추가

@Getter
@Setter
@ConfigurationProperties(prefix = "retry")
public class DatabaseLockProperties {

    private Integer count;

    private Integer sleep;
}

OptimisticLockAspect AOP 추가

@RequiredArgsConstructor
@Slf4j
@Order(Ordered.LOWEST_PRECEDENCE - 1)
@Aspect
@Component
public class OptimisticLockAspect {

    private final DatabaseLockProperties properties;

    /**
     * RETRY_MAX_COUNT만큼 반복하여 OptimisticLockException이 발생 하면 retry
     *
     * @param joinPoint
     * @return
     * @throws Throwable
     */
    @Around("execution(* com.yeji.couponservice.application.service.CouponLockService.issuanceCouponWithOptimisticLock(..))")
    public Object doOneMoreRetryTransactionIfOptimisticLockExceptionThrow(ProceedingJoinPoint joinPoint) throws Throwable {
        Exception exceptionHolder = null;
        for (int retryCount = 0; retryCount <= properties.getCount(); 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(properties.getSleep());
            }
        }
        //100번 retry했음에a도 실패하는 경우.
        throw exceptionHolder;
    }

}

 

AOP를 사용해 정상적으로 값이 줄어드는 것을 확인 할 수 있었습니다.

 

소스코드

https://github.com/cyeji/coupon-service

 

GitHub - cyeji/coupon-service

Contribute to cyeji/coupon-service development by creating an account on GitHub.

github.com

 

반응형
profile

Yeji's Tech Notes

@Jop

포스팅이 좋았다면 "좋아요❤️" 또는 "구독👍🏻" 해주세요!