티스토리 뷰

기존에 운영되고 있던 Api 서버에서 동시성 이슈가 발생이 되었다. 포인트에서 마일리지로 전환하는 과정에서 적립과 차감내역이 정상적으로 반영되지 않던 문제였는데, 해결한 과정을 정리하려고 한다.
 

문제

포인트에서 마일리지로 전환할 때, 포인트는 차감되고 마일리지는 적립이 된다. 이때, 포인트가 1,000원이 있고 마일리지가 0원인 상태에서 포인트 -> 마일리지 전환에 대한 3번의 요청이 동시에 들어오면, 아래와 같은 결과가 발생하게 되었다.

포인트 차감

포인트 차감의 경우, 950원 -> 900원 -> 850원으로 반영되어야 하는데 3개의 내역이 모두 950원의 내역으로 반영되어 있다.

마일리지 적립 

마일리지 적립의 경우, 50원 -> 100원 -> 150원으로 반영되어야 하는데 3개의 내역이 모두 50원의 내역으로 반영되어 있다.

통합 내역

통합 내역의 경우에도 total_mileage 값은 150원 total_point 값은 850원으로 반영되어야 하는데, total_mileage 값은 50원과 total_point 값은 950원으로 반영되어 있다.
 
위와 같은 결과로 인해 데이터 정합성이 떨어지게 되었고, 사용자들이 서비스를 사용하는 데 있어서 신뢰감이 떨어지게 되는 경우가 발생하게 되었다. 이를 해결하기에 앞서 원인은 무엇이며, 원인을 어떻게 해결할 것인지에 대해 고민을 많이 했다.
 

원인

동시에 2개의 트랜잭션에서 하나의 레코드에 접근하는 경우, 같은 버전의 레코드를 갖게 된다. 이때 2개의 트랜잭션이 동시에 데이터를 변경한다면, 한 트랜잭션에서 변경했던 결과는 잃어버리고 나중에 변경했던 결과만 반영되는 것이 원인이었다.(갱신 분실이 발생하고 있었음.) 상황에 따라 마지막 결과만 정상적으로 반영되어도 괜찮은 비즈니스라면 문제가 없다. 나의 경우에는 2개의 트랜잭션 동시에 접근했을 때, 처음 처리된 트랜잭션의 결과가 이후의 트랜잭션의 결과에도 영향이 있기 때문에 각각의 트랜잭션은 순차적으로 처리되는 것이 중요했다.
 

기술 선택

아래의 3가지를 기술을 통해 동시성 문제를 해결하고자 고민을 했었는데, 동시 요청이 와도 작업들이 누락되지 않고, 정상적으로 처리되어야 한다는 부분에 초점을 맞추고 기술들을 파악하기 시작했다.

  • JPA Version 방식을 활용한 낙관적 락(Optimistic Locking)
  • MySQL의 Named Lock 활용하기
  • Redis Java Client 중의 하나인 Redisson의 RLock을 활용하기

첫 번째로, JPA Version 방식은 충돌을 감지하는 방식이다. 따라서 하나의 작업만 성공하고 나머지 작업들은 ObjectOptimisticLockingException 예외로 인해서 모두 실패한다. 내가 해결하고자 하는 목표는 동시 요청이 와도 작업들이 누락되지 않고, 정상적으로 처리되어야 한다는 목표가 있었기 때문에 이를 충족시키려면, 예외가 발생했을 경우 재시도 처리를 추가적으로 구현해야 하는 부분이 있었으며, 더 나아가 재시도 처리가 실패하는 케이스를 고려해야 하는 부분이 있었기 때문에 코드 복잡성이 높아지는 부분이 있었다.
 
