티스토리 뷰

분산락을 고려해야하는 상황은 무엇일까?


Scale-out 을 통해 서버를 여러대로 확장한 상황에서 하나의 데이터에 대한 동시성 문제를 해결하기 위해 사용한다.

내가 동시성 문제를 겪은 상황은 API 서버를 여러 대로 Scale-out 한 환경에서 1개의 DB 서버를 공유하는 상황이었다. 부하분산을 위해 Scale-out 이 기본이되는 요즘 환경에서는 반드시 고려해야하는 부분이라고 생각한다.

 

프로젝트를 진행하면서 어떤 동시성 이슈를 만났고, 이를 해결하기 위해 어떻게 분산락을 사용했는지 살펴보려고 한다.

 

동시성 이슈를 경험하다!


위 테이블은 가상자산의 메타데이터 정보를저장하기 위한 테이블이다.

"업비트" 와 같은 거래소에 신규 상장코인이 등장하면 그걸 식별해 로직상에서 currency 에 새로운 데이터를 Insert 해줘야 하는 요구사항이 있었다.

 

fun findOrCreate(symbol: String): Currency {
    return currencyRepository.findBySymbol(symbol)
        ?: currencyRepository.save(Currency(symbol = symbol))
}

 

사용자가 "자신이 보유한 가상자산을 조회"하는 과정에서 currency 테이블에 그 사용자가 보유한 자산에 대한 메타데이터가 없다면 새로운 Row 를 추가하는 방향을 선택했다. 내가 예상한 시나리오는 첫 번째 사용자가 없는 가상자산에 대한 currency 를 Insert 하고 이후 사용자들은 생성된 currency Row 를 Select 하는 것이었다.

 

아래는 동시성 문제가 발생하지 않을 때 정상적으로 동작한 결과다.

 

예상한대로 DB 에 1개의 Row 만 Insert 되어있는 것을 확인할 수 있다.

 

하지만 여러 사용자가 동시에 보유 가상자산 조회를 실행하게되면 동시에 Insert 가 발생되고 currency 에 동일한 가상자산이 여러 Row 들어가면서 데이터의 무결성이 깨지게 되는 문제가 발생했다.

 

테스트에 사용한 코드는 아래와 같다.

 

사실 이 코드는 다중 인스턴스 환경에서의 동시성을 테스트하진 못하고 싱글 인스턴스 멀티 스레드 환경에서의 동시성을 테스트해주는 코드다. 현재 코드의 문제점을 파악하기에 충분할 것으로 생각되어 해당 테스트 코드를 사용했다.

@Test
fun multi_thread_concurrent_test() {
    repeat(THREAD_CNT) {
        executor.execute {
            cyclicBarrier.await()
            currencyService.findOrCreate("ch4njun")
        }
    }
    executor.shutdown()
    executor.awaitTermination(5, TimeUnit.SECONDS)
}

 

그 결과 위에서 말했다시피 동일한 Row 가 많이 Insert 되 데이터의 무결성이 깨진 것을 확인할 수 있다.

(총 100개의 Thread 에 의해서 100개의 Row 가 Insert 됐는데 길이상 생략했다)

 

아찔하다..

 

어떻게 해결할 수 있을까?


멀티 Thread 환경에서 발생하는 동시성(동기화) 문제는 JAVA 에서 제공하는 synchronized 키워드를 사용하는 방식을 통해 Lock 을 잡아 해결할 수 있지만, 멀티 인스턴스 환경에서 발생하는 동시성 문제는 이러한 방식으로 해결할 수 없다.

 

다중 인스턴스 환경에서는 외부에 무언가(MySQL 의 네임드 락, Redis, Zookeeper 등) 를 통해서 Lock 을 잡아서 동시성 이슈를 해결할 수 있다.

 

이번 포스팅에서는 그 중에서도 가장 간단하게 구현할 수 있는 Redis 를 이용해보려고 한다.

 

Redis 는 기본적으로 싱글 스레드로 동작하기 때문에 동시에 요청을 보내는 인스턴스가 여러개인 상황에서도 안전하게 Lock 을 관리할 수 있다는 장점이 있다.

추가로 Lock 기능에 사용되는 모든 커맨드는 Atomic 하게 동작해야한다. 이러한 부분에서도 싱글 스레드 방식으로 동작하는 Redis 는 비교적 구현이 쉽다고 생각한다.

 

방법1. Redis 를 이용해 Lock 을 직접 구현해보자!

Lock 을 위한 Redis Repository 를 먼저 구현해보자.

@Repository
class LockRedisRepository(
    private val redisTemplate: RedisTemplate<String, String>
) {

    fun lockIfAbsent(key: String, deadLine: Duration): Boolean {
        val value = "${LocalDateTime.now()}|${InetAddress.getLocalHost().hostName}"
        return redisTemplate.opsForValue().setIfAbsent(key, value, deadLine)!!
    }

    fun releaseLock(key: String): Boolean = redisTemplate.delete(key)

    fun get(key: String): String? = redisTemplate.opsForValue().get(key)
    
}

