AOP를 활용한 분산락 최적화

박양원·2025년 3월 31일

Trouble Shooting

목록 보기
17/17
post-thumbnail

Topic

AOP를 활용한 분산락 최적화

Issue

Redisson 적용 후 특정 트래픽 이상으로 상품 교환 API가 호출되면 Hikaripool Connection이 고갈되는 문제 발생

Step

1. 발생 원인

  • 회원별 상품 교환 포인트를 조회 API 로직 최적화 실패
  • 트랜잭션 범위 과대 설정으로 인한 상품 교환 로직 병목 현상

2. 어떻게 해결하나?

일단 포인트 조회 API 로직 최적화를 수행한 후, 이번 포스팅의 주제인 상품 교환 로직 최적화를 수행하였다.

기존 코드는 아래와 같이 API가 호출하는 Service 메소드가 하나의 트랜잭션으로 관리되고 있었다.

@Transactional(rollbackFor = Exception.class)
public Long makeOrder(Long memberId, Long itemId, OrderInfoAndDeliveryHistoryDTO.SaveRequest request) {
    log.info("MAKE ORDER :: {}", itemId);

    RLock lock = acquireLock(itemId);

    try {
        return performOrderTransaction(memberId, itemId, request);
    } finally {
        releaseLock(lock);
    }
}
    
private RLock acquireLock(Long itemId) {
	RLock lock = redissonClient.getLock("itemLock:" + itemId);

    try {
        if (!lock.tryLock(10, 30, TimeUnit.SECONDS)) {
            log.error("COULD NOT ACQUIRE A LOCK FOR ITEM :: {}", itemId);
            throw new RuntimeException("COULD NOT ACQUIRE A LOCK FOR ITEM :: " + itemId);
        }
    } catch (InterruptedException e) {
        Thread.currentThread().interrupt();
        log.error("LOCK ACQUISITION INTERRUPTED", e);
        throw new RuntimeException("LOCK ACQUISITION INTERRUPTED", e);
    }

    return lock;
}

위 코드에는 크게 2가지의 문제점이 있다.

  • 트랜잭션 범위가 너무 넓어 makeOrder 메소드 내부 로직 수행 시간이 RLock의 임대 시간보다 긴 경우 원하는 결과를 얻을 수 없음
  • 트랜잭션이 열린 후 RLock을 획득하여 Hikaripool 고갈 문제가 발생할 수 있음

위에 언급한 문제들을 해결하기 위해 아래의 과정을 수행해보았다.

2-1. 트랜잭션 분리

일단 트랜잭션의 범위를 메소드 전체가 아닌 로직 별로 분리하는 과정을 거쳤다.

@Component
class ItemOrderFacade(
	private val itemStockService: ItemStockService,
	...
) {
	fun makeOrder(
    	...
    ) {
    	// Validation 로직
    
	    // 재고 감소 로직 수행
	    itemStockService.reduceItemStock(itemId)
        
        // 남은 Business 로직 수행
    }
}
@Service
@Transactional
class ItemStockService(
	...
) {
	val lock = acquireLock(itemId)
    
    try {
    	// 재고 감소 수행
    } finally {
    	releaseLock(lock)
    }
}

// RLock 획득 메소드
private fun acquireLock(itemId: Long) {
	...
}

현재 프로젝트는 Service 최상단 부분에 @Transactional Annotation을 붙이는게 컨벤션이기에 메소드별로 Trasaction을 개별로 컨트롤하기 위해 별도의 Facade Layer를 두고 재고 감소 전체 로직을 수행하는 형식으로 바꾸었다.

새롭게 발생한 이슈

위와 같이 트랜잭션을 분리한 후, 재고 감소가 정상적이게 반영되지 않는 이슈가 발생하였다. 한참을 고생하다가 찾은 원인은 아래 이유였다.

  • 트랜잭션 종료 시점과 락의 종료 시점 제어 이슈

쉽게 말해 트랜잭션이 종료되기 전에 락이 먼저 종료되어 우리가 원하던 동시성 제어에 실패하게 된 것이다.

어떻게 해결할까??

내가 원하는 플로우는 다음과 같다.

분산락 획득 
	-> 트랜잭션 시작 
    	-> 비즈니스 로직 수행 
    -> 트랜잭션 종료 
-> 분산락 종료

따라서 분산락 획득을 트랜잭션 시작 시점보다 더 앞으로 땡길 수 있는 방법을 모색하다가 관련하여 좋은 포스팅을 발견하였다.

