동시성 이슈를 해결하는 다양한 방법

yellowsunn·2023년 5월 3일
4

트래픽이 몰려 짧은 시간에 요청이 거의 동시에 발생하거나 서버가 불안정해져 요청을 처리하는데 시간이 오래 걸리는 경우 동시성 이슈로 데이터 정합성에 문제가 생길 수 있다.

문제 상황 예시


위와 같이 상품의 재고를 조회하고 하나의 재고를 감소시키는 로직이 있다고 가정해보자. 이 때 2개의 요청이 거의 비슷한 시간에 도달하여 재고를 조회하고 감소시켰다. 여기서 발생한 문제점은 재고를 감소하는 작업을 완료하기 전에 다른 요청도 재고를 조회할 수 있기 때문에 재고가 하나 있음 에도 두요청이 처리되어 재고가 0이되는 의도하지 않은 결과가 생기게 된다.

그렇기 때문에 공유자원에 안정적인 접근과 제어를 위해서는 특정 코드 블럭에서 한번의 하나의 요청만을 처리할 수 있도록 제한하는 락(Lock)이 필요하다.

1. 프로세스에서 Lock 제어하기

다양한 프로그래밍언어에서는 보통 프로세스에 여러 스레드가 접근하는 것을 방지하기위한 thread-safe한 기능들을 제공한다. Java에서는 메서드에 synchronized 키워드를 추가하거나 코드 내부에synchronized 블럭을 추가하여 동시성을 제어할 수 있다.

코드 예시:

// Kotlin에서 메서드에 동시성을 제어하려면 @Synchronized 어노테이션을 추가하면 된다
@Synchronized
fun decrease(id: Long, quantity: Long) {
    val stock: Stock = stockRepository.findByIdOrNull(id) 
        ?: throw IllegalArgumentException("재고 정보를 찾을 수 없습니다.")
    stock.decrease(quantity);
}

단점:

애플리케이션 서버가 1개로 구성된 것이 아닌 다중 서버 환경에서는 제대로 작동하지 않는다. 위와 같은 방식은 프로세스 내에서만 동시성을 제어할 수 있기 때문에 프로세스가 여러개로 구성된 경우 동일하게 동시성 이슈가 발생할 수 있다.

2. DB에서 Lock 제어하기

Application Level이 아닌 데이터베이스에서 직접 Lock을 제어하는 방식이 있다.

2.1. 비관적락 (Pessimisitc Lock)

자원 요청시 동시성 이슈가 자주 발생할 것이라고 비관적으로 예상하여 락을 걸어버리는 방법이다. 한 트랜잭션이 데이터에 접근하고 있으면 다른 트랜잭션은 조회나 쓰기를 금지한다. 많은 RBDMS(MySQL, Oracle, PostgreSQL 등)에서는 SELECT ~ FOR UPDATE 와 같은 SQL문장으로 update가 완료되어 커밋할때까지 다른 트랜잭션에서 row에 접근을 할 수 없는 기능을 제공한다.

코드 예시:

interface StockRepository : JpaRepository<Stock, Long> {
    @Lock(LockModeType.PESSIMISTIC_WRITE)
    fun findStockById(id: Long): Stock?
}

위의 예시처럼 JPA를 사용한다면 @Lock(LockModeType.PESSIMISTIC_WRITE)으로 비관적락을 지정할 수 있다.

  • LockModeType.PESSIMISTIC_WRITE: SELECT ~ FOR UPDATE 쿼리가 실행 (exclusive lock으로 읽기, 쓰기 모두 잠금)
  • LockModeType.PESSIMISTIC_READ: SELECT ~ FOR SHARE 쿼리가 실행 (shared lock으로 쓰기 잠금)

단점:

여러 테이블이 조인되어 있는 경우 데드락이 발생할 위험이 있다.
또, 잦은 락으로 처리량(throughput)이 감소하므로 동시성 이슈가 거의 발생하지 않는다면 성능상 손해다.

2.2 낙관적락 (Optimistic Lock)

낙관적락은 실제 락을 이용하는 방식이 아닌 버전과 같은 칼럼을 추가하여 데이터의 정합성을 맞추는 방법이다. 동시성 이슈가 자주 발생하지 않을 것으로 낙관적으로 판단하여 모든 요청을 락 없이 처리하고 데이터 정합성 이슈가 발견되면 그때서야 롤백을 수행해 정합성을 맞춘다.

동시성 이슈가 발생했을 때 기존의 버전 값을 한 트랜잭션에서 이미 업데이트한 경우, 다른 트랜잭션에서는 해당 버전값을 조회할 수 없어 업데이트가 발생하지 않는다. 업데이트가 발생하지 않으면 문제가 있는 것으로 판단해 롤백 처리를 하여 데이터의 정합성을 보장할 수 있다.

