본문 바로가기
개발/시스템 설계

간단한 distributed-lock 구현하기

by 상용최 2022. 4. 10.
반응형

개발을 하다보면 데이터 정합성이 굉장히 중요한경우들이 발생한다.

간단한예로는 1인당 구매수량 제한이 걸려있는 상황이 있을 수 있다.

1인당 상품 1개밖에 구매하지 못한다고 했을때 2개이상을 구매하게 되면 절대 안된다.

우리는 아래와 같은 코드를 작성할 수 있을 것이다.

fun order(userId: Long, productId: Long) {
    val alreadyBuy = orderRepository.findByUserId(userId, productId)
    if(alreadyBuy) {
        throw AlreadyBuyException("$userId is already buy")
    }

    orderRepository.save(userId, productId)
}

언뜻 보기에는 위코드에는 전혀 문제점이 없어보인다.

위 코드에 어떤 문제점이 있는지 생각해보자

10:00 에 request-1 구매내역을 조회했다.
10:01 에 request-2 구매내역을 조회했다.
10:02 에 request-1 구매를 생성한다.
10:02 에 reuqest-2 구매를 생성한다.

 

위와 같은 상황이 발생한다면 1인당 여러개의 상품을 구매할 수 있게된다.

이럴때 어떻게하면 1인당 1개만 구매할 수 있도록 할 수 있을까 ?

가장 간단하게 생각할 수 있는것은 synchronized 일것이다.

val lock = Any()

fun order(userId: Long, productId: Long) {
    synchronized(lock) {
        val alreadyBuy = orderRepository.findByUserId(userId, productId)
        if(alreadyBuy) {
            throw AlreadyBuyException("$userId is already buy")
        }

        orderRepository.save(userId, productId)
    }
}

이 방법은 서버가 1대일때는 별다른 문제가 발생하지 않을 것이다.

하지만 서버가 2대 이상되어도 문제가 발생하지 않을까 ?

여러서버가 있다면 synchronized 가 없을때의 문제점이 재현될 것이다.

그렇다면 이럴땐 어떻게 해결할 수 있을까 ?

필자는 redis 로 lock 을 관리하는 방법을 통해 문제를 해결하고자한다.

redis 는 싱글스레드이기때문에 여러서버에서 동시에 접근하여도 순차적인 처리를 보장해준다.

즉, 동시성을 보장해준다.

또한 빠른 성능이 이미 검증되어 있기때문에 서비스에 문제가 없을거라고 생각한다.

필자는 아래와 같은 프로세스로 동시성을 보장하고자 했다.

  1. 진입하면서 lock 얻기 시도
    1. 얻기실패시 throw
  2. 주문내역이 있는지 조회
    1. 이미 존재한다면 throw
  3. 주문저장
  4. lock 해제

Redis 의 setnx 명령어를 이용하여 lock 을 관리한다. (https://redis.io/commands/setnx/)

setnx 는 set if not exists 의 약어로 key 가 없을때만 set 을 하는 특징을 가지고 있는 명령어이다.

값이 set 됐다면 1을 set 되지 않았다면 0 을 반환하는 특성을 사용하여 lock 획득 여부를 결정한다.

fun lock(key: String): Boolean {
    return orderRedisTemplate
        .opsForValue()
        .setIfAbsent(key, true) ?: false
}

전체적인 흐름을 코드로 본다면 아래와같이 작성될 수 있다.

fun order(
    id: String,
    userId: Long,
) {
    val availableLock = orderLockRedisRepository.lock(id)

    if (!availableLock) {
        throw IllegalArgumentException("$id already in use")
    }

    val alreadyBuy = orderRepository.existsBuyHistory(userId, id)

    if (alreadyBuy) {
        throw IllegalArgumentException("$userId is already buy")
    }

    orderRepository.order(id, userId)

    orderLockRedisRepository.unlock(id)
}

 

이 예제에서는 굉장히 많은 부분을 축약했다.

첫째로 주문을 할때 상품의 재고를 전혀 고려하지 않았다.

상품의 재고가 1개 남았을 때, 유저 2명이 동시에 주문한다면 재고가 -1이 되는 현상이 발생될 수 있다.

이런경우에는 상품의 재고에도 관리를 해주어야하며, 이럴때는 lock 획득 실패시 실패처리를 하는것이 아니라 잡혀있는 lock 이 끝나면 lock 획득시도를 다시 하는 로직이 추가되어야 한다.

 

둘째로 redis 의 클러스가 1대만 있다고 가정했습니다.

이때는 redis 가 single points of failure 가 될 수 있습니다.

이런 문제점을 어떻게 처리할지 고려가 되어야합니다.

이글을 읽는 여러분께서는 위의 상황에서는 어떤식으로 처리를 할지 생각을 해보신다면 좋겠습니다.

 

반응형

댓글