• 현재 진행 중인 배달 서비스 프로젝트는 라이더가 주문을 수락하는 과정에서 동시성 문제가 발생할 가능성이 있습니다.
  • 여러 라이더가 동시에 동일한 주문을 수락할 경우, 하나의 주문이 여러 라이더에게 할당될 수 있는 상황을 방지하기 위해 다양한 동시성 제어 방법에 대해 알아보게 되었습니다.

자바의 synchrozied 키워드

자바는 synchronized 키워드를 사용하여 특정 메소드나 코드 블록에 락을 걸어 여러 스레드가 동시에 접근하지 못하게 합니다. synchronized를 통해 간단하게 문제를 해결할 수 있으나 하나의 프로세스 내에서만 동시성 제어가 가능하다는 한계가 있습니다. Scale-Out을 통해 서버가 2대 이상일 경우 여전히 동시성 문제가 발생하기 때문에 DB 레벨에서의 동시성 제어가 필요합니다.

낙관적 락 vs 비관적 락

낙관적 락

  • 데이터를 읽을 때 별도의 락을 걸지 않고 애플리케이션에서 제공하는 버전 관리 기능을 사용합니다.
    • 주로 version 등의 구분 컬럼을 사용
  • 트랜잭션커밋하는 시점에 데이터 충돌 여부를 확인합니다.
    • 만약 두 개의 트랜잭션이 같은 데이터를 수정하려고 할 때, 버전 번호를 비교하여 충돌이 발생한 경우 해당 트랜잭션은 실패하고 롤백됩니다.
  • 락을 걸지 않기 때문에 동시성 처리에서 성능에 유리할 수 있습니다.
    • 업데이트 과정에서만 락을 점유하여 락 점유 시간을 최소화함으로써 동시성을 보장합니다.
  • 하지만 수정에 대한 동시 요청이 많을 경우, 일치하지 않는 버전으로 인해 데이터 연산을 정상적으로 반영할 수 없습니다.

비관적 락

  • 데이터를 읽는 시점에서 을 획득하고, 연산이 끝날 때까지 을 유지합니다.
    • 트랜잭션이 시작될 때 X-Lock 혹은 S-Lock을 설정하여 다른 트랜잭션이 동일한 데이터를 수정하거나 읽지 못하도록 합니다.
  • 데이터 수정 과정에서 즉시 트랜잭션 충돌을 알 수 있습니다.
    • 락을 획득하지 못하면 다른 트랜잭션은 대기 상태에 들어가거나, 충돌이 발생해 실패하게 됩니다.
  • 안전성은 높지만 성능 저하 가능성이 있습니다.
    • 충돌을 방지할 수 있지만 여러 트랜잭션이 대기 상태로 들어가거나 교착 상태 문제가 발생할 수 있습니다.

분산 락

  • 서버가 여러 대인 경우 동일한 데이터에 대해 동기화를 보장하기 위해 사용합니다.
  • 서버들 간 동기화 처리가 필요하고, 공통된 락을 적용해야 하기 때문에 redis를 이용해 분산락을 사용합니다.
  • 공통된 데이터 저장소(DB)를 이용해 자원이 사용중인지 확인하기 때문에 전체 서버의 동기화된 처리를 보장합니다.

Redis 기반 분산락 사용하기

Redis는 단일 쓰레드로 동작하기 때문에, 락을 구현하는 데 매우 적합합니다. 이유는 단일 쓰레드이기 때문에 락 설정이나 해제 작업에 원자성을 보장하여 경합 상태를 고려하지 않아도 되기 때문입니다. Redis를 활용한 분산 락은 주로 SETNX 명령어를 사용하고 Lettuce와 Redisson과 같은 Redis 클라이언트를 사용하여 분산 락을 구현할 수 있습니다.

SETNX는 키가 존재하지 않을 때만 값을 설정하는 명령어입니다. 이를 활용하여 락을 구현할 수 있습니다. 예를 들어, 특정 자원에 대한 락을 설정할 때, 해당 자원의 키를 생성하고 값을 설정합니다.

왜냐하면 SETNX 명령어는 키가 존재하지 않을 때만 값을 설정하기 때문에, 여러 클라이언트가 동시에 락을 설정하려고 해도 하나의 클라이언트만 성공할 수 있기 때문입니다. 락을 해제할 때는 DEL 명령어를 사용하여 키를 삭제합니다. 이를 통해 다른 클라이언트가 자원에 접근할 수 있게 됩니다.

  • Lettuce
    • 기본적으로 스핀락을 사용하여 분산 락을 구현합니다.
    • 스핀락 방식은 락을 획득하기 위해 계속해서 시도하는 방식으로, 락을 기다리는 동안 CPU 자원을 사용하게 됩니다.