마켓컬리 분산락 관련 포스팅

해당 포스팅은 나에게 단비와 같은 존재였고 이를 참고하여 AOP와 Annotation을 활용한 나만의 재고 감소 플로우를 완성할 수 있었다.

@Aspect
@Component
class DistributedLockAspect(
	private val redissonClient: RedissonClient,
	private val transactionForAOP: TransactionForAOP,
) {
	@Around("@annotation(com.ppfriends.api.common.annotation.DistributedLock)")
	fun getDistributedLock(joinPoint: ProceedingJoinPoint): Any {
		val signature = joinPoint.signature as MethodSignature
		val method = signature.method

		val distributedLock = method.getAnnotation(DistributedLock::class.java)

		val key =
			REDISSON_LOCK_PREFIX +
				CustomSpringELParser.getDynamicValue(
					signature.parameterNames,
					joinPoint.args,
					distributedLock.key,
				)

		val lock = redissonClient.getLock(key)

		try {
			log.info { "Lock 획득 시도 :: $key" }

			val available = lock.tryLock(distributedLock.waitTime, distributedLock.leaseTime, distributedLock.timeUnit)

			if (!available) {
				// Lock 획득에 실패한 경우 핸들링
			}

			log.info { "Lock 획득 성공 :: $key" }

			return transactionForAOP.proceed(joinPoint)
		} catch (e: Exception) {
        	// 예외 발생 시 핸들링
		} finally {
			try {
				if (lock.isHeldByCurrentThread && lock.isLocked) {
					lock.unlock()
					log.info { "Lock 해제 성공 :: $key" }
				} else {
					// 정상 종료 실패시 핸들링
				}
			} catch (e: IllegalMonitorStateException) {
	            // 해제된 락에 접근하려는 시점 핸들링 
			}
		}
	}

	companion object {
		private const val REDISSON_LOCK_PREFIX = "LOCK:"
	}
}
@Component
class TransactionForAOP {
	// 트랜잭션을 이 시점에 열어줌
    // REQUIRES_NEW 옵션으로 항상 새로운 트랜잭션을 획득함
	@Transactional(propagation = Propagation.REQUIRES_NEW)
	fun proceed(joinPoint: ProceedingJoinPoint): Any {
		return joinPoint.proceed()
	}
}
@Component
class ItemOrderFacade(
	private val itemOrderService: ItemOrderService,
	private val itemStockService: ItemStockService,
    ...
) {
	fun makeOrder(
		memberId: Long,
		itemId: Long,
		request: OrderInfoAndDeliveryHistoryDTO.SaveRequest,
	) {
		// Validation 로직
		
	    // 재고 감소
        itemStockService.reduceItemStock(itemId)

		try {
			// 비즈니스 로직 수행
		} catch (e: Exception) {
			// 비즈니스 로직 수행 실패 시, 재고 복구
            itemStockService.restoreItemStock(itemId)
			throw e
		}
	}
}
import java.util.concurrent.TimeUnit

@Target(AnnotationTarget.FUNCTION)
@Retention(AnnotationRetention.RUNTIME)
annotation class DistributedLock(
	// Lock Name
	val key: String,

	// Lock Time 단위
	val timeUnit: TimeUnit = TimeUnit.SECONDS,

	// Lock 대기 시간
	val waitTime: Long = 5L,

	// Lock 임대 시간 (획득 이후 leaseTime이 경과하면 Lock을 해제함)
    // 0으로 설정 시, WatchDog 동작 (릴리즈 타임 자동)
	val leaseTime: Long = 3L,
)
@DistributedLock(key = "#itemId")
fun reduceItemStock(itemId: Long): Item {
	...
	// 재고 감소
	item.reduceStock(1)

	return item
}

@DistributedLock(key = "#itemId")
fun restoreItemStock(itemId: Long): Item {
	...
	// 재고 복구
	item.restoreStock(1)

	return item
}

Reflection

좋은 래퍼런스를 참고하여 분산락과 트랜잭션에 관한 좋은 학습 및 이를 실제 운영에 적용할 수 있어 매우 뿌듯한 경험을 할 수 있었다. 또한 Annotation을 활용한 분산락 적용 방식을 채택하여 보다 간결하게 분산락을 사용할 수 있게 되었다.
이 포스팅이 분산락과 트랜잭션의 순서를 제어하고자 하는 다른 개발자 분들에게 도움이 되었으면 좋겠다.

0개의 댓글