Back-end/기타 (BE)

[동시성] 오프라인 선점 락 (Redis)

philo0407 2023. 12. 12. 23:53

 

사진: Unsplash 의 Nicolas HIPPERT

 

코드는 이곳에 !
https://github.com/progress0407/jvm-tools/tree/main/redis

 

 

과거에 맡았던 도메인 중에 동일 요청건에 대한 중복 결제 요청을 막아야할 일이 있었다 (자세히 기술할 수 없다!)

그때 사용했던 방식이다

과거에 최범균님의 DDD에서 본 방법인데, 이름은 오프라인 선점 잠금으로 알고 있다

마침 사내에서 Redis를 사용했고 락을 짧은 시간 동안 유지 + 분산 환경임을 고려하여 Redis를 사용했다

 

세간에 알려진 오프라인 선점 락과는 다소 다른 것은... 시간 순서에 따른 락을 선점 -> 해제는 아니었다

 

즉 아래와 같은 상황이 아니었다

 

API 1 접근: Lock 획득

API2  접근: Lock 방출

 

정확히는 아래와 같은 상황이다

 

API 1에 동시 접근 가능성이 존재하고API 2에서 또한 API 1을 수행하는 동안 접근이 가능

 

문제 사항

짧은 시간 동안(* ms ~ 5sec) 같은 결제건에 대한 중복 요청이 온다

기존의 최초로 먼저 온 요청건은 먼저 처리한다

해당 요청은 한 API만으로 오지 않을 수 있다 -> 즉 @Tx 하나만 Thread-Safe 하게 처리하는 방법으로는 승부볼 수 없다

 

 

Core 코드

아래와 같이 LockManager를 구현해서 처리할 수 있다

 

Lock을 관리하는 핵심 코드

Lock을 구현하는 방식이 JdbcTemplate, RDBMS  JPA, Redis 등 다양하게 있을 수 있기에 인터페이스를 추출했다

/**
 * Lock을 관리하는 역할
 */
interface LockManager {

    /**
     * Lock 선점
     */
    fun preemptLock(lockId: String)

    /**
     * Lock 보유 확인
     */
    fun checkLock(lockId: String): Boolean

    /**
     * 락 방출
     */
    fun releaseLock(lockId: String)

    /**
     * 기존 락 보유시간 연장
     */
    fun extendLockExpiration(lockId: String, extendSeconds: Int)
}

 

@Component
class RedisLockManager(private val repository: LockRepository) : LockManager {

    override fun preemptLock(lockId: String) {

        if (checkLock(lockId)) throw LockException("Lock이 이미 존재합니다.")

        val lock = Lock(lockId, 10)

        repository.save(lock)
    }

    override fun checkLock(lockId: String): Boolean {

        return repository.existsById(lockId)
    }

    override fun releaseLock(lockId: String) {

        if (checkLock(lockId).not()) throw LockException("Lock이 존재하지 않습니다.")

        repository.deleteById(lockId)
    }

    override fun extendLockExpiration(lockId: String, extendSeconds: Int) {

        val lock = repository.findByIdOrNull(lockId) ?: throw LockException("Lock이 존재하지 않습니다.")

        lock.update(extendSeconds)
    }
}

 

Repository

Lock을 다루는 Repository (다음 포스팅에서 다룰 예정)

interface LockRepository : ExtendedCrudRepository<Lock, String> {
}

 

interface ExtendedCrudRepository<T, ID>: CrudRepository<T, ID> {

    fun findByIdOrNull(id: ID): T? {

        val optionalEntity: Optional<T> = findById(id)

        return optionalEntity.orElse(null)
    }
}

 

Entity

@RedisHash(value = "lock")
class Lock(

    @Id
    private val paymentTxId: String,

    @TimeToLive(unit = SECONDS)
    private var ttl: Int
) {

    fun update(ttl: Int): Lock {
        this.ttl = ttl
        return this
    }

    override fun toString(): String {
        return "Lock(paymentTxId='$paymentTxId', ttl=$ttl)"
    }
}

 

 

Service

@Service
class LockService(private val lockManager: LockManager) {

    @Transactional
    fun doPaymentProcess(txId: String): Any {
        if(lockManager.checkLock(txId)) {
            return "이미 결제 처리 중입니다."
        }

        lockManager.preemptLock(txId)

        Thread.sleep(6_000)

        lockManager.releaseLock(txId)

        return "결제 처리 완료 및 락 해제"
    }
}

 

Controller

락으로 보호해야하는 API는 2개라고 가정하였다

@RestController
@RequestMapping("/test/redis")
class LockController(private val lockService: LockService) {

    @RequestMapping("/proceed-payment/{txId}")
    fun doSomething(@PathVariable txId: String): Any {

        return lockService.doPaymentProcess(txId)
    }

    @RequestMapping("/proceed-payment-2/{txId}")
    fun doSomething2(@PathVariable txId: String): Any {

        return lockService.doPaymentProcess(txId)
    }

}