락을 테스트할 테스트 코드

쓰레드를 일정 갯수만큼 동시에 실행시키위한 코드이다

final int threadCount = 100;
ExecutorService executorService = Executors.newFixedThreadPool(32);
CountDownLatch latch = new CountDownLatch(threadCount);
// when
final Long currentMemberId = 1L;
for (int i = 0; i < threadCount; i++) {
    int ad = i + 1;
    executorService.submit(() -> {
        try {
            boolean b = couponUserService.issueCouponToUser(couponId, currentMemberId);
 
        } catch (Exception e) {
//                    System.out.println("에러 확인 : " + ad + " : " + e);
        } finally {
            latch.countDown();
        }
 
    });
}
latch.await();

락의 종류

락 및 트랜잭셕에 대한 기본 설명

락 및 트랜잭션 정리

synchronized

synchronized를 사용하는 방법

  1. 메서드 선언부에 사용 아래의 그림과 같이 메서드를 작성할 때 선언함으로써 들어갈 때 락을 걸어줄 수 있다.
@Transactional
public synchronized boolean issueCouponToUser(Long couponId, Long userId) {
	
	    Coupon coupon = couponByIdAndCheck(couponId);
 
	    // issue coupon
	     LocalDateTime expiredTime = coupon.issueCoupon();
		...
	
	    return true;            
}

여기서 문제

다만 위와 같이 작성을 하는 경우 Transactional은 Spring AOP 형식으로 동작을 하게 되는데 커밋 되기 전에 락이 풀려서 다른 쓰레드가 쿠폰 객체를 점유할 수 있어서 문제가 발생한다.

sequenceDiagram
    participant Thread1 as 스레드 1
    participant coupon as Coupon
    participant Thread2 as 스레드 2
    
    
    Note right of coupon: quantity = 100
    Thread1->>coupon: tx.start()
    Thread1->>coupon: couponService.issue() 발급
    activate Thread1
    Note over Thread1: Stock 객체 점유<br/>quantity = 100
    Thread1-->>coupon: couponService.issue() 종료
    deactivate Thread1

    Thread2->>coupon: tx.start()
    Note right of coupon: quantity = 100
    Thread2->>coupon: couponService.issue() 시작
    activate Thread2
    Note over Thread2: Coupon 객체 점유<br/>quantity = 100
    Thread2-->>coupon: couponService.issue() 종료
    deactivate Thread2
    Thread1->>coupon: tx.commit
    Note left of coupon: quantity = 99
    Thread2->>coupon: tx.commit()
    Note right of coupon: quantity = 99
  1. synchronized 블록에 사용 아래와 같이 블록을 만들어서 적용할 수 있다. Transactional과 함께 묶어 주기 위해서 controller에서 락을 진행한 것을 볼 수 있다.
// Controller
public ResponseEntity<?> 쿠폰_발급() {
	synchronized(this) {
		this.couponService.issueCouponToUser(couponId, userId);
	}
}
 
@Transactional
public boolean issueCouponToUser(Long couponId, Long userId) {
	    ...
	    return true;            
}

단점

synchronized를 사용하게 되면 하나의 프로세스에서만 Lock이 걸리게 되므로 서버를 여러 대 사용할 경우 동시성을 방지할 수 없다.

perssimistic(비관적) Lock

비관적 락이란 DB 단에서 X-Lock(쓰기 락)을 설정해서 동시성을 제어하는 방법이다.

MySql에서 데드락 타임아웃 기본 설정 시간 확인하기
select @@innodb_lock_wait_timeout

아래와 같이 JPA에서 비관적 락을 적용하여 쿼리를 하는 것을 확인할 수 있다. 또한 문제가 발생해서 트랜잭션이 데드락이 걸리는 경우 5초내로 나올 수 있도록 설정하였다.

@Lock(LockModeType.PESSIMISTIC_WRITE)
@QueryHints({@QueryHint(name = "jakarta.persistence.lock.timeout", value = "5000")})
Optional<Coupon> findCouponByCouponId(Long id);

비관적락을 적용한 후 발생하는 쿼리를 확인하면 for update가 적용되어 있는 모습을 볼 수 있다.

select
    c1_0.id,
    ...
    c1_0.updated_by 
from
    p_coupon c1_0 
where
    (
        c1_0.is_deleted = false
    ) 
    and c1_0.id=? for update

optimistic(낙관적) Lock

JPA에서 낙관적 락 적용하기

발생하는 쿼리에 LockMode를 설정해준다.

/**
 * find By Id with 낙관적 락
 */
@Lock(LockModeType.OPTIMISTIC)
Optional<Coupon> findCouponByCouponId(Long id);

낙관적 락을 위한 버저닝 컬럼을 Entity에서 설정해준다.

/**
 * 낙관적 락을 위한 버저닝
 */
