동시성 문제를 해결하는 방법들에는 어떤 것이 있을까?
- 현재 진행 중인 배달 서비스 프로젝트는 라이더가 주문을 수락하는 과정에서 동시성 문제가 발생할 수 있습니다.
- 여러 라이더가 동시에 동일한 주문을 수락할 경우, 하나의 주문이 여러 라이더에게 할당될 수 있는 상황을 방지하기 위해 다양한 동시성 제어 방법에 대해 알아보게 되었습니다.
자바의 synchrozied 키워드
자바는 synchronized
키워드를 사용하여 특정 메소드나 코드 블록에 락을 걸어 여러 스레드가 동시에 접근하지 못하게 합니다. synchronized
를 통해 간단하게 문제를 해결할 수 있으나 하나의 프로세스 내에서만 동시성 제어가 가능하다는 한계가 있습니다. Scale-Out을 통해 서버가 2대 이상일 경우 여전히 동시성 문제가 발생하기 때문에 DB 레벨에서의 동시성 제어가 필요합니다.
낙관적 락 vs 비관적 락
낙관적 락
- 데이터를 읽을 때 별도의 락을 걸지 않고 애플리케이션에서 제공하는 버전 관리 기능을 사용합니다.
- 주로 version 등의 구분 컬럼을 사용
- 트랜잭션을 커밋하는 시점에 데이터 충돌 여부를 확인합니다.
- 만약 두 개의 트랜잭션이 같은 데이터를 수정하려고 할 때, 버전 번호를 비교하여 충돌이 발생한 경우 해당 트랜잭션은 실패하고 롤백됩니다.
- 락을 걸지 않기 때문에 락 대기가 없어 동시성 처리에서 성능에 유리할 수 있습니다.
- 하지만 수정에 대한 동시 요청이 많을 경우, 일치하지 않는 버전으로 인해 데이터 연산을 정상적으로 반영할 수 없습니다.
비관적 락
- 데이터를 읽는 시점에서 락을 획득하고, 연산이 끝날 때까지 락을 유지합니다.
- 트랜잭션이 시작될 때 X-Lock 혹은 S-Lock을 설정하여 다른 트랜잭션이 동일한 데이터를 수정하거나 읽지 못하도록 합니다.
- 데이터 수정 과정에서 즉시 트랜잭션 충돌을 알 수 있습니다.
- 락을 획득하지 못하면 다른 트랜잭션은 대기 상태에 들어가거나, 충돌이 발생해 실패하게 됩니다.
- 안전성은 높지만 성능 저하 가능성이 있습니다.
- 충돌을 방지할 수 있지만 여러 트랜잭션이 대기 상태로 들어가거나 교착 상태 문제가 발생할 수 있습니다.
분산 락
- 서버가 여러 대인 경우 동일한 데이터에 대해 동기화를 보장하기 위해 사용합니다.
- 서버들 간 동기화 처리가 필요하고, 공통된 락을 적용해야 하기 때문에 redis를 이용해 분산락을 사용합니다.
- 공통된 데이터 저장소(DB)를 이용해 자원이 사용중인지 확인하기 때문에 전체 서버의 동기화된 처리를 보장합니다.
Redis 기반 분산락 사용하기
Redis는 단일 쓰레드로 동작하기 때문에, 락 설정이나 해제 작업에 원자성을 보장하여 락을 구현하는 데 적합하다고 생각하였습니다. 분산락은 Lettuce
와 Redisson
과 같은 Redis 클라이언트를 사용하여 구현할 수 있습니다.
- Lettuce
- 기본적으로 스핀락을 사용하여 분산 락을 구현합니다.
- 스핀락 방식은 락을 획득하기 위해 계속해서 시도하는 방식으로, 락을 기다리는 동안 CPU 자원을 사용하게 됩니다.
- Lettuce로 분산락을 사용하기 위해서는
setnx
,setex
등을 이용해 직접 분산락을 구현해야 합니다. - 락을 획득하기 위해 개발자가 직접 retry와 timeout 로직을 구현해야 하므로 코드가 상대적으로 더 복잡해질 수 있습니다.
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 메소드에 타임아웃을 명시하도록 되어있습니다.
- waitTime : 락을 얻기 위한 대기 시간
- leaseTime : 락이 만료되어 사라지는 시간 지정
- TimeUnit : 시간 단위
첫 번째 파라미터만큼의 시간이 지나면 false가 반환되며 락 획득에 실패했다고 알려줍니다. 그리고 두 번째 파라미터만큼의 시간이 지나면 락이 만료되어 사라지기 때문에 어플리케이션에서 락을 해제해주지 않더라도 다른 쓰레드 혹은 애플리케이션에서 락을 획득할 수 있습니다.
이로 인해 락이 해제되지 않는 문제로 무한 루프에 빠질 위험이 사라졌기 때문에 스핀락 방식의 문제점을 해결할 수 있습니다.
Redisson의 프로세스는 다음과 같습니다.
(1) Redisson의 tryLock()
메소드는 대기 없이 락을 시도합니다. 락이 즉시 사용 가능한 상태라면, 이 메소드는 성공적으로 락을 획득하고 true를 반환합니다. 경합이 없을 때, 즉 락이 현재 사용 중이지 않다면, 별도의 오버헤드 없이 빠르게 락을 획득할 수 있습니다.
(2) pub/sub을 이용하여 메세지가 올 때까지 대기하다가 락이 해제되었다는 메세지가 오면 대기를 풀고 다시 락 획득을 시도합니다. 락 획득에 실패하면 다시 락 해제 메세지를 기다립니다. 이 프로세스를 타임아웃시까지 반복합니다.
(3) 락 해제 메시지를 수신하면, Redisson은 락 획득을 다시 시도합니다.
이 프로세스는 락 획득에 성공할 때까지 계속됩니다. 만약 락이 여전히 사용 중이라면, 메시지 수신 대기를 계속하며 재시도합니다.
(4) 주어진 타임아웃 시간 내에 락을 획득하지 못하면, 최종적으로 false를 반환하고 락 획득에 실패했음을 알립니다.
대기가 풀릴 때 타임아웃 여부를 체크하므로, 타임아웃이 발생하는 순간은 파라미터로 넘긴 타임아웃 시간과 약간의 차이가 있을 수 있습니다.
정리
여러 서버가 하나의 DB를 공유하고 있는 경우, 낙관적 락이나 비관적 락을 통해 동시성 문제를 효과적으로 해결할 수 있습니다. 낙관적 락은 데이터 수정 시 충돌 여부를 커밋 시점에서 확인하며, 비관적 락은 트랜잭션이 진행되는 동안 락을 유지하여 데이터 충돌을 방지합니다.
분산 DB 환경에서는 낙관적 락이나 비관적 락만으로는 동시성 문제를 완전히 해결하기 어렵습니다. 이 경우에는 분산 락을 사용하는 것이 필요합니다. 분산 락은 Redis와 같은 분산 저장소를 활용하여 서버 간 동기화를 보장하고, 전체 서버의 동기화된 처리를 구현할 수 있습니다.
Lettuce는 분산 락을 구현하기 위해 SETNX와 SETEX 같은 명령어를 사용해야 하며, lock을 획득하기 위한 retry 및 timeout 로직을 개발자가 직접 작성해야 하므로 코드가 복잡해질 수 있습니다. 또한, 스핀락 방식으로 동작해 지속적인 Redis 요청으로 인해 부하가 커질 위험이 있습니다. 반면, Redisson은 RLock 인터페이스를 제공하여 락을 간편하게 관리할 수 있습니다. 또한 Pub/Sub 기반의 이벤트 방식으로 lock을 관리해 Lettuce와 달리 lock을 얻기위한 지속적인 시도를 하지 않아도 되기 때문에 Redisson을 선택하여 분산 락을 구현하기로 하였습니다.
참고