그리고 다음은 서비스 코드를 작성해보자.

@Service
class LockRedisService(
    private val lockRedisRepository: LockRedisRepository
) {

    fun <T> withDeadLineLock(key: String, deadLine: Duration = Duration.ofSeconds(10), func: () -> T): T? {
        return when(this.acquireLock(key, deadLine)) {
            true -> this.call(func, key)
            false -> null
        }
    }

    private fun acquireLock(key: String, deadLine: Duration): Boolean {
        return lockRedisRepository.lockIfAbsent(key, deadLine)
    }

    private fun <T> call(func: () -> T, key: String): T {
        return func().also {
            lockRedisRepository.releaseLock(key)
        }
    }

}

 

이렇게 작성한 후 호출하는 쪽에서 LockRedisService 를 어떻게 사용하는지 예제코드를 살펴보자.

fun findOrCreate(symbol: String): Currency {
    return currencyRepository.findBySymbol(symbol) ?: this.create(symbol)
}

fun create(symbol: String): Currency {
    val key = "lock:currency:create:$symbol"
    return lockRedisService.withDeadLineLock(key) {
        currencyRepository.save(Currency(symbol = symbol))
    } ?: throw RuntimeException("Lock Exception!!")
}

 

코드자체가 어렵진 않아서 한줄한줄 설명은 하지 않을 예정이다.

 

한 가지 눈여겨봐야 할 포인트는 Lock 을 할당받지 못했을 때 어떻게 할 것인지에 대한 것이다. 가장 쉽게 생각해볼 수 있는 상황은 아래 두 가지 인 것 같다.

 

  1. 예외를 발생시킨다.
  2. Lock 을 획득할 때 까지 대기한다. → Spin Lock & Pub/Sub 을 이용한 구독 후 대기

상황에 따라 다르겠지만 나는 "자주 발생하는 상황이 아니고, 사용자가 바로 재시도 했을 때 정상동작 할 것" 이라고 생각했고 이게 사용자성에 큰 영향을 미치지 않으리라 판단해 예외를 발생시키는 방식으로 작성했다.

 

결과는 이렇게 Lock 을 획득하지 못했다는 에러가 잔뜩 발생하며 1개의 Row 만 Insert 된 것을 확인할 수 있다.

Redis 는 싱글스레드로 동작하기 때문에 멀티 인스턴스에서도 동일한 방법으로 동시성 이슈가 해결될 것라고 생각한다.

 

하지만 이후 이 부분이 이슈가된다면 언제든지 2번으로 전환할 것이다.

그런의미에서 Redisson 라는 Redis Client 라이브러리를 이용한 방법도 소개하려고 한다.

 

방법2. Redisson 을 이용해 Lock 획득까지 대기해보자!

Lock 을 획득할 때까지 대기하는 가장 쉬운 방법은 Spin Lock 이다.

0.1 초에 한번씩 재시도하며 Lock 을 획득할 수 있는지 확인하는 것이다. 이 방법의 가장 큰 문제는 계속해서 Thread 를 점유해 리소스를 낭비한다는 점Redis 에 계속 요청을 보내 부하를 준다는 점이다.

 

Redisson 에서는 이러한 문제를 해결하기 위해 Redis 의 Publisher & Subscriber 을 이용해 Lock 을 구현해두었다.

여기서 Redisson 은 Lettuce 와 같은 JAVA Redis Client 라이브러리다.

implementation("org.redisson:redisson-spring-boot-starter:3.19.0")

의존성 주입을 받고,,,

@Service
class CurrencyService(
    private val currencyRepository: CurrencyRepository,
    private val redissonClient: RedissonClient
) {

    fun findOrCreate(symbol: String): Currency {
        val lock = redissonClient.getLock("lock:currency:create:$symbol")
        return try {
            if (!lock.tryLock(10, 1, TimeUnit.SECONDS)) {
                throw RuntimeException("Lock Exception!!")
            }
            currencyRepository.findBySymbol(symbol) ?: currencyRepository.save(Currency(symbol = symbol))
        } finally {
            lock.unlock()
        }
    }
}

그냥 RedissonClient 를 주입받아 사용하면된다..!!

Lock 을 획득하고 반납하는 과정을 AOP 를 이용해 어노테이션을 사용하도록 구성하면 좀 더 깔끔해질 것 같다.

 

직접 Pub&Sub 을 이용해 대기하는 구조를 개발하기 어렵다면 간단하게 Redisson 을 이용해도 괜찮을 것 같다.


여기까지 Redis 의 분산락을 이용해 동시성 문제를 해결하는 두 가지 방법에 대해서 살펴봤다.

나중에 MySQL 의 네임드 락이나 Zookeeper 를 이용해 해결하는 방법도 경험해보고 정리하고 싶다.

 

댓글
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
TAG
more
«   2024/09   »
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
글 보관함