@Version
private Long version;

이후 어플리케이션에서 자체적으로 롤백 로직을 구현해 줘야 한다. 먼저 AOP 적용을 위한 애노테이션을 만들어준다.

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface RetryCoupon {
 
    int maxRetries() default 100;
 
    int retryDelay() default 500;

위에서 작성한 어노테이션을 적용해서 AOP를 붙여서 재시도 로직을 붙여준다.

@Order(Ordered.LOWEST_PRECEDENCE - 1)
@Aspect
@Component
public class OptimisticLockRetryAspect {
 
    @Pointcut("@annotation(com.webest.coupon.common.aop.RetryCoupon)")
    private void couponRetry() {
 
    }
 
    @Around("@annotation(retryCoupon)")
    public Object retryCouponMethod(ProceedingJoinPoint joinPoint, RetryCoupon retryCoupon)
        throws Throwable {
 
        Exception exceptionHolder = null;
 
        for (int attemp = 0; attemp < retryCoupon.maxRetries(); attemp++) {
            try {
                return joinPoint.proceed();
            } catch (ObjectOptimisticLockingFailureException e) {
                exceptionHolder = e;
                Thread.sleep(retryCoupon.retryDelay());
            }
        }
 
        throw exceptionHolder;
 
    }
}

이후에 원하는 Method로 가서 적용해 줄 수 있다.

@Transactional
@RetryCoupon
public boolean issueCouponToUser(Long couponId, Long userId) {
	...
}

분산 락

분산 시스템 환경에서 여러 인스턴스나 서버가 동일한 자원에 동시에 접근하지 못하도록 제어하는 락 메커니즘입니다. 분산된 여러 노드(서버, 애플리케이션 인스턴스 등) 간에 데이터의 일관성을 유지하면서 동시에 특정 리소스를 하나의 노드만 수정할 수 있도록 보장하는 방식

분산락을 적용시 Redis 접근 라이브러리 차이

Lettuce

setnx를 활용한 스핀락을 사용해서 락을 적용한다. (SET IF NOT EXISTS) 레디스에서 제공하는 기본적인 연산이다. 해당 명령어를 통해서 반복을 사용해서 현재 락이 걸려있는지 확인을 한다.

Redisson

pub-sub방식으로 락을 구성한다.

// 현재 쓰레드 아이디를 구독에 등록한다.
CompletableFuture<RedissonLockEntry> subscribeFuture = subscribe(currentThreadId);
// 락이 해제 되기 까지 등록을 한다.
pubSub.timeout(subscribeFuture);
 

적용과정

redisson 클라이언트 설정

private static final String REDISSON_HOST_PREFIX = "redis://";
 
private final RedisProperty redisProperty;
 
@Bean
public RedissonClient redissonClient() {
    Config config = new Config();
    config.useSingleServer()
        .setAddress(REDISSON_HOST_PREFIX + redisProperty.host() + ":" + redisProperty.port())
        .setUsername(redisProperty.username()).setPassword(redisProperty.password());
    return Redisson.create(config);
}

어노테이션 설정

@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface RedissonLock {
 
    String value(); // Lock의 고유 이름
 
    long waitTime() default 5000L; // Lock획득 시도하는 최대 시간 (ms)
 
    long leaseTime() default 2000L; // Lock을 획득한 후 점유하는 최대 시간 (ms)
    
}

AOP 코드 작성

메서드로 부터 이름과 파라미터를 갖고 Redis 키를 지정해준다.

private final RedissonClient redissonClient;
 
@Around("@annotation(com.webest.coupon.common.aop.RedissonLock)")
public void redissonLock(ProceedingJoinPoint joinPoint) throws Throwable {
 
    MethodSignature signature = (MethodSignature) joinPoint.getSignature();
    Method method = signature.getMethod();
    RedissonLock lockAnnotation = method.getAnnotation(RedissonLock.class);
 
    String lockKey =
        method.getName() + "-" + CustomSpringELParser.getDynamicValue(
            signature.getParameterNames(),
            joinPoint.getArgs(), lockAnnotation.value());
 
    RLock lock = redissonClient.getLock(lockKey);
 
    try {
        boolean lockable = lock.tryLock(
            lockAnnotation.waitTime(), lockAnnotation.leaseTime(), TimeUnit.MILLISECONDS);
 
        if (!lockable) {
            // 대기 시간 초과
            log.info("Lock 획득 실패 : {}", lockKey);
            return;
        }
 
        joinPoint.proceed();
 
    } catch (InterruptedException e) {
        log.error("에러 발생" + e);
        throw e;
    } finally {
        // 락 해제
        lock.unlock();
    }
 
}

Lock이 Transactional을 감싸줘야 하므로 가장 먼저 실행되도록 한다.

@Order(Ordered.LOWEST_PRECEDENCE - 1)

참고 블로그