반응형
개요
- 현재 공연 좌석을 예매할 시 락의 적용 없이 DB에 바로 저장시도 하도록 되어있습니다.
- 공연 좌석 예매는 공연 날짜, 좌석에 따라 결정되며, 이 두가지 조건에 대해 Unique Constraint가 걸려있어 중복 예매를 방지하지만, 바로 Database에 많은 INSERT Query를 날리는 것은 많은 부하를 줄 수 있다고 판단했습니다.
@Entity
@Table(
name = "carts",
uniqueConstraints = [UniqueConstraint(
name = "ux_seat_uid_date_uid",
columnNames = ["seat_id", "performance_datetime_id"]
)]
)
class Cart(
//...
}
- 따라서 Redis로 한번 중복 예매를 한번 거르고 INSERT 요청을 보내 DB에 부하를 줄이기 위해서 Proxy를 도입해, Proxy에서 Redis를 사용해 이미 예약되어 있는지 확인하는 로직을 개발해 보고자 하였습니다.
Sequence Diagram
- Proxy를 도입하지 않은 Sequence Diagram은 다음과 같습니다.
- 동일한 2개의 예약에 대해, 두번째 요청은 DB에 INSERT 문으로 예약을 시도하지만, Unique Constraint에 의해 DataIntegrityException 을 throw하여 예약에 실패합니다.
- 그러면 Redis를 활용한 Proxy를 도입한다면 어떻게 될까요?
- 이렇게 Redis를 사용해서 Key를 저장하고, Key를 사용해서 중복 예약을 막는다면 DB에 직접 접근하지 않고 중복 예약을 체크할 수 있습니다.
Redis 자료 구조 선정
- Redis 자료 구조는 Strings를 선정하였습니다.
- Get/Set이 O(1)만에 가능
- 처음에 생각했던 자료구조는 BitMap 이였는데, BitMap 의 장점을 생각했던 이유는
- 비트 값을 저장하기 때문에 효율적인 Memory 사용이 가능(시간 복잡도도 O(1))
- seat는 row, column을 가지고 있기 때문에 row, column을 가지고 index를 만들어서 사용 가능
💡 index 계산식 : row * (총 column) + column
- 입니다. 하지만 예매 객체인 Cart를 사용해서는 index를 계산할 수가 없기 때문에 Strings를 사용하기로 결정했습니다.
구현 방식
- 구현 방식은 예매하는 로직(DB 저장)인 CartWriter.save() 에 Proxy를 적용하는 방식으로 결정하였습니다. save() 메서드에 Proxy Pattern을 적용하면 기존 코드의 적은 변경으로도 Redis 적용이 가능합니다.
@Transactional
@Component
class CartWriter(
private val cartRepository: CartRepository
) {
fun save(cart: Cart) {
cartRepository.save(cart)
}
// ...
}
@Aspect
@Component
class CartWriterLockingProxy {
@Around("execution(* com.flab.ticketing.order.repository.writer.CartWriter.save(..))")
fun acquireLockBeforeSave(joinPoint: ProceedingJoinPoint): Any? {
return joinPoint.proceed()
}
}
예약할 때 중복 체크 로직 구현하기
- 예약할 때 로직은 다음과 같습니다.
@Around("execution(* com.flab.ticketing.order.repository.writer.CartWriter.save(..))")
fun acquireLockBeforeSave(joinPoint: ProceedingJoinPoint): Any? {
val (key, value) = joinPoint.extractKVFromCart()
acquireLockOrThrows(key, value)
return joinPoint.proceed()
}
private fun ProceedingJoinPoint.extractKVFromCart(): Pair<String, String> {
val cart: Cart = this.args
.firstOrNull { it is Cart }
?.let { it as Cart } ?: throw InternalServerException(CommonErrorInfos.SERVICE_ERROR)
val key = "${LOCK_PREFIX}${cart.performanceDateTime.uid}_${cart.seat.uid}"
val value = "${Thread.currentThread().id}:${System.currentTimeMillis()}"
return Pair(key, value)
}
extractKVFromCart 메서드는 save 매개변수인 Cart 객체를 가져오고, 이를 기반으로 Redis에 저장할 Key와 Value를 만드는 메서드입니다.
private fun acquireLockOrThrows(key: String, value: String) {
val result = redisTemplate.opsForValue()
.setIfAbsent(key, value, LOCK_TIMEOUT_MILLIS)!!
if (!result) {
throw DuplicatedException(OrderErrorInfos.ALREADY_RESERVED)
}
}
- extractKVFromCart 메서드에서 만든 Key를 기반으로 Lock을 얻는 로직인데요, 보통 Lock에는 재시도를 하도록 합니다만, 예매 서버의 특성상 한번 Lock이 걸린 객체는 Lock이 풀릴 경우가 적기 때문에 Retry는 구현하지 않았습니다.
- 또, 예매 서버는 순간적으로 짧은 시간에 많은 트래픽이 몰릴 수 있기 때문에, 특정 Key를 Redis에 계속 저장하는 것은 저장공간 낭비로 이루어 질 수 있습니다. 따라서 충분한 시간의 TTL을 두도록 해서 저장공간을 효율적으로 사용하도록 하였습니다.
예약을 취소할 때 Release하는 로직 추가
- 위 코드만을 사용해서 서비스를 운영하다 보면 어떤 경우의 수에 대해 오류가 발생할 수 있는데요, 그 경우의 수는 다음과 같습니다.
💡 어떤 사용자가 예약을 완료한 후, TTL 시간 이전에 예약을 취소할 시 Redis의 값이 만료될 때까지 다시 예약을 못하는 문제
- 따라서 예약을 제거할 때 Redis의 값도 함께 제거해주어야 합니다. 이에 대한 로직은 다음과 같습니다.
@Around("execution(* com.flab.ticketing.order.repository.writer.CartWriter.deleteAll(..))")
fun releaseLockBeforeRemove(joinPoint: ProceedingJoinPoint): Any? {
val keyList = joinPoint.extractKeyListFromCartList()
redisTemplate.delete(keyList)
return joinPoint.proceed()
}
추상화
- 위 로직은 Lock을 바로 Release하지 않기 때문에 분산락이라고 보기는 어렵지만, 중복 체크를 할 때 사용하도록 추상화가 가능할 것 같습니다. 그래서 아래와 같이 추상화를 하기로 결정했습니다.
- Annotation @DuplicationCheck(key=?) 을 사용하는 메서드는 해당 어노테이션의 Key(Spring EL)을 기준으로 중복 체크
- Annotation ReleaseDuplicationCheck(key=?) 를 사용하는 메서드는 key가 더이상 중복 체크가 되지 않도록 제거
annotation class DuplicatedCheck(
val key: String
)
annotation class ReleaseDuplicateCheck(
val key: String
)
이렇게 되면 key를 SpringEL 표현식으로 작성하고, 이를 파싱하는 객체가 있어야 합니다.
- 이번 글을 작성하며 마켓컬리의 분산락 글을 많이 참고하였는데, Spring EL 표현식을 파싱하는 객체도 여기서 가져와서 쉽게 사용할 수 있었습니다.
object CustomSpringELParser {
fun getDynamicValue(parameterNames: Array<String>, args: Array<Any>, expression: String): Any? {
val parser: ExpressionParser = SpelExpressionParser()
val context = StandardEvaluationContext()
parameterNames.zip(args).forEach { (name, value) ->
context.setVariable(name, value)
}
return parser.parseExpression(expression).getValue(context, Any::class.java)
}
}
- 그러면 아래와 같이 값을 파싱해서 key를 만들 수 있습니다.
private fun extractKeyFromAnnotation(joinPoint: ProceedingJoinPoint): Pair<String, String> {
val signature = joinPoint.signature as MethodSignature
val method = signature.method
val annotation = method.getAnnotation(DuplicatedCheck::class.java)
val key = "${LOCK_PREFIX}${getDynamicValue(signature.parameterNames, joinPoint.args, annotation.key)}"
val value = "${Thread.currentThread().id}:${System.currentTimeMillis()}"
return Pair(key, value)
}
- Release하는 것은 List도 지원될 필요가 있었는데요, 그래서 Release하는 로직은 List 파싱이 가능하도록 변경하였습니다.
private fun extractReleaseKeys(joinPoint: ProceedingJoinPoint): List<String> {
val signature = joinPoint.signature as MethodSignature
val method = signature.method
val annotation = method.getAnnotation(ReleaseDuplicateCheck::class.java)
val parameterNames = signature.parameterNames
return when (val result = getDynamicValue(parameterNames, joinPoint.args, annotation.key)) {
is List<*> -> result.filterNotNull().map { "${LOCK_PREFIX}$it" }
null -> throw InternalServerException(CommonErrorInfos.SERVICE_ERROR)
else -> listOf(
"${LOCK_PREFIX}$result"
)
}
}
- 이렇게하면 아래와 같이 Spring EL을 작성해 단건 또는 리스트로 처리가 가능합니다.
@DuplicatedCheck(
key = "#cart.seat.uid + '_' + #cart.performanceDateTime.uid",
)
fun save(cart: Cart) {
// ...
}
@ReleaseDuplicateCheck(
key = "#carts.![seat.uid + '_' + performanceDateTime.uid]",
)
fun deleteAll(carts: List<Cart>) {
// ...
}
- AcquireLock에는 데드락 발생 가능성이 있기 때문에 단건 등록만 가능하도록 하였습니다.
참고 자료
https://helloworld.kurly.com/blog/distributed-redisson-lock/
반응형
'Spring > Ticketing 프로젝트' 카테고리의 다른 글
공연 조회 API 성능 측정 및 개선 사안 찾아보기 (0) | 2024.11.16 |
---|---|
공연 조회 API에 캐싱 적용하기 (0) | 2024.11.16 |
공연 정보 조회 API 쿼리 분석하고 개선하기 (0) | 2024.11.15 |
Spring + Grafana, Loki, Prometheus로 모니터링 시스템 구축하기 (0) | 2024.11.15 |
비동기 환경에서 Request를 유지하려면 어떻게 해야할까? (0) | 2024.11.15 |