시뻘건 개발 도전기

part2. 동시성 이슈의 해결 방법 (2+1)가지 본문

Framework/spring

part2. 동시성 이슈의 해결 방법 (2+1)가지

시뻘건볼때기 2023. 9. 6. 10:12
반응형

이전 포스팅에서 우리는 대표적인 낙관적 락과 비관적 락에대해서 간략하게 살펴보았다.

사실 나는 낙관적 락도 사용하지 않았고 비관적 락도 사용하지 않았다.

DB의 부하를 줄일 수 있는 방법을 몰색하다가 redis를 사용한 [분산 락]이라고 하는 녀석을 파보았다.

"왜 분산 락을 사용해야만 했는가?"에 대해서는 마지막에 언급하겠다.


분산 락 (Distributed Lock)

동시성 이슈(경쟁)로 동일한 리소스에 대해서 접근이 이루어질 때 리소스의 결함이 생기지 않게하기 위해서 분산된 서버들을 하여금 원자성을 보장한다. 분산서버로 쉽고 간편한 redis를 사용하였다.

 

redis client

그렇다면 redis client로 무엇을 사용해야할까. 결론을 먼저 이야기 하자면 Redisson을 사용하는 것이 좋다. 나는  이미 내 프로젝트에 Lettuce를 사용하고 있었으나, Redisson으로 싹 갈아 엎었다. 

 

Lettuce vs Redisson

redisson은 자바 언어로 구현된 레디스 분산락 클라이언트다. 레디스 분산락은 서로 다른 프로세스가 서로 베타적인 방식으로 공유 리소스와 함께 작동해야 하는 많은 환경에서 매우 유용한 기본 기능이다.

 

 

Distributed Locks with Redis

A distributed lock pattern with Redis

redis.io

 

[그림 1] 스핀 락 방식
[그림 2] pub/sub 방식


Lettuce는 분산락 기능을 제공하지 않기 때문에 필요할 때 직접 구현해서 사용해야 한다. Lettuce의 락 획득 방식을 살펴보면 락을 획득하지 못한 경우에 락을 획득하기 위해 redis에 계속해서 요청을 보내는 [스핀 락]으로 구성되어 있기 때문에(redis에 요청을 폴링하여 계속 보내는 방식) redis에 부하가 생길 수 있다.

반면에 Redisson는 락 획득 시 [pub/sub] 방식을 사용한다. pub/sub 방식은 락이 해제될 때마다 subscribe 중인 트랜잭션에게 "너 이제 락 획득 해도 돼!"라는 노티를 보내는 방식이다. 즉, 락을 얻지 못한 트랜잭션은 redis에 계속 락 획득 요청을 하는 과정이 사라지게 되고 자연스럽게 redis에 부하를 주지 않을 수 있게 되는 것이다. 이것이 내가 Redisson으로 갈아 엎게된 큰 이유이다.

 

Redisson의 사용

Redisson을 사용하기 위해 아래와 같이 의존성을 추가해주자.

implementation 'org.redisson:redisson-spring-boot-starter:3.23.4'

 

 

나는 락을 획득하여 동시성 이슈가 생기는 기능이 적지 않을 것을 대비하여 spring의 특징인 AOP를 사용하였다. 아래와 같이 분산 락을 사용할 메서드를 대상으로 사용할 어노테이션을 정의한다.

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface DistributedLock {
  String key();
  TimeUnit timeUnit() default TimeUnit.SECONDS; // 락 시간 단위
  long waitTime() default 5L;   // 락 획득을 위해 waitTime(s) 만큼 대기
  long leaseTime() default 5L;  // 락을 획득한 이후 leaseTime(s) 이 지나면 락을 해제
}

 

@Around("@annotation(com.xxx.xxx.DistributedLock)")
  public Object lock(final ProceedingJoinPoint joinPoint) throws Throwable {
    MethodSignature signature = (MethodSignature) joinPoint.getSignature();
    Method method = signature.getMethod();
    DistributedLock distributedLock = method.getAnnotation(DistributedLock.class);

    String key = REDISSON_LOCK_PREFIX + ELParser.getDynamicValue(signature.getParameterNames(), joinPoint.getArgs(), distributedLock.key());
    RLock rLock = redissonClient.getLock(key);	// [1]

    try {
      boolean available = rLock.tryLock(distributedLock.waitTime(), distributedLock.leaseTime(), distributedLock.timeUnit());	// [2]
      if (!available) {
        return false;	// [3]
      }

      return joinPointTransaction.proceed(joinPoint);	// [4]
    } catch (InterruptedException e) {
      log.error("interrupted! - {}", e);
      throw new RuntimeException(e);
    } finally {
      try {
        rLock.unlock();	// [5]
      } catch (IllegalMonitorStateException e) {
        // already unLock: do nothing
      }
    }
  }
}

 

  1. key에 해당하는 값으로 lock을 정의한다.
  2. 락 획득을 시도한다.
  3. 락 획득에 실패했다면 대기한다.
  4. 락 획득에 성공했다면 Logic(joinPoint)를 수행한다.
  5. 락을 푼다.