두 번째로, MySQL의 Named Lock의 경우 GET_LOCK(문자열, Timeout)을 통해 Lock을 획득하고, RELEASE_LOCK(문자열)을 통해 Lock을 해제하는 방식으로 구현할 수 있다. 또한 GET_LOCK을 얻을 때 Timeout 시간을 지정하게 되는데, Timeout 시간 내에서 동시에 요청에 의한 작업들은 누락되지 않고, 정상적으로 처리되는 부분이 있었다. 추가로 비즈니스 로직에서 사용하는 DB Connection Pool과 Lock을 획득하는 DB Connection Pool을 분리해야 하는 작업이 필요했고, 어떠한 작업이 오래 걸린다면 작업 시간만큼 잠금을 소유하고 있게 된다. 이로 인해 대기하고 있는 작업들은 타임아웃으로 실패하게 되는 부분들이 있었다. 추가로 가장 중요한 저장소인 MySQL에는 Lock과 관련된 요청으로 큰 부하를 주고 싶지 않았다.
 
세 번째로, Redis Java Client 중의 하나인 Redisson 클라이언트였는데, 자체적으로 Lock과 관련된 잠금 객체가 존재했었다. 가장 큰 특징들은 Redis 저장소를 활용하기 때문에 Lock에 대한 만료 시간을 설정할 수 있다는 부분과 대기하고 있는 작업들이 Redis에 Lock을 획득할 수 있는지를 스핀 락 방법으로 체크하는 것이 아닌, Pub/Sub 방식을 통해 Redis에서 Lock을 획득할 수 있다고 알려주는 특징들이 있었다. MySQL의 Named Lock과 동일하게 Timeout 시간을 지정할 수 있고, Timeout 시간 내에서 동시에 요청에 의한 작업들은 누락되지 않고, 정상적으로 처리되는 부분이 있었다. 다만 Redis 인프라를 관리해야 하는 부담이 존재했다.
 
결국 세 번째 방식인 Redisson 클라이언트를 활용한 잠금 처리 방법을 선택했다. 가장 큰 이유는 잠금이 해제되지 않을 경우에 어떻게 처리할지 고민이 많이 됐었는데(잠금을 획득했는데 애플리케이션 서버가 죽는 경우. 즉, 잠금이 해제되지 않은 상태), Redisson의 경우에는 만료 시간이 지나면 Lock을 해제하기 때문에 잠금 관리에 대한 부담이 적을 것이라는 점과 Lock 만료 시간이 지나면, 자동으로 해제시키기 때문에 대기하고 있던 작업들은 자신들의 작업을 수행할 수 있다. 따라서 Retry와 같은 별도의 구현이 필요 없다는 점도 한몫하게 되어 Redisson 클라이언트를 선택했다.
 

해결

Spring AOP와 @Annotation 클래스를 정의해서 아래와 같은 Lock 획득 및 해제 로직을 구현했다.

@Aspect
@Component // 다른 분산 락 Aspect를 사용하는 경우, 반드시 주석 처리해야 함
class RedissonDistributedLockAspect(private val redissonClient: RedissonClient) {

    private val logger = LoggerFactory.getLogger(this::class.java)
    private val uniqueLockMemberIdField = "memberId"

    @Around("@annotation(distributedLock)")
    fun <T : Any> executeWithLock(joinPoint: ProceedingJoinPoint, distributedLock: DistributedLock): T {
        val lockName = getLockName(joinPoint = joinPoint)
        val rLock = getLock(lockName = lockName)

        tryLock(
            rLock = rLock,
            lockName = lockName,
            waitTime = distributedLock.waitTime,
            leaseTime = distributedLock.leaseTime,
            timeUnit = distributedLock.timeUnit
        )

        try {
            @Suppress("UNCHECKED_CAST")
            return joinPoint.proceed() as T
        } finally {
            releaseLock(rLock = rLock, lockName = lockName)
        }
    }

    private fun getLockName(joinPoint: ProceedingJoinPoint): String {
        if (ObjectUtils.isEmpty(joinPoint.args)) {
            throw IllegalArgumentException("적용하려는 메서드의 인자가 존재하지 않습니다.")
        }

        return RedissonClientConfig.LOCK_NAME_PREFIX + getMemberId(joinPoint = joinPoint)
    }

