동시성 문제 - 중복 저장
@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 제약 조건의 경우 쓰기를 할 때 쓰기 잠금을 지원하고 있어 중복 저장이라는 동시성 문제를 겪지 않을 수 있었음)
UNIQUE 제약 조건을 제거한 이후에는 위와 같이 중복 저장이 발생하는 것을 확인할 수 있었습니다.
사진에서는 curl의 &를 통해 임의로 동시 요청을 만들었지만 위와 같은 문제는 클라이언트에서 의도치 않은 중복 요청, 네트워크 이슈와 같은 상황에서 충분히 발생할 수 있는 문제라고 생각하였고 락을 도입하여 이러한 문제를 해결해 보았습니다.
네임드 락
FEW를 통해 아직 돈을 벌고 있지는 않기 때문에 락을 구현하기 위해 새로운 자원을 투입하기보다는 기존의 자원을 활용하는 방법을 선택하였고 MySQL에서 지원하는 네임드 락을 사용하여 락을 구현하였습니다.
구현
쿼리
MySQL의 경우 네임드 락을 GET_LOCK 함수를 통해 지원하고 파라미터로 임의의 문자열과 타임아웃을 위한 숫자를 받습니다.
네임드 락에서 락의 기준은 임의의 문자열이기 때문에 멤버 식별자와 워크북 식별자를 합하여 문자열을 구성하였습니다.
그리고 타임아웃의 경우 5초를 기본으로 설정하였습니다.
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
}
< jOOQ를 활용한 네임드 락 쿼리 >
그리고 획득한 락을 해지하기 위한 RELEASE_LOCK 함수를 아래와 같이 구현하였습니다.
fun releaseLock(memberId: Long, workbookId: Long): Boolean {
return dslContext.fetch(
"""
SELECT RELEASE_LOCK(CONCAT('subscription_', $memberId, '_', $workbookId));
"""
).into(Int::class.java).first() == 1
}
< jOOQ를 활용한 릴리즈 락 쿼리 >
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 어노테이션을 기준으로 포인트 컷을 설정하고 메서드 수행 전/후에 락을 획득/해지하도록 하였습니다.
(LockFor 어노테이션의 identifier을 활용한 분기를 통해 락 획득/해지 요구사항을 구현하였기 때문에 새로운 락 요구사항에도 유연히 대응할 수 있을 것이라 기대하고 있습니다.)
확인
구현 이후 이전과 동일한 요청을 실행해 본 결과 이전과 다르게 하나의 구독 정보만 저장된 것을 확인할 수 있었습니다.
추가적으로
타임아웃
네임드 락을 획득하기 위한 GET_LOCK 함수의 파라미터 중 타임아웃의 의미가 궁금하여 아래와 같은 설정을 추가하여 테스트를 진행해 보았습니다.
- 구독 기능에 대기 시간 10초
- 타임 아웃 5초
Pinpoint를 통해 확인해 본 결과 첫 번째 요청이 10초 동안 락을 점유하고 있어서 동일한 락을 원하는 두 번째 요청은 락을 획득하기 위해 5초간 대기한 이후 예외를 발생시키는 것을 확인할 수 있었습니다.
이를 통해 타임아웃의 경우 락 획득을 위한 대기시간임을 알 수 있었고 락을 위해 너무 많은 대기가 일어나지 않도록 적절한 대기시간을 설정해야겠다는 생각을 할 수 있었습니다.
소감
동시성 문제는 특정 정보에 대한 수정 권한이 여러 사람에게 있을 때만 발생할 것이라 생각하였는데 이번 경험을 통해 중복 저장의 경우에도 동시성 문제가 발생할 수 있다는 것을 알 수 있어 더욱 의미 있는 구현이었던 것 같습니다.
'개발' 카테고리의 다른 글
SpringBoot에서 HTTP 요청을 처리하는 과정을 살펴보며 (DispatcherServlet) (0) | 2024.08.26 |
---|---|
Pinpoint 도입 이모저모 (0) | 2024.08.22 |
유연한 코드를 작성하기 위한 고민 (0) | 2024.08.20 |
하나의 유즈케이스에서 여러 번 쿼리를 요청하면? (0) | 2024.08.19 |
캐싱을 도입하며 (0) | 2024.07.30 |