위 코드를 잘 보면 락 획득에 성공한 경우에만 로직을 수행하게되어있고 실패했을 경우에는 대기하여 노티를 받기를기다리게된다. 우리가 만든 어노테이션의 옵션인 leaseTime와 waitTime을 사용하여 데드락을 방지할 수도 있다.

 

tryRock의 로직

tryRock은 분산 락의 핵심이라고 생각한다. 결국 락을 획득 했냐 실패했냐에 따라 트랜잭션의 액션이 달라지니까. 그렇다면 우리는 tryRock의 로직을 살펴볼 필요가 있다. 내부 코드는 누구나 볼 수 있으니, 포스팅이 길어지는 것을 대비하여 기입하진 않겠다.

 

전체적인 흐름은 아래와 같다.

  1. 락 획득을 시도한다.
    • 획득을 시도하려는 lock의 유지시간을 확인한다. (leaseTime)
    • ttl이 null이면 lock을 획득했다고 본다.
    • exists()를 통해 키 값이 있으면 해당 값을 1 증가시키고 ttl 시간을 설정하며 null을 리턴한다.
    • 만약 키 값이 없다면 setnx()를 한 번 더 실행해서 존재하지 않으면 위 과정을 수행한다
  2. waitTime이 초과되었는지 확인한다.
    • lock 에 대한 점유시간이 아직 남아있다면 다시 lock에 대한 획득을 시도하기 이전에 waitTime이 초과되지는 않았는지 확인한다.
    • 만약 이미 초과되었다면 lock 획득은 실패로 리턴한다.
  3. Thread Id를 채널로 구독하여 lock이 available할때까지 대기한다. (대기 중인 트랜잭션이 노티를 받을 수 있는 핵심)
    • CompleteFuture.get() 메서드를 호출하여 thread id로 구독한 채널로 lock 획득이 유효할때까지 대기한다.
    • waitTime을 초과할 경우 TimeoutException이 발생하여 lock 획득에 실패한다.
    • subscribe 내부에는 세마포어를 사용해서 공유자원에 대한 점유를 수행한다.
    • 세마포어를 사용하여 공유자원을 점유하기 때문에 스핀락 보다는 redis I/O에 대한 부하를 줄일 수 있다.
  4. waitTime 이전까지 무한루프를 수행하면서 lock 점유시간을 확인한다.
  5. thread id로 구독한 객체로 유효시간 또는 남은시간까지 lock이 avaliable한지 구독한다.
  6. lock을 시도할 수 있는 시간이 남아있는지 체크한다.
  7. 유효시간 동안 while문으로 위 과정을 반복한다.

 

세마포어

캐시에서의 경쟁 조건을 해결하는 방법 중에는 뮤텍스(Mutex) 또는 세마포어(Semaphore)와 같은 동기화 기술을 사용할 수 있다. 모든 스레드가 캐시에 접근하기 전에 뮤텍스 또는 세마포어를 사용하여 접근을 잠금 처리(락)한다. 하나의 스레드가 캐시를 읽고 쓸 때, 다른 스레드는 대기하게 된다. 그런데 왜 Redisson은 세마포어를 채택했을까?

When the thread has finished its work, it releases the permit back to the semaphore, and the counter is incremented by 1. If another thread is waiting, this second thread can now acquire the permit that has just been released.

 

Redisson 공식 문서를 보면 알 수 있듯이 세마포어를 "한 번에 하나의 스레드만 리소스에 액세스할 수 있도록 허용하는 동기화 메커니즘"이라고 정의하고있다. redis는 single thread로 동작한다는 사실을 고려해본다면 "공유 리소스에 대해서 thread safe하도록 실행하기 위해 동기화 매커니즘 수행의 용도로 사용되었다."라고 생각된다.

 

 

참고 문헌

반응형

'Framework > spring' 카테고리의 다른 글

part1. 동시성 이슈의 해결 방법 (2+1)가지  (0) 2023.09.05
배치 처리 테스트하기  (0) 2022.09.22
클라우드 네이티브 배치  (0) 2022.09.21
#18 : Lombok  (0) 2020.07.22
#17 : H2 database 연동 준비  (0) 2020.06.13
Comments