단점:

동시성 이슈가 자주발생하면 빈번한 롤백이 발생할 수 있다.

3. Redis를 활용해 Lock 제어하기

레디스를 활용하면 애플리케이션이 여러개인 다중 서버 환경에서도 효과적으로 락을 사용할 수 있다.

3.1. SETNX 명령을 활용한 스핀락

레디스의 SETNX는 "SET if Not eXists" 명령으로 키가 존재하지 않을 때 값을 세팅하는 방법이다. 이를 통해 특정 키를 락으로 설정하고, 락이 이미 사용중이면 주기적으로 락을 획득하기 위해 요청하는 스핀락을 구현할 수 있다.

위의 예시에서 SET stock-id "lock" NX EX 3 는 stock-id 키가 존재하지 않으면 3초 후에 만료하는 “lock” 값을 세팅한다.

애플리케이션은 stock-id 키를 획득할 때 까지 계속 요청을 시도하고, 사용이 완료되면 키를 삭제한다.

(SETNX 명령은 deprecated 되었으므로 SET 명령과 NX 옵션을 사용하는 것이 권장된다.)

코드 예시:

@Component
class RedisLockRepository(
    private val redisTemplate: RedisTemplate<String, String>,
) {
    fun lock(key: Long): Boolean {
        val isSuccess: Boolean? = redisTemplate.opsForValue()
            .setIfAbsent(key.toString(), "lock", Duration.ofSeconds(3L))

        return isSuccess ?: false
    }

    fun unlock(key: Long): Boolean {
        return redisTemplate.delete(key.toString())
    }
}

@Service
class RedisLockStockService(
    private val redisLockRepository: RedisLockRepository,
) {
    fun decrease(id: Long, quantity: Long) {
        while (redisLockRepository.lock(id).not()) {
            TimeUnit.MILLISECONDS.sleep(100L) // 100 milliseconds 동안 sleep 하며 주기적으로 요청
        }

        try {
            ...
            // 재고 감소 로직 수행
        } finally {
            redisLockRepository.unlock(id)
        }
    }
}

단점:

구현은 단순하지만, 락을 획득할 때까지 계속 요청을 시도하기 때문에 레디스 서버에 부하를 주는 방식이다.

3.2. Redisson을 사용한 분산락

Redisson은 레디스에서 분산락을 효율적으로 처리할 수 있도록 도와주는 오픈소스이다. pub/sub과 Lua 스크립트를 활용해 효과적으로 분산락을 처리하는 기능을 제공하고 있다.

pub/sub 기능

Redisson 은 레디스의 pub/sub 기능을 활용하여 락을 획득할 때까지 subscribe로 채널의 메시지를 기다린다. 이후 unlock이 발생하여 채널의 메시지가 publish 되면 락 획득을 시도한다.

Lua 스크립트를 사용한 원자적인 명령 실행

레디스는 Lua 스크립트를 사용해 명령어 집합을 원자적으로 실행할 수 있는데, Redisson에서도 이를 활용해 lock, unlock 명령 집합을 원자적으로 실행한다.

위의 예시는 Redisson의 unlock 과정의 한 코드이다.
1. 특정 해시키를 가진 락을 조회하고 1을 감소한다.
2. counter 결과가 0이면 키를 삭제한다.
3. publish 명령으로 채널의 메시지를 전송한다.

위와 같은 기능으로 unlock이 발생하면 다른 스레드에서 subscribe로 메시지가 수신되어 락 획득을 시도할 수 있다.

코드 예시:

@Service
class RedissonLockStockFacade(
    private val stockService: StockService,
    private val redissonClient: RedissonClient,
) {
    private val log: Logger = LoggerFactory.getLogger(this::class.java)

    fun decrease(id: Long, quantity: Long) {
        val lock: RLock = redissonClient.getLock(id.toString())

        try {
            val available: Boolean = lock.tryLock(5L, 1L, TimeUnit.SECONDS)
            if (available.not()) {
                log.error("lock 획득 실패")
                return
            }
            stockService.decrease(id, quantity)
        } finally {
            lock.unlock()
        }
    }
}

소스코드

https://github.com/yellowsunn/learning-projects/tree/main/stock-concurrency

참고자료
https://jinhokwon.github.io/mysql/mysql-select-for-update/
https://www.cockroachlabs.com/blog/select-for-update/
https://redis.io/docs/manual/pubsub/
https://www.inflearn.com/course/%EB%8F%99%EC%8B%9C%EC%84%B1%EC%9D%B4%EC%8A%88-%EC%9E%AC%EA%B3%A0%EC%8B%9C%EC%8A%A4%ED%85%9C

0개의 댓글