@Test
@DisplayName("여러개의 주문이 동시에 남은 재고를 구매하려 할 때")
void createOrder_concurrency_test() throws BrokenBarrierException, InterruptedException {
// given
OrderRequest orderRequest = OrderRequest.builder()
...
.build();
// 스레드 수 설정
// 배타락 테스트시 히카리 db풀 크기 -1 만큼 설정
int numberOfThreads = 21;
// 멀티 스레드 풀
// 스레드 수 * 2 권장
ExecutorService executorService = Executors.newFixedThreadPool(48);
// 동기화 제어
// CountDownLatch : latch.countDown / latch.await
// 카운트 다운이 설정한 횟수에 도달할 때 까지 정지
// CyclicBarrier : barrier.await
// 설정한 횟수만큼 await가 호출되면 한번에 실행
// 동기화 제어가 필요한 이유 : 멀티 태스크와 같은 비동기 작업들은 종료 시점을 알 수 없음
// = 호출한 함수보다 늦게 끝날 수 도 있음
CyclicBarrier barrier = new CyclicBarrier(numberOfThreads);
// 시작 시간 측정
long start = System.currentTimeMillis();
AtomicInteger executeCount = new AtomicInteger(0);
// when
for (int i = 0; i < numberOfThreads - 1; i++) {
executorService.execute(() -> {
try {
barrier.await();
orderService.createOrder(orderRequest);
executeCount.incrementAndGet();
} catch (InterruptedException | BrokenBarrierException e) {
e.printStackTrace();
}
});
}
// 작업 시작점
barrier.await();
// 멀티 스레드 종료
executorService.shutdown();
// 위의 shutdown은 새로운 작업을 받지 않겠다는 뜻일 뿐
// 작업이 완료되는걸 기다려주지 않는다.(none blocking)
// awaitTermination으로 모든 작업이 끝날 때까지 블로킹 해줄 수 있다.
if (!executorService.awaitTermination(30, TimeUnit.SECONDS))
// 지정된 시간까지 완료되지 않으면 강제 종료한다.
executorService.shutdownNow();
// 종료 시간 측정
long end = System.currentTimeMillis();
System.out.println("멀티스레드 실행 횟수 : " + executeCount.get() + "실행 시간 : " + (end - start) + "ms");
// then
Product product = productRepository.findById(productId).get();
Assertions.assertEquals(80, product.getStock());
}
@RequiredArgsConstructor
@Repository
public class RedisLockRepository {
private final RedisTemplate<String, String> redisTemplate;
public Boolean lock(String key, String uuid, long ttl) {
return redisTemplate
.opsForValue()
.setIfAbsent(key, uuid, Duration.ofMillis(ttl));
}
public Boolean unlock(String key, String uuid) {
// 키에 해당하는 값이 UUID와 같으면 삭제
String luaScript =
"if redis.call('GET', KEYS[1]) == ARGV[1] then return redis.call('DEL', KEYS[1]) else return 0 end";
RedisScript<Long> script = RedisScript.of(luaScript, Long.class);
Long deleteResult = redisTemplate.execute(script, Collections.singletonList(key), uuid);
return deleteResult > 0;
}
}
public boolean tryLock(String key, long timeout) throws InterruptedException {
long startTime = System.currentTimeMillis();
while (System.currentTimeMillis() - startTime < timeout) {
if (redisLockRepository.lock(key))
return true;
Thread.sleep(50);
}
return false;
}
RedisTemplate
를 통해 lock이 가능한지 확인하고, 정해둔 timeout 시간에 lock 획득에 실패하면 false를 반환하도록 설정했다.redisLockRepository.lock
은 TTL을 5000ms로 설정해 시간이 지나면 락이 해제돼서 데드락을 방지할 수 있다.락이 트랜잭션 내부에 있을 경우 영속화가 끝나기전에 락이 해제될 수 있다.
트랜잭션 시작
락
저장
락 해제
! << 이 틈에 다른 스레드가 들어온다면 트랜잭션이 종료되기 전에 락이 걸릴 수 있음
트랜잭션 종료
트랜잭션이 영속성을 db에 적용하기전에 락이 해제될 수 있다.
락
트랜잭션 시작
저장
트랜잭션 종료
락 해제
! 트랜잭션 커밋 << 락이 트랜잭션 작업 종료 후 영속성 저장을 기다려주지 않기 때문에 발생
멀티 스레드 환경에서 락 소유자 확인 없이 unlock을 호출하면, 다른 스레드가 소유한 락을 실수로 해제하여 정합성 문제가 발생할 수 있다.
스핀락을 사용할 때 타임아웃 설정이 너무 짧으면 락 획득 경쟁이 심해져 스레드가 락을 얻지 못하고 실패할 수 있다.
(테스트로 50개의 스레드를 한번에 보낼 때 timeout을 5초로 설정해야만 정상적으로 완료됐고, 그 이하로 설정 시 스레드가 락 획득에 실패해 실행되지 않았다.)
딜레이(Time.sleep)를 짧게 설정하면 레디스에 부하가 크고 길게 설정하면 지연이 되기 때문에 적절한 시간을 설정해야한다.
@Slf4j
@Aspect
@Component
@RequiredArgsConstructor
public class LettuceLockAspect {
private static final String LOCK_PREFIX = "LOCK:";
private final LettuceLockService lockService;
@Around("@annotation(lettuceLock)")
public Object lettuceLock(ProceedingJoinPoint joinPoint, LettuceLock lettuceLock) throws Throwable {
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
// key 추출
EvaluationContext evaluationContext = new StandardEvaluationContext();
Object[] args = joinPoint.getArgs();
String[] paramNames = signature.getParameterNames();
for (int i = 0; i < args.length; i++) {
evaluationContext.setVariable(paramNames[i], args[i]);
}
ExpressionParser parser = new SpelExpressionParser();
String stringKey = parser.parseExpression(lettuceLock.key()).getValue(evaluationContext, String.class);
if (stringKey == null || stringKey.isBlank()) {
log.info("Key value is null or blank");
throw new IllegalArgumentException("Key value is null or blank");
}
// lock
String uniqueId = UUID.randomUUID().toString();
String lockPrefixKey = LOCK_PREFIX + stringKey;
int maxRetryCount = 3;
for (int i = 1; i <= maxRetryCount; i++) {
try {
boolean available = lockService.tryLock(lockPrefixKey, uniqueId, 4000, 50, 5000);
// 락 획득 실패
if (!available) {
log.info("{} : Retry Count{}", uniqueId, i);
continue;
}
// 락 획득 성공
log.info("프로세스 락 : {}", uniqueId);
Object result = joinPoint.proceed();
//
if (TransactionSynchronizationManager.isSynchronizationActive()) {
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
// DB 커밋 성공 후 호출
@Override
public void afterCommit() {
lockService.unlock(lockPrefixKey, uniqueId);
log.info("프로세스 언락 (트랜잭션 완료) : {}", uniqueId);
}
});
} else {
// 트랜잭션이 없는 경우 바로 언락
if (lockService.unlock(lockPrefixKey, uniqueId))
log.info("프로세스 언락 : {}", uniqueId);
}
return result;
} catch (Throwable e) {
// 트랜잭션 롤백 시 처리
if (lockService.unlock(lockPrefixKey, uniqueId))
log.info("프로세스 언락 (트랜잭션 롤백) : {}", uniqueId);
throw e;
}
}
// 재시도 3회 모두 실패 시
log.info("To many Request");
throw new IllegalArgumentException("To many Request");
}
}
AOP Around before 시점에 락
트랜잭션 시작
영속 상태
트랜잭션 종료 및 커밋
AOP Around after 시점에 락 해제
트랜잭션이 영속성을 DB에 저장하기 전에 락이 먼저 해제되는 문제가 발생했다.
이는 AOP가 트랜잭션이 종료되고 영속성 컨텍스트를 플러시(flush)하기 전에 스레드를 종료시키면서 finally 블록의 언락 로직이 먼저 실행되었기 때문이었다.
이 문제를 해결하기 위해, Spring의 TransactionSynchronizationManager
를 활용했다.
lock 획득 후 afterCommit()
콜백에 언락 로직을 등록함으로 락 해제가 DB 커밋이 완료된 후에 실행되도록 시점을 정확히 동기화하여 문제를 해결했다.
for (int i = 1; i <= maxRetryCount; i++) {
boolean available = lockService.tryLock(LockPrefixKey, uniqueId, 8000, 50, 10000);
// 락 획득 실패
if (!available) {
log.info("{} : Retry Count{}", uniqueId, i);
continue;
}
// 락 획득 성공
log.info("프로세스 락 : {}", uniqueId);
Object result = joinPoint.proceed();
// 현재 실행 중인 스레드에 트랜잭션이 활성화 돼있는지 검사
if (TransactionSynchronizationManager.isSynchronizationActive()) {
// 콜백으로 트랜잭션이 종료될 때 실행할 함수를 등록
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
@Override
// 트랜잭션 커밋 종료 후 호출
public void afterCommit() {
lockService.unlock(LockPrefixKey, uniqueId);
log.info("프로세스 언락 (트랜잭션 완료) : {}", uniqueId);
}
});
} else {
// 트랜잭션이 없는 경우 바로 언락
lockService.unlock(LockPrefixKey, uniqueId);
log.info("프로세스 언락 : {}", uniqueId);
}
lockService.unlock(LockPrefixKey, uniqueId);
return result;
}
TransactionSynchronizationManager
를 사용하여 unlock 시점을 DB 커밋 후로 미뤘음에도 불구하고, 테스트를 실행할 때 첫 lock이 두 번 걸리는 오류가 발생했다.
이는 @Transactional
AOP와 custom lock AOP의 실행 순서 충돌로 인해, lock AOP가 트랜잭션 경계를 벗어나거나 트랜잭션이 활성화되기 전에 동기화 로직이 실행되었기 때문이었다.
이 문제를 해결하기 위해 @Order
어노테이션으로 순서를 정하는 방법과 서비스 계층의 경계를 명확하게 분리하는 방법이 있었는데 후자를 선택해서 기존 OrderService
는 @Transactional
을 유지하여 순수 DB 트랜잭션만 담당하도록 하고, 새로 구현한 OrderLockService
에 custom lock AOP를 달아 분산락 획득/해제만 담당하도록 했다.
OrderLockService
가 OrderService
를 호출하는 구조로 변경함으로써 lock 로직이 트랜잭션을 확실하게 감싸게 되었고, TransactionSynchronizationManager
가 트랜잭션 활성화 상태에서 정확히 등록되어 최종적으로 데이터 정합성 문제를 해결할 수 있었다.
@Service
@RequiredArgsConstructor
public class OrderLockService {
private final OrderService orderService;
@LettuceLock(key = "#orderRequest.getProductId()")
public OrderResponse createOrderWithLettuceLock(OrderRequest orderRequest) {
// @Transaction
return orderService.createOrder(orderRequest);
}
logging:
level:
org:
hibernate:
SQL: DEBUG
type: TRACE
engine:
transaction:
internal:
TransactionImpl: DEBUG
spi: TRACE
event: TRACE
@Version
컬럼을 통해 동시성 제어@Version Long version
@Lock(LockModeType.OPTIMISTIC)
@Query("select p from Product p where p.id = :id")
Optional<Product> findByIdWithOptimisticLock(Long id);
@Retryable(
retryFor = {
OptimisticLockException.class,
ObjectOptimisticLockingFailureException.class
},
maxAttempts = 5,
backoff = @Backoff(delay = 100)
)
public OrderResponse createOderWithOptimisticLock(OrderRequest orderRequest) {
return orderService.createOrderWithOptimisticLock(orderRequest);
}
@Version
을 사용해 낙관적 락 구현 시 발생한 이슈maximum-pool-size
크게 설정해야 함@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select p from Product p where p.id = :id")
Optional<Product> findByIdWithPessimistLock(Long id);
// application.yml hikari cp 설정
spring:
datasource:
hikari:
maximum-pool-size: 60
// 커넥션이 풀에서 가져간 뒤 2000ms 이상 반납되지 않으면 누수로 간주
leak-detection-threshold: 2000
pool-name: MyHikariCP
maximum pool size
(기본값 10) 이상의 스레드가 접근하면 커넥션 풀에 자리가 없어서 무한히 대기하게 되며, 이 상황을 누수가 발생했다고 부른다.connection-timeout
(기본값 30초) 정지 상태가 된다.maximum pool size
를 10개로 설정했을때 9개의 스레드만 접근해야 정상 작동하는데 상태 변환을 위한 여유 풀이 필요하기 때문이다.Caused by: java.sql.SQLTransientConnectionException: MyHikariCP - Connection is not available, request timed out after 30010ms (total=10, active=10, idle=0, waiting=1)
@Service
@RequiredArgsConstructor
public class RedissonLockService {
private final RedissonClient redissonClient;
public RLock tryLock(String key, long waitTimeMs, long leaseTimeS) {
RLock lock = redissonClient.getLock(key);
try {
boolean isLocked = lock.tryLock(waitTimeMs, leaseTimeS, TimeUnit.SECONDS);
if (isLocked) {
return lock;
}
return null;
} catch (InterruptedException e) {
// 스레드 강제 종료
Thread.currentThread().interrupt();
throw new RuntimeException("lock interrupted");
}
}
public void unlock(RLock lock) {
if (lock != null && lock.isLocked() && lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
@Slf4j
@Aspect
@Component
@RequiredArgsConstructor
public class RedissonLockAspect {
private static final String LOCK_PREFIX = "LOCK:";
private final RedissonLockService lockService;
@Around("@annotation(redissonLock)")
public Object lettuceLock(ProceedingJoinPoint joinPoint, RedissonLock redissonLock) throws Throwable {
// key 추출
...
// lock
// 메서드 검증을 Redisson에서 해주기 때문에 스레드 검증을 하지 않아도 된다.
String uniqueId = UUID.randomUUID().toString();
String lockPrefixKey = LOCK_PREFIX + stringKey;
RLock lock = null;
try {
lock = lockService.tryLock(
lockPrefixKey,
redissonLock.waitTimeS(),
redissonLock.leaseTimeS()
);
// 락 획득 실패 (waitTime 초과)
if (lock == null) {
throw new IllegalArgumentException("To many Request");
}
// 락 획득 성공
log.info("프로세스 락 : {}", uniqueId);
Object result = joinPoint.proceed();
if (TransactionSynchronizationManager.isSynchronizationActive()) {
RLock localLock = lock;
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
// DB 커밋 성공 후 호출
@Override
public void afterCommit() {
lockService.unlock(localLock);
log.info("프로세스 언락 (트랜잭션 완료) : {}", uniqueId);
}
});
} else {
// 트랜잭션이 없는 경우 바로 언락
lockService.unlock(lock);
log.info("프로세스 언락 : {}", uniqueId);
}
return result;
} catch (Throwable e) {
// 트랜잭션 롤백 시 처리
lockService.unlock(lock);
log.info("프로세스 언락 : {}", uniqueId);
throw e;
}
}
}
락 방식 | 권장 환경 | 장점 | 단점 |
---|---|---|---|
낙관적 락 (Optimistic) | 충돌 위험 낮음 (읽기 위주) | 락 대기가 없기 때문에 빠름. | 경쟁 심화 시 DB 데드락 가능성. |
비관적 락 (Pessimistic) | 충돌 위험 높음 (쓰기 위주) | 높은 데이터 무결성 보장. 충돌 즉시 차단. | 심각한 DB 성능 저하 및 긴 대기 시간 유발. |
분산 락 (Lettuce) | 서버 분산 (경량화) | Redis 명령 직접 사용해 락 로직을 커스텀 할 수 있음. | 복잡한 구현 (Lua Script 필수). |
분산 락(Redisson) | 서버 분산 | 안전성 및 편의성이 높음 (Watchdog, Pub/Sub 대기). | Lettuce 단독보다 오버헤드 있음. 기능들이 많기 때문에 Redis에 부하를 줄 수 있음 |