동시성 문제 - 중복 저장
@Component
class SubscribeWorkbookUseCase(
private val subscriptionDao: SubscriptionDao,
private val applicationEventPublisher: ApplicationEventPublisher,
) {
@Transactional
fun execute(useCaseIn: SubscribeWorkbookUseCaseIn) {
/** 워크북 구독 히스토리를 조회한다. */
val workbookSubscriptionHistory = subscriptionDao.selectTopWorkbookSubscriptionStatus()
when {
/** 구독한 히스토리가 없는 경우 */
workbookSubscriptionHistory.isNew -> {
/** 구독 기록을 저장한다 */
subscriptionDao.insertWorkbookSubscription(command)
}
/** 이미 구독한 히스토리가 있고 구독이 취소된 경우 */
workbookSubscriptionHistory.isCancelSub -> {
if (cancelledWorkbookSubscriptionHistory.isSubEnd(lastDay)) {
/** 이미 구독이 종료된 경우 */
throw SubscribeIllegalArgumentException("subscribe.state.end")
} else {
/** 재구독인 경우 구독 기록을 갱신한다 */
subscriptionDao.reSubscribeWorkbookSubscription(command)
}
}
/** 구독 중인 경우 */
else -> {
throw SubscribeIllegalArgumentException("subscribe.state.subscribed")
}
}
/**
* 구독 이벤트 발행 한다
*/
applicationEventPublisher.publishEvent(
WorkbookSubscriptionEvent(
workbookId = subTargetWorkbookId,
memberId = memberId,
articleDayCol = workbookSubscriptionHistory.subDay
)
)
}
}
FEW에서 워크북을 구독하면 '멤버 구독 정보 저장/갱신', '아티클 이메일 전송' 그리고 '전체 구독 정보 웹 훅'이 수행됩니다.
이때 구독 정보는 별도의 식별자를 가지지만 멤버 식별자와 워크북 식별자를 통해 조회되기 때문에 동일한 정보가 중복돼서 저장되면 안 됩니다.
create table subscription
(
id bigint auto_increment
primary key,
member_id bigint not null,
target_workbook_id bigint null,
created_at timestamp default CURRENT_TIMESTAMP not null,
deleted_at timestamp null,
unsubs_opinion varchar(300) null,
progress bigint default 0 not null,
send_time time default '08:00:00' null,
send_day char(10) default '0011111' not null,
modified_at timestamp default CURRENT_TIMESTAMP not null,
constraint target_workbook_id_member_id_uq
unique (target_workbook_id, member_id)
);
그렇기에 기존의 구독 테이블의 경우 위와 같이 워크북 식별자와 멤버 식별자가 유니크하게 설계하였습니다.
UNIQUE 제약 조건을 통해 만들어지는 인덱스 역시 효과적으로 사용할 수 있을 것이라 생각하였습니다.
하지만 개발이 진행되면 워크북 식별자와 멤버 식별자를 함께 사용해서 쿼리를 작성하기보다는 각각의 식별자를 사용해서 작성한 쿼리가 더 많아졌고 논의 끝에 UNIQUE 제약 조건을 제거하고 각각의 인덱스를 만들기로 하였습니다.
이에 기존의 경우 UNIQUE 제약 조건이 동시에 구독 요청이 오더라도 위의 사진과 같이 중복 저장을 막아주었지만
UNIQUE 제약 조건을 제거한 이후에는 위와 같이 중복 저장이 발생하는 것을 확인할 수 있었습니다.
사진에서는 curl의 &를 통해 임의로 동시 요청을 만들었지만 위와 같은 문제는 클라이언트에서 의도치 않은 중복 요청, 네트워크 이슈와 같은 상황에서 충분히 발생할 수 있는 문제라고 생각하였고 락을 도입하여 이러한 문제를 해결해 보았습니다.
네임드 락
FEW를 통해 아직 돈을 벌고 있지는 않기 때문에 락을 구현하기 위해 새로운 자원을 투입하기보다는 기존의 자원을 활용하는 방법을 선택하였고 MySQL에서 지원하는 네임드 락을 사용하여 락을 구현하였습니다.
구현
쿼리
MySQL의 경우 네임드 락을 GET_LOCK
함수를 통해 지원하고 파라미터로 임의의 문자열과 타임아웃을 위한 숫자를 받습니다.
네임드 락에서 락의 기준은 임의의 문자열이기 때문에 멤버 식별자와 워크북 식별자를 합하여 문자열을 구성하였습니다.
그리고 타임아웃의 경우 5초를 기본으로 설정하였습니다.
jOOQ를 활용한 네임드 락 쿼리
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
}
그리고 획득한 락을 해지하기 위한 RELEASE_LOCK
함수를 아래와 같이 구현하였습니다.
jOOQ를 활용한 릴리즈 락 쿼리
fun releaseLock(memberId: Long, workbookId: Long): Boolean {
return dslContext.fetch(
"""
SELECT RELEASE_LOCK(CONCAT('subscription_', $memberId, '_', $workbookId));
"""
).into(Int::class.java).first() == 1
}
AOP
@Target(AnnotationTarget.FUNCTION)
@Retention(AnnotationRetention.RUNTIME)
annotation class LockFor(
val identifier: LockIdentifier,
)
enum class LockIdentifier {
/**
* 구독 테이블에 멤버와 워크북을 기준으로 락을 건다.
*/
SUBSCRIPTION_MEMBER_ID_WORKBOOK_ID,
}
우선 락이 필요한 기능임을 나타낼 어노테이션과 어떤 식별자를 통해 락을 획득할 것인지 나타내는 열거형 클래스를 위와 같이 만들었습니다.
@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)
}
}
}
}
@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)
}
LockAspect
에서는 LockFor
어노테이션을 기준으로 포인트 컷을 설정하고 메서드 수행 전/후에 락을 획득/해지하도록 하였습니다.
확인
구현 이후 이전과 동일한 요청을 실행해 본 결과 이전과 다르게 하나의 구독 정보만 저장된 것을 확인할 수 있었습니다.
추가적으로
타임아웃
네임드 락을 획득하기 위한 GET_LOCK
함수의 파라미터 중 타임아웃의 의미가 궁금하여 아래와 같은 설정을 추가하여 테스트를 진행해 보았습니다.
- 구독 기능에 대기 시간 10초
- 타임 아웃 5초
Pinpoint를 통해 확인해 본 결과 첫 번째 요청이 10초 동안 락을 점유하고 있어서 동일한 락을 원하는 두 번째 요청은 락을 획득하기 위해 5초간 대기한 이후 예외를 발생시키는 것을 확인할 수 있었습니다.
이를 통해 타임아웃의 경우 락 획득을 위한 대기시간임을 알 수 있었고 락을 위해 너무 많은 대기가 일어나지 않도록 적절한 대기시간을 설정해야겠다는 생각을 할 수 있었습니다.
'개발' 카테고리의 다른 글
테스트 객체 용어 정리 (0) | 2024.12.01 |
---|---|
MC/DC 커버리지 (0) | 2024.12.01 |
Pinpoint 도입 이모저모 (0) | 2024.08.22 |
jOOQ 사용기 (1) | 2024.07.23 |
FEW MVP 기능을 구현하며 (0) | 2024.07.14 |