티스토리 뷰
분산락을 고려해야하는 상황은 무엇일까?
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 을 할당받지 못했을 때 어떻게 할 것인지에 대한 것이다. 가장 쉽게 생각해볼 수 있는 상황은 아래 두 가지 인 것 같다.
- 예외를 발생시킨다.
- 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 를 이용해 해결하는 방법도 경험해보고 정리하고 싶다.