AOP를 활용한 분산락 최적화
Redisson 적용 후 특정 트래픽 이상으로 상품 교환 API가 호출되면 Hikaripool Connection이 고갈되는 문제 발생
일단 포인트 조회 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가지의 문제점이 있다.
위에 언급한 문제들을 해결하기 위해 아래의 과정을 수행해보았다.
일단 트랜잭션의 범위를 메소드 전체가 아닌 로직 별로 분리하는 과정을 거쳤다.
@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
}
좋은 래퍼런스를 참고하여 분산락과 트랜잭션에 관한 좋은 학습 및 이를 실제 운영에 적용할 수 있어 매우 뿌듯한 경험을 할 수 있었다. 또한 Annotation을 활용한 분산락 적용 방식을 채택하여 보다 간결하게 분산락을 사용할 수 있게 되었다.
이 포스팅이 분산락과 트랜잭션의 순서를 제어하고자 하는 다른 개발자 분들에게 도움이 되었으면 좋겠다.