본문 바로가기
개발지식

동시성 이슈를 해결하기 위한 Redisson 분산락 알아보기(1)

by devLog by Ronnie's 2023. 3. 27.

동시성 이슈를 해결하기 위한 방법으로는 여러가지 방법이 있지만 오늘은 그중 Redisson에서 제공하는 분산락을 통해 동시성을 제어하는 방법에 대해서 정리한다. 내용은 1편과 2편으로 나눠 1편에서는 동시성 이슈의 관한 이론과 Redisson의 이론적인 부분을 정리하고 2편에서는 코드를 통해서 동시성 이슈에 대한 테스트를 진행한 내용에 대해서 정리한다.

 

동시성 이슈를 해결하기 위한 Redisson 분산락 알아보기(1)

 

동시성 이슈를 해결하기 위한 Redisson 분산락 알아보기(1)

 

동시성 이슈?


Redisson에 대해서 알아보기 위해 먼저 동시성 이슈가 무엇인지에 대해서 알아본다.

 

동시성 이슈란, 동일한 자원에 대해  둘 이상의 스레드가 동시에 제어할 때 나타나는 문제이다.

 

이해가 쉽도록 다음 코드로 예를 들어본다.

 

다음과 같은 서비스 로직이 있다.

@SpringBootTest
class ConcurrencyServiceTest {

    val log: Logger = LoggerFactory.getLogger(this::class.java)

    @Autowired
    private lateinit var concurrencyService: ConcurrencyService

    @Test
    fun concurrencyTest() {
        log.info("start")
        val threadA = Thread { concurrencyService.getAge(30) }
        val threadB = Thread { concurrencyService.getAge(20) }
        threadA.start()
        sleep(100)
        threadB.start()
        sleep(3000)
        log.info("exit")
    }

    private fun sleep(millis: Int) {
        try {
            Thread.sleep(millis.toLong())
        } catch (e: InterruptedException) {
            e.printStackTrace()
        }
    }
}

 

그리고 다음과 같은 테스트 코드가 있다.

@Service
class ConcurrencyService {

    val log: Logger = LoggerFactory.getLogger(this::class.java)

    private var age: Int? = null

    fun getAge(age: Int) {
        this.age = age
        log.info("저장 age={} -> age={}", age, this.age)
        sleep(1000)
        log.info("조회 age ={}", this.age)
    }

    private fun sleep(millis: Int) {
        try {
            Thread.sleep(millis.toLong())
        } catch (e: InterruptedException) {
            e.printStackTrace()
        }
    }
}

 

 

테스트 코드의 동작을 유추해 본다면 threadA, threadB 두개의 스레드가 생성되고 threadA start() 호출을 통해 쓰레드A가 실행되고 sleep(100)을 통해 잠시 후 쓰레드B가 실행되어 동시성을 고려하지 않았다면 다음과 같이 결과값이 출력될 것이라고 생각할 수 있다.

 

start
저장 age=30 -> age=30
조회 age=30
저장 age=20 -> age=20
조회 age=20
exit


하지만 코드를 실행해보면 아래와 같은 결과를 확인할 수 있다.

 

이렇게 결과가 나오는 이슈는 쓰레드A가 getAge() 함수를 통해 age 값을 30으로 변경 후에 1초 대기 상태로 들어간 후 쓰레드B가 getAge()를 호출하여 age 값을 20으로 변경하게 된다.

 

쓰레드A가 sleep 이후 age 값을 읽게되면 쓰레드B가 변경한 값을 읽어들인다.

 

이러한 동시성 문제는 지역 변수에 대해서는 쓰레드마다 다른 메모리 영역이 할당 받기 때문에 발생하지 않지만 인스턴스 필드 또는 static과 같은 공용 필드에 접근에 대해서 발생한다.

 

또 여기서 중요한 점은 동시성 문제란 동일한 자원에 대해서 접근한다고 무조건 발생하는 것이 아닌 동시에 접근한 자원에 대해서 변경이 일어나는 경우 발생하는 문제이다.

 

즉, 변경하지 않고 읽기만 한다면 발생하지 않는다는 것이다.

 

해당 개념을 확장 시켜 데이터베이스에 저장되어 있는 자원에 대해서 접근할 때도 마찬가지이다.

 

간단한 예시를 통해서 동시성 이슈에 대해서 알아보았다. 다음은 이런 동시성 이슈를 해결할 수 있는 방법 중 하나Redisson에 대해서 알아본다.

 

 

Redisson 분산락 + Lettuce 스핀락


먼저 스프링부트의 스타터 레디스(Spring-Boot-Starter-Redis)에서는 Lettuce 라이브러리를 사용한다.

 

Lettuce에서 Lock을 구현할 때는 스핀락 구조 형태로 락을 구현하게 되는데 스핀락은 계속해서 Lock을 획득하기 위해 순회하므로 만약 Lock을 획득한 쓰레드나 프로세스가 Lock을 정상적으로 해제해주지 못하면 다른 쓰레드에서는 계속 Lock을 획득하기 위해 시도하느라 실행되는 애플리케이션에 장애가 될 수 있다.

 

이러한 상황을 방지하기 위해 락에 만료 시간에 대한 정책이 필요하게 된다. (순회 횟수 제한, 시간으로 제한 등)

 

이뿐만 아니라 Lock을 획득하기 위해 순회하는 동안 계속해서 레디스에 요청을 보내게 되는게 이러한 스레드가 많다면 레디스에 부하도 발생할 수 있다.

 

Redisson에서는 이러한 문제를 해결하기 위해 새로운 방식을 도입했는데 그것이 분산락이다.

 

분삭란을 RLock이라는 클래스를 통해 구현이 가능한데 스핀락에서 순회하며 Lock을 획득하는 방식이 아닌 RLock에서 제공하는 lock() 메서드를 확인해보면 다음과 같이 pub-sub 구조를 통해서 개선했다.

 

그리고 분산락에서도 락에 만료 시간에 대한 정의가 필요한데 tryLock 메서드를 통해 정의한다.

 

tryLock에서는 waitTimeleaseTime 설정을 통해 락에 대한 점유 및 해제 시간을 정의하게 된다.

  • waitTime : 락을 사용할 수 있을 때까지 waitTime 시간 만큼 기다린다.
  • leaseTime : leaseTime 시간 동안 락을 점유하는 시도를 한다.

풀어서 설명하면 선행 락 점유 스레드가 존재한다면 waitTime동안 락 점유를 기다린다. leaseTime 설정을 통해 락을 해제 시켜 다른 스레드도 락을 점유할 수 있도록 한다.

 

이것으로  동시성 이슈의 관한 정의와 Redisson의 분산락과 Lettuce의 스핀락의 이론적인 부분을 정리하였다. 다음 시간에는 Redisson 분산락을 통해서 동시성 이슈를 해결하는 방법에 대해서 정리해보도록 한다.

댓글