void doProcess() {
    String lockKey = "lock";

    try {
        while (!tryLock(lockKey)) { // (2)
            try {
                Thread.sleep(50);
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
        }
        
        // (3) do process
    } finally {
        unlock(lockKey); // (4)
    }
}

boolean tryLock(String key) {
    return command.setnx(key, "1"); // (1)
}

void unlock(String key) {
    command.del(key);
}

(1) tryLock() 메소드는 Redis의 SETNX 명령어를 사용하여 락을 시도합니다. SETNX는 ‘SET if Not Exists’의 약자로, Redis에서 키가 존재하지 않을 경우에만 값을 설정합니다. 만약 키가 이미 존재하면 락을 획득하지 못한 것으로 간주하여 실패를 반환합니다.
(2) tryLock()이 false를 반환하면, 즉 락을 획득하지 못하면, Thread.sleep(50)을 통해 일정 시간 대기합니다. 이후 다시 tryLock()을 호출하여 락을 획득할 수 있을 때까지 반복합니다. 이 과정은 ‘스핀락’이라고 불립니다.
(3) 락을 성공적으로 획득한 후에는 프로세스를 수행합니다.
(4) 프로세스가 끝난 후에는 unlock() 메소드를 호출하여 Redis에서 락을 해제합니다. unlock() 메소드는 Redis에서 키를 삭제하여 락을 해제합니다.

하지만 해당 방식엔 다음과 같은 문제점이 있습니다.

tryLock()이 false를 반환하면, 즉 락을 획득하지 못한 경우 무한루프를 돌게 됩니다. 그렇기 때문에 일정 시간이 지나면 중지되도록 해줘야 하는데 SETNX 명령어에 expire time을 설정할 수 없습니다. 또한 지속적으르 락의 확득을 시도하기 때문에 많은 부하가 발생할 수 있습니다.

  • Redission
    • 스핀락 방식이 아닌 pub/sub 기능을 이용하여 락을 관리합니다.
    • Pub/Sub(Publish/Subscribe) 기반의 이벤트 방식을 사용하여 락을 관리하므로 Lettuce와 달리 지속적인 lock을 얻기 위한 시도를 하지 않아도 됩니다.

Redisson은 tryLock 메소드에 타임아웃을 명시하도록 되어있습니다.

img

  • waitTime : 락을 얻기 위한 대기 시간
  • leaseTime : 락이 만료되어 사라지는 시간 지정
  • TimeUnit : 시간 단위

첫 번째 파라미터만큼의 시간이 지나면 false가 반환되며 락 획득에 실패했다고 알려줍니다. 그리고 두 번째 파라미터만큼의 시간이 지나면 락이 만료되어 사라지기 때문에 어플리케이션에서 락을 해제해주지 않더라도 다른 쓰레드 혹은 애플리케이션에서 락을 획득할 수 있습니다.

이로 인해 락이 해제되지 않는 문제로 무한 루프에 빠질 위험이 사라졌기 때문에 스핀락 방식의 문제점을 해결할 수 있습니다.

Redisson의 프로세스는 다음과 같습니다.

(1) Redisson의 tryLock() 메소드는 대기 없이 락을 시도합니다. 락이 즉시 사용 가능한 상태라면, 이 메소드는 성공적으로 락을 획득하고 true를 반환합니다. 경합이 없을 때, 즉 락이 현재 사용 중이지 않다면, 별도의 오버헤드 없이 빠르게 락을 획득할 수 있습니다.
(2) pub/sub을 이용하여 메세지가 올 때까지 대기하다가 락이 해제되었다는 메세지가 오면 대기를 풀고 다시 락 획득을 시도합니다. 락 획득에 실패하면 다시 락 해제 메세지를 기다립니다. 이 프로세스를 타임아웃시까지 반복합니다.
(3) 락 해제 메시지를 수신하면, Redisson은 락 획득을 다시 시도합니다. 이 프로세스는 락 획득에 성공할 때까지 계속됩니다. 만약 락이 여전히 사용 중이라면, 메시지 수신 대기를 계속하며 재시도합니다.
(4) 주어진 타임아웃 시간 내에 락을 획득하지 못하면, 최종적으로 false를 반환하고 락 획득에 실패했음을 알립니다. 대기가 풀릴 때 타임아웃 여부를 체크하므로, 타임아웃이 발생하는 순간은 파라미터로 넘긴 타임아웃 시간과 약간의 차이가 있을 수 있습니다.

정리

여러 서버가 하나의 DB를 공유하고 있는 경우, 낙관적 락이나 비관적 락을 통해 동시성 문제를 효과적으로 해결할 수 있습니다. 낙관적 락은 데이터 수정 시 충돌 여부를 커밋 시점에서 확인하며, 비관적 락은 트랜잭션이 진행되는 동안 락을 유지하여 데이터 충돌을 방지합니다.

분산 DB 환경에서는 낙관적 락이나 비관적 락만으로는 동시성 문제를 완전히 해결하기 어렵습니다. 이 경우에는 분산 락을 사용하는 것이 필요합니다. 분산 락Redis와 같은 분산 저장소를 활용하여 서버 간 동기화를 보장하고, 전체 서버의 동기화된 처리를 구현할 수 있습니다.

  • Lettuce스핀락을 사용하여 락을 획득하며, 무한루프와 높은 CPU 부하가 발생할 수 있습니다.
  • Redissonpub/sub 기능을 이용하여 락을 관리하며, 타임아웃을 설정할 수 있어 무한 루프 문제를 해결합니다.


참고

https://hyperconnect.github.io/2019/11/15/redis-distributed-lock-1.html

https://helloworld.kurly.com/blog/distributed-redisson-lock/#3-%EB%B6%84%EC%82%B0%EB%9D%BD%EC%9D%84-%EB%B3%B4%EB%8B%A4-%EC%86%90%EC%89%BD%EA%B2%8C-%EC%82%AC%EC%9A%A9%ED%95%A0-%EC%88%98%EB%8A%94-%EC%97%86%EC%9D%84%EA%B9%8C

업데이트: