diff --git a/api-repo/src/main/kotlin/com/few/api/repo/dao/subscription/SubscriptionDao.kt b/api-repo/src/main/kotlin/com/few/api/repo/dao/subscription/SubscriptionDao.kt index ba01f49b7..fa7948c67 100644 --- a/api-repo/src/main/kotlin/com/few/api/repo/dao/subscription/SubscriptionDao.kt +++ b/api-repo/src/main/kotlin/com/few/api/repo/dao/subscription/SubscriptionDao.kt @@ -18,6 +18,21 @@ import java.time.LocalDateTime class SubscriptionDao( private val dslContext: DSLContext, ) { + fun getLock(memberId: Long, workbookId: Long, timeout: Int = 5): Boolean { + return dslContext.fetch( + """ + SELECT GET_LOCK(CONCAT('subscription_', $memberId, '_', $workbookId), $timeout); + """ + ).into(Int::class.java).first() == 1 + } + + fun releaseLock(memberId: Long, workbookId: Long): Boolean { + return dslContext.fetch( + """ + SELECT RELEASE_LOCK(CONCAT('subscription_', $memberId, '_', $workbookId)); + """ + ).into(Int::class.java).first() == 1 + } fun insertWorkbookSubscription(command: InsertWorkbookSubscriptionCommand) { insertWorkbookSubscriptionCommand(command) diff --git a/api/build.gradle.kts b/api/build.gradle.kts index 472d26fd1..dc1735a11 100644 --- a/api/build.gradle.kts +++ b/api/build.gradle.kts @@ -18,12 +18,16 @@ dependencies { implementation("org.springframework.boot:spring-boot-starter-web") implementation("org.springframework.boot:spring-boot-starter-security") implementation("org.springframework.boot:spring-boot-starter-actuator") + implementation("org.springframework.boot:spring-boot-starter-aop") /** jwt */ implementation("io.jsonwebtoken:jjwt-api:${DependencyVersion.JWT}") implementation("io.jsonwebtoken:jjwt-impl:${DependencyVersion.JWT}") implementation("io.jsonwebtoken:jjwt-jackson:${DependencyVersion.JWT}") + /** aspectj */ + implementation("org.aspectj:aspectjweaver:1.9.5") + /** scrimage */ implementation("com.sksamuel.scrimage:scrimage-core:${DependencyVersion.SCRIMAGE}") /** for convert to webp */ diff --git a/api/src/main/kotlin/com/few/api/config/AspectConfig.kt b/api/src/main/kotlin/com/few/api/config/AspectConfig.kt new file mode 100644 index 000000000..ad3a4e76d --- /dev/null +++ b/api/src/main/kotlin/com/few/api/config/AspectConfig.kt @@ -0,0 +1,8 @@ +package com.few.api.config + +import org.springframework.context.annotation.Configuration +import org.springframework.context.annotation.EnableAspectJAutoProxy + +@Configuration +@EnableAspectJAutoProxy +class AspectConfig \ No newline at end of file diff --git a/api/src/main/kotlin/com/few/api/domain/common/lock/LockAspect.kt b/api/src/main/kotlin/com/few/api/domain/common/lock/LockAspect.kt new file mode 100644 index 000000000..6ceb5015e --- /dev/null +++ b/api/src/main/kotlin/com/few/api/domain/common/lock/LockAspect.kt @@ -0,0 +1,77 @@ +package com.few.api.domain.common.lock + +import com.few.api.domain.subscription.usecase.dto.SubscribeWorkbookUseCaseIn +import com.few.api.repo.dao.subscription.SubscriptionDao +import io.github.oshai.kotlinlogging.KotlinLogging +import org.aspectj.lang.annotation.AfterReturning +import org.aspectj.lang.annotation.AfterThrowing +import org.aspectj.lang.JoinPoint +import org.aspectj.lang.annotation.Aspect +import org.aspectj.lang.annotation.Before +import org.aspectj.lang.annotation.Pointcut +import org.aspectj.lang.reflect.MethodSignature +import org.springframework.stereotype.Component + +@Aspect +@Component +class LockAspect( + private val subscriptionDao: SubscriptionDao, +) { + private val log = KotlinLogging.logger {} + + @Pointcut("@annotation(com.few.api.domain.common.lock.LockFor)") + fun lockPointcut() {} + + @Before("lockPointcut()") + fun before(joinPoint: JoinPoint) { + getLockFor(joinPoint).run { + when (this.identifier) { + LockIdentifier.SUBSCRIPTION_MEMBER_ID_WORKBOOK_ID -> { + val useCaseIn = joinPoint.args[0] as SubscribeWorkbookUseCaseIn + getSubscriptionMemberIdAndWorkBookIdLock(useCaseIn) + } + } + } + } + + private fun getSubscriptionMemberIdAndWorkBookIdLock(useCaseIn: SubscribeWorkbookUseCaseIn) { + subscriptionDao.getLock(useCaseIn.memberId, useCaseIn.workbookId).run { + if (!this) { + throw IllegalStateException("Already in progress for ${useCaseIn.memberId}'s subscription to ${useCaseIn.workbookId}") + } + log.debug { "Lock acquired for ${useCaseIn.memberId}'s subscription to ${useCaseIn.workbookId}" } + } + } + + @AfterReturning("lockPointcut()") + fun afterReturning(joinPoint: JoinPoint) { + getLockFor(joinPoint).run { + when (this.identifier) { + LockIdentifier.SUBSCRIPTION_MEMBER_ID_WORKBOOK_ID -> { + val useCaseIn = joinPoint.args[0] as SubscribeWorkbookUseCaseIn + releaseSubscriptionMemberIdAndWorkBookIdLock(useCaseIn) + } + } + } + } + + @AfterThrowing("lockPointcut()") + fun afterThrowing(joinPoint: JoinPoint) { + getLockFor(joinPoint).run { + when (this.identifier) { + LockIdentifier.SUBSCRIPTION_MEMBER_ID_WORKBOOK_ID -> { + val useCaseIn = joinPoint.args[0] as SubscribeWorkbookUseCaseIn + releaseSubscriptionMemberIdAndWorkBookIdLock(useCaseIn) + } + } + } + } + + private fun getLockFor(joinPoint: JoinPoint) = + (joinPoint.signature as MethodSignature).method.getAnnotation(LockFor::class.java) + + private fun releaseSubscriptionMemberIdAndWorkBookIdLock(useCaseIn: SubscribeWorkbookUseCaseIn) { + subscriptionDao.releaseLock(useCaseIn.memberId, useCaseIn.workbookId) + log.debug { "Lock released for ${useCaseIn.memberId}'s subscription to ${useCaseIn.workbookId}" } + } +} \ No newline at end of file diff --git a/api/src/main/kotlin/com/few/api/domain/common/lock/LockFor.kt b/api/src/main/kotlin/com/few/api/domain/common/lock/LockFor.kt new file mode 100644 index 000000000..bd1af4189 --- /dev/null +++ b/api/src/main/kotlin/com/few/api/domain/common/lock/LockFor.kt @@ -0,0 +1,7 @@ +package com.few.api.domain.common.lock + +@Target(AnnotationTarget.FUNCTION) +@Retention(AnnotationRetention.RUNTIME) +annotation class LockFor( + val identifier: LockIdentifier, +) \ No newline at end of file diff --git a/api/src/main/kotlin/com/few/api/domain/common/lock/LockIdentifier.kt b/api/src/main/kotlin/com/few/api/domain/common/lock/LockIdentifier.kt new file mode 100644 index 000000000..532e80899 --- /dev/null +++ b/api/src/main/kotlin/com/few/api/domain/common/lock/LockIdentifier.kt @@ -0,0 +1,8 @@ +package com.few.api.domain.common.lock + +enum class LockIdentifier { + /** + * 구독 테이블에 멤버와 워크북을 기준으로 락을 건다. + */ + SUBSCRIPTION_MEMBER_ID_WORKBOOK_ID, +} \ No newline at end of file diff --git a/api/src/main/kotlin/com/few/api/domain/subscription/usecase/SubscribeWorkbookUseCase.kt b/api/src/main/kotlin/com/few/api/domain/subscription/usecase/SubscribeWorkbookUseCase.kt index e19faa369..7296d4f7a 100644 --- a/api/src/main/kotlin/com/few/api/domain/subscription/usecase/SubscribeWorkbookUseCase.kt +++ b/api/src/main/kotlin/com/few/api/domain/subscription/usecase/SubscribeWorkbookUseCase.kt @@ -1,5 +1,7 @@ package com.few.api.domain.subscription.usecase +import com.few.api.domain.common.lock.LockFor +import com.few.api.domain.common.lock.LockIdentifier import com.few.api.domain.subscription.event.dto.WorkbookSubscriptionEvent import com.few.api.repo.dao.subscription.SubscriptionDao import com.few.api.repo.dao.subscription.command.InsertWorkbookSubscriptionCommand @@ -21,6 +23,7 @@ class SubscribeWorkbookUseCase( private val applicationEventPublisher: ApplicationEventPublisher, ) { + @LockFor(LockIdentifier.SUBSCRIPTION_MEMBER_ID_WORKBOOK_ID) @Transactional fun execute(useCaseIn: SubscribeWorkbookUseCaseIn) { val subTargetWorkbookId = useCaseIn.workbookId