    private fun getMemberId(joinPoint: ProceedingJoinPoint): Any {
        joinPoint.args.forEach { arg ->
            val field = getDeclaredMemberIdField(arg = arg)
            field.getAnnotation(DistributedLockUniqueKey::class.java)?.let {
                field.isAccessible = true
                return field.get(arg)
            }
        }

        throw RuntimeException("memberId 필드에 @DistributedLockUniqueKey을 설정해주세요.")
    }

    private fun getDeclaredMemberIdField(arg: Any): Field {
        try {
            return arg.javaClass.getDeclaredField(uniqueLockMemberIdField)
        } catch (exception: NoSuchFieldException) {
            throw IllegalAccessException("memberId 필드가 존재하지 않아서 접근할 수 없습니다.")
        }
    }

    private fun getLock(lockName: String): RLock {
        logger.info("getLock (key = {})", lockName)

        return redissonClient.getLock(lockName)
    }

    private fun tryLock(rLock: RLock, lockName: String, waitTime: Long, leaseTime: Long, timeUnit: TimeUnit) {
        val isAcquiredLock: Boolean

        try {
            isAcquiredLock = rLock.tryLock(waitTime, leaseTime, timeUnit)
        } catch (exception: InterruptedException) {
            throw RuntimeException("잠금을 획득하는 과정에서 예외로 인해 작업이 중단되었습니다.")
        }

        if (!isAcquiredLock) {
            throw RuntimeException("일시적으로 작업을 수행할 수 없습니다. 잠시 후에 다시 시도해주세요.")
        }

        logger.info("tryLock (key = {})", lockName)
    }

    private fun releaseLock(rLock: RLock, lockName: String) {
        if (rLock.isLocked && rLock.isHeldByCurrentThread) {
            logger.info("releaseLock (key = {})", lockName)
            return rLock.unlock()
        }

        logger.info("Already releaseLock (key = {})", lockName)
    }
}

위의 로직에서 가장 중요한 점이 releaseLock() 메서드인데, rLock.unlock()만 수행하게 되면 Lock을 획득하지 않은 경우에 대해서 Lock을 해제하려고 하고, 예외가 발생하게 된다. 따라서 rLock.isLocked 조건을 추가해야 한다. 하지만 여기서 끝난 것이 아니고, 다른 스레드에서 이미 획득한 Lock을 해제하는 경우도 존재한다. 따라서 rLock.isHeldByCurrentThread 조건을 추가해야 현재 스레드에서 획득한 Lock만 해제하게 된다. 위의 2가지 조건에 대해서 성립하지 않는 경우는 현재 처리되는 작업 시간이 Lock 만료 시간보다 길어서 이미 Lock이 해제된 경우이다. 그래서 위의 코드를 보면 알 수 있겠지만, Already releaseLock (key = {lockName}) 로그를 통해 이미 Lock이 해제되었음을 알 수 있다.
 

결과

포인트 차감

포인트 차감의 경우, 950원 -> 900원 -> 850원의 내역이 정상적으로 반영되었다.

마일리지 적립

마일리지 적립의 경우, 50원 -> 100원 -> 150원의 내역이 정상적으로 반영되었다.

통합 내역

통합 내역의 경우에도 total_mileage 값은 150원 total_point 값은 850원으로 정상적인 값이 반영되었다.
 

정리

주어진 문제를 어느 정도 수습했지만, 동시성 이슈는 굉장히 어려운 문제이기 때문에 깊이 있는 학습이 필요하다. 추가로 Redis 서버에 장애가 발생하는 경우에 대해서도 대비를 해야 한다. 학습을 통해 부족한 부분들을 보완하고, 추후에 다시 문제가 생긴다면 또 다른 내용을 정리하려고 한다.

댓글
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
TAG
more
«   2024/05   »
1 2 3 4
5 6 7 8 9 10 11
12 13 14 15 16 17 18
19 20 21 22 23 24 25
26 27 28 29 30 31
글 보관함