• 일전의 공부한 내용을 실제로 구현해보면서 공부해보고자 시작한 프로젝트다.
  • 이전 글
  • 아직 공부 중인 개념입니다. 조금 틀려도 너른 이해 부탁드립니다!
  • 이 글은 개인적인 의견을 다룹니다.

다루는 내용

  • 동시성 이슈에 분산락을 적용하면서

동시성 이슈에 분산락을 적용하면서

목차

1. 동시성 이슈?

2. 그냥 진행하면?

3. SELECT FOR UPDATE? IsolationLevel=Serializable?

4. 단순 Redis 사용

5. Redisson을 사용한 분산락

6. 결과적으로?


1. 동시성 이슈?

  • 예약을 다루는 서비스를 개발하면서 피할 수 없는 것이 바로 동시성 이슈일 것이다.
  • 한정된 리소스에 동시에 접근하는 경우 overbooking이 벌어질 수도 있다. 이는 예상컨데 도메인 특성상 꽤나 큰 문제가 될 것으로 보인다.
    • 정확성: 한정된 슬롯에 overbooking이 벌어지면 안된다.
    • 공정성: 선입선출, 먼저 요청한 사용자의 요청이 먼저 처리되어야 공정하다.
    • 안정성: 순간적으로 몰리는 대량의 요청에도 시스템이 안정적으로 버틸 수 있어야 한다.

2. 그냥 진행하면?

  • 만약 그냥 진행한다면 Race Condition에 놓이며 한정된 리소스에 대한 점유가 중복으로 벌어질 것이 뻔하다.
  • 유사한 고민을 실제 일하는 부분을 한 적이 있다. 중복 출고, 그에 따른 재고가 틀어지는 상황 등 단순 예약이 아니더라도 많은 도메인에서 벌어질 수 있는 일이다.

3. SELECT FOR UPDATE? IsolationLevel=Serializable?

SELECT FOR UPDATE를 사용하면?

1. SELECT FOR UPDATE

  • SELECT FOR UPDATE는 비관적 잠금(Pessimistic Lock) 메커니즘이다.
    • 조회한 row에 XLock(Exclusive Lock)을 설정한다.
    • 트랜잭션 롤백/ 커밋 시까지 다른 트랜잭션의 읽기/ 쓰기 차단
    • WHERE 조건의 인덱스 범위 만큼 잠근다.
    • 인덱스가 없다면 전체를 잠글 수도 있다.
    • 범위 조회시 Gap Lock으로 간격도 잠글 수 있다.
    • 락 대기 시간이 응답 시간에 누적된다.
  • 응답 시간이 느려지며, 이 때문에 Connection Pool 고갈이 심해질 수도 있다.
  • 심각하면 DeadLock이 발생할 수도 있다.

Isolation Level을 Serializable로 높이면?

2. IsolationLevel=Serializable

  • 가장 높은 격리 수준이다.
    • 트랜잭션들이 완전히 순차 실행되는 것처럼 동작한다.
    • DIrty Read, Non-Repeatable Read, Phantom Read 등 방지할 수 있다.
    • 모든 SELECT가 자동으로 SharedLock을 획득
    • 읽는 범위에 GapLock 설정
  • 처리량이 급감하고 대기 시간이 증가한다.
  • 전체 테이블을 잠그며 다른 시간대 처리도 블록된다.
  • 데드락 위험이 높아진다.

4. 단순 Redis 사용

  • Redis의 원자성을 사용한 방법이다.
    • SETNX, setIfAbsent로 처리하여 키가 없는 경우에만 작업을 처리하게 한다.
    • 성능적으로 이득을 볼 수 있으며, Redis가 단일 장애점이 되지 않는 이상 분산 환경에서도 율히하게 작동한다.
  • 단, 요청 순서에 대한 보장이 어렵다. 이 부분은 도메인적으로 굉장히 치명적이다.

5. Redisson을 사용한 분산락

  • Redisson은 단순히 RedisClient를 넘어 고수준 분산 객체 등의 유틸리티를 제공한다.
    • Redis를 유틸리티라는 의도에 맞게 락, 컬렉션, 동기화 도구로 편리하게 사용할 수 있게 해준다.
    • 고수준 분산 객체 : RLock, RReadWriteLock, RSemaphore, RateLimiter 등이 있다.
    • Jedis,Lettuce와 다른 시스템 유틸리티를 포함한 라이브러리다.
  • 위의 여러 가지 한계를 감안하여 Redisson을 이용하여 해결하기로 결정했다.
@RateLimiter(  
    key = RATE_LIMITER_SP_EL_KEY,  
    type = RateLimitType.WHOLE,  
    rate = RATE_LIMITER_CAPACITY,  
    maximumWaitTime = RATE_LIMIT_MAXIMUM_WAIT_TIME,  
    rateIntervalTime = RATE_LIMITER_RATE_INTERVAL,  
    bucketLiveTime = RATE_LIMITER_DURATION,  
)  
@DistributedLock(  
    key = DISTRIBUTED_LOCK_SP_EL_KEY,  
    lockType = LockType.FAIR_LOCK,  
    waitTime = FAIR_LOCK_MAXIMUM_WAIT_TIME,  
    waitTimeUnit = TimeUnit.MINUTES,  
)  
@Transactional  
override fun execute(command: CreateTimeTableOccupancyCommand): Boolean {  
    val key = key(command.restaurantId, command.date, command.startTime)  
  
    try {  
        val list = loadBookableTimeTables(command)  
        acquireSemaphore(key, list.size)  
        val domainEvent = saveOccupancy(command.userId, list)  
  
        return saveToOutBoxAndPublish(domainEvent)  
    } catch (e: ClientException) {  
        when (e) {  
            is AllTheThingsAreAlreadyOccupiedException -> throw e  
            is AllTheSeatsAreAlreadyOccupiedException -> throw e  
            else -> {  
                releaseSemaphore(key)  
                throw e  
            }  
        }  
    } catch (e: DataIntegrityViolationException) {  
        releaseSemaphore(key)  
        throw e  
    }  
}
  1. 분산락을 Annotation을 기반으로 적용하며 Transactional 보다 앞서 실행할 수 있게 구성한다. 내부적으로 FairLock을 구현하여 진입 자체를 공정하게 진행할 수 있게 한다.
  2. 내부적으로 예약 가능한 수량을 확인하고 Semaphore로 한정된 수만 진입할 수 있게 하여 예약 프로세스를 진행한다. 혹여 Exception이 발생하면 Semaphore를 반환하고 다음 사용자가 점유할 수 있게 한다.
  3. RateLimiter를 Annotation을 기반으로 적용하며, 절대적인 방어책은 아니지만 (물론 인프라 레벨에서 일차적으로 처리해야 한다.) traffic spike로부터 시스템을 보호할 수 있도록 장치를 마련한다.
flowchart LR
    RL[RateLimiter<br/>permits/sec]
    FL[FairLock<br/>FIFO]
    SM[Semaphore<br/>N permits]

    RL --> FL --> SM

6. 결과적으로?

  1. 하나의 솔루션으로 완벽하게 모든 문제를 해결할 수 없다. 여러 가지 적절한 메커니즘을 통해서 문제를 점진적으로 해결해야 한다
  2. 분명 분산락 처리를 진행하면서 성능적 손실이 있었다. 다만, 성능과 목표하는 바 간의 trade-off를 잘 계산해서 최적의 접근법에 도달해야 한다.