네임드 락을 활용한 동시성 제어
동시성 문제 - 중복 저장@Componentclass SubscribeWorkbookUseCase( private val subscriptionDao: SubscriptionDao, private val applicationEventPublisher: ApplicationEventPublisher,) { @Transactional fun execute(useCaseIn: SubscribeWorkbookUseCaseIn) {
belljundev.tistory.com
최근 AOP에 대해 다시 공부하며 AOP와 네임드 락을 활용하여 중복 구독 방지를 위해 구현하였던 LockAspect가 문제가 있지 않을까? 하는 생각을 하였습니다.
AOP를 다시 공부하기 이전 제가 생각하였던 기존 구현의 동작은 위의 사진과 같았습니다.@Before
과 @AfterReturning
을 통해 선언한 어드바이저가 비즈니스 로직이 수행되는 유스케이스 전후에 실행되어 락 획득/해제할 것이라 생각하였습니다.
하지만 트랜잭션을 위한 프록시 객체와 AspectJ의 프록시 객체는 어떤 순서로 실행될까?를 공부하며 AOP 인터셉터는 위의 사진과 같이 적용되는 것이 아닌 아래와 같이 적용됨을 확인할 수 있었습니다.
그렇다면 아래와 같은 경우도 생각해 볼 수 있을 것입니다.
아래 사진은 요청을 2개로 가정하고 동시성 문제 확인을 위해 각 요소들의 간격을 임의로 넓힌 사진입니다.
락을 도입하였지만 커밋이 되기 전 락이 해제되며 동시성 문제가 발생할 가능성이 여전히 남아있는 것입니다.
- [요청1] 요청 시작
- [요청1] TX 시작
- [요청1] 락 획득 // 요청1이 락 획득
- [요청1] 비즈니스로직(A -> A+1), [요청2] 요청시작
- [요청2] TX 시작
- [요청1] 락 해제 // 요청1이 락 해제
- [요청2] 락 획득 // 요청2가 락 획득
- [요청2] 비즈니스로직(A -> A+1)
- [요청2] 락 해제 // 요청2가 락 해제
- [요청2] TX 커밋 (A+1)
- [요청1] TX 커밋 (A+1)
2번의 요청이 처리 되었지만 기대하는 A+2가 아닌 A+1이 저장되는 것을 확인할 수 있다.
현재 구현에서 발생할 수 있는 문제는 트랜잭션이 커밋되기 이전에 락을 해제하며 생기는 문제로 아래와 같이 락과 트랜잭션의 순서를 바꿀 수 있다면 해결할 수 있을 것입니다.
기존과 같이 유스케이스의 트랜잭션을 @Transactional
을 활용하여 다룬다면 락과 트랜잭션의 순서를 바꿀 수 없습니다.
순서를 조정하기 위해서는 락을 다루고 있는 LockAspect
에서 트랜잭션도 함께 다루어야 하며 이를 위해 기존에는 단순이 락 기능에 관한 책임만을 담당하였던 LockFor
클래스에 트랜잭션 여부에 대한 책임을 추가로 부여합니다.
import org.springframework.transaction.annotation.Propagation
@Target(AnnotationTarget.FUNCTION)
@Retention(AnnotationRetention.RUNTIME)
annotation class LockFor(
val identifier: LockIdentifier,
val transactional: Propagation = Propagation.NEVER
)
최종 수정
@Around("lockPointcut()")
fun around(joinPoint: ProceedingJoinPoint): Any? {
val lockFor = getLockFor(joinPoint)
val identifier = lockFor.identifier
val transactional = lockFor.transactional
when (identifier) {
LockIdentifier.SUBSCRIPTION_MEMBER_ID_WORKBOOK_ID -> {
getSubscriptionMemberIdAndWorkBookIdLockCase(joinPoint)
}
}
val proceed = when (transactional) {
Propagation.REQUIRED -> transactionTemplate.execute {
joinPoint.proceed()
}
// ...
else -> joinPoint.proceed()
}
when (identifier) {
LockIdentifier.SUBSCRIPTION_MEMBER_ID_WORKBOOK_ID -> {
releaseSubscriptionMemberIdAndWorkBookIdLockCase(joinPoint)
}
}
return proceed
}
관리의 편의를 위해 기존에 @Before
과 @AfterReturning
로 분리되었던 락 획득/해제 관련 로직도 @Around
로 변경하였습니다.
그리고 기존에 @Transactional
로 관리하였던 트랜잭션에 대한 관리는 transactionTemplate
을 활용하는 방법으로 수정하였습니다.
이에 다시 한번 동시성 문제 확인을 위해 각 요소들의 간격을 임의로 넓혀본다면 아래와 같이 예상할 수 있을 것입니다.
- [요청1] 요청 시작
- [요청1] 락 획득 // 요청1이 락 획득
- [요청1] TX 시작
- [요청1] 비즈니스로직(A -> A+1), [요청2] 요청시작
- [요청2] 락 획득 // 요청 2가 락 획득 실패
- [요청1] TX 커밋 (A+1)
- [요청1] 락 해제 // 요청1이 락 해제
- [요청3] 요청 시작
- [요청3] 락 획득 // 요청3이 락 획득
- [요청3] TX 시작
- [요청3] 비즈니스로직(A+1 -> A+2)
- [요청3] TX 커밋 (A+2)
- [요청3] 락 해제 // 요청3이 락 해제
요청2의 경우 락을 획득하지 못하고 실패
요청3은 요청1의 락이 해제된 이후 요청되기 때문에 성공
소감
스프링 부트는 정말 편하지만 이것을 잘 다루기 위해서는 많은 공부가 필요하다는 것을 다시 한번 느낄 수 있었습니다.
부트나 코틀린을 사용하다 보면 마법 같은 순간들이 자주 있는데 항상 의심해 보고 확인할 수 있는 능력을 쌓아나가야겠다는 생각을 다시 한번 할 수 있었습니다.
'개발' 카테고리의 다른 글
동적인 락 순서에 의한 데드락 (0) | 2025.03.07 |
---|---|
블로킹 큐와 프로듀서-컨슈머 패턴 (0) | 2025.03.06 |
SQS 리스너 구현기 (0) | 2025.01.16 |
도메인 이벤트 모듈 구성 (0) | 2025.01.16 |
이벤트 모듈 설계 문서 (0) | 2025.01.14 |