분산락이란?
분산 락은 분산 환경에서 여러 대의 서버와 여러 DB간의 동시성을 관리하는데 사용된다. 일반적으로 분산 환경이 아니라면 비관적 락 등을 이용하여 동시성을 제어할 수 있지만, 여러 대의 DB가 존재하는 분산 DB 환경에서는 동시성 문제를 해결할 수 없다.
분산 DB에서 비관적 락으로 동시성 문제를 해결할 수 없는 이유
- 네트워크 파티션 문제
- 두 노드 사이의 네트워크 연결이 끊긴다.
- 한 노드에서 데이터의 락을 설정했지만, 연결이 끊어진 노드에서는 이 락의 정보를 알 수 없다.
- 결과적으로 두 노드에서 동시에 동일한 데이터를 변경할 수 있다.
- 데이터 복제본과 일관성의 문제
- 한 노드에서 데이터를 업데이트하고 락을 해제한 후, 변경 사항을 다른 노드에 복제한다.
- 복제하는 동안, 다른 사용자가 이전 버전의 데이터를 다른 노드에서 읽을 수 있다.
- A노드에서 데이터 X에 대한 락을 설정했다고 하면, B노드에서는 그 정보를 모르기 때문에 B노드의 사용자가 동일한 데이터 X에 접근 가능하기 때문이다.
분산 데이터베이스에서는 여러 노드가 데이터의 복제본을 가지고 있기 때문에, 한 노드에서의 락이 다른 노드의 데이터 접근에 어떤 영향을 미칠지 예측하기 어려운 문제가 있다.
(분산 락은 반드시 분산 환경에서만 사용할 수 있는 것은 아니다.)
Redis를 이용한 분산락
Redis를 이용해 분산 락을 구현하기 위해서는 Lettuce 라이브러리와 Redisson 라이브러리를 활용할 수 있다.
- Lettuce
- spring-data-redis의 기본 구현체
- 기본적으로 Spin Lock을 사용한다.
- 이는 Lock을 대기하는 상황에서, Lock을 획득할 수 있는지 계속 요청을 보낸다. 따라서 Lock을 획득하려는 스레드가 많을 경우 Redis에 부하가 집중된다.
- Lock에 대한 타임아웃이 없어, Unlock(잠금 해제) 호출을 하지 못한 경우 Dead Lock을 유발할 수 있다.
- Redisson
- pub/sub 방식을 사용한다.
- Lock을 당장 획득할 수 없으면 대기한다. Lock이 획득 가능할 경우 Redis에서 클라이언트로 획득 가능함을 알린다.
- Lock의 lease time 설정이 가능하다.
- 즉, 설정된 lease time이 지난 경우 자동으로 Lock의 소유권을 회수하여 Dead Lock을 방지한다.
- pub/sub 방식을 사용한다.
차이를 간단히 보면 아래와 같다.
Lettuce | Redisson | |
분산락 기능 제공 | X (직접 구현 필요) | O |
라이브러리 크기 | 상대적으로 작음 | 상대적으로 큼 |
구현 방식 | spin lock | pub/sub |
일반적으로 Redisson을 이용하는 것이 분산락을 구현했을 때 성능이 더 좋다고 알려져 있지만, 그렇다고 해서 무조건 Redisson을 선택하는 것은 바람직하지 않다. 둘의 차이를 이해하고 프로젝트의 요구사항에 맞춰 선택해야 한다.
이번에는 학습을 위해 두 가지 방식 모두 사용해 볼 계획이다.
Lettuce (Spring Data Redis)
Lettuce의 setnx
* docker에 redis 이미지를 pull 하여 실습해볼 수 있다.
$ docker pull redis // redis 이미지 pull
$ docker run --name my-redis -d -p 6379:6379 redis // redis 컨테이너 실행
$ docker ps -a // 모든 컨테이너 보기
$ docker exec -it my-redis redis-cli // redis-cli 접속
Lettuce는 Redis의 setnx 명령어를 통해 lock을 구현할 수 있다.
setnx는 키-값을 저장하는 명령어인데, 해당 키가 존재하면 명령어 처리에 실패하고 존재하지 않으면 키-값 저장에 성공한다. (nx는 NotExist의 줄임말)
- 1이라는 key값으로 lock이라는 value를 등록하면 처음에는 아무런 키-값이 존재하지 않기 때문에 등록에 성공하고 1을 리턴받는다.
- 그 다음으로 또 1이라는 key값을 등록하려하면 기존에 등록한 키가 있기 때문에 실패하고 0을 리턴받는다.
- 키를 삭제하려면 del 명령어를 사용하면 된다.
이제 이 setnx 와 del 명령어를 위해 직접 spin lock 형태로 lock을 구현해보자.
Lettuce lock 구현
build.gradle
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
RedisLockRepository.java
@Component
public class RedisLockRepository {
private final RedisTemplate<String, String> redisTemplate;
public RedisLockRepository(RedisTemplate<String, String> redisTemplate) {
this.redisTemplate = redisTemplate;
}
public Boolean lock(Long key){
return redisTemplate
.opsForValue()
.setIfAbsent(generated(key), "lock", Duration.ofMillis(3_000));
}
public Boolean unlock(Long key){
return redisTemplate.delete(generated(key));
}
private String generated(Long key) {
return key.toString();
}
}
- Redis에서 키-값 쌍을 저장하기 위해 RedisTemplate을 이용하였다. RedisTemplate은 추상화와 직렬화의 편리함을 제공해준다.
- 저장할 키-값 쌍을 String으로 저장하기로 했으므로 opsForValue() 메서드를 사용하였다. opsForValue() 는 String을 serialize/deserialize 하기 위한 메서드이다.
- setIfAbsent() 는 이전에 터미널에서 실행한 setnx 명령어다. 인자로 받은 key와 “lock”이라는 value로 키-값 쌍 저장을 시도한다.
- unlock() 메서드는 인자로 받은 key 값의 key-value 쌍을 메모리에서 삭제하는 메서드이다.
lock-unlock 구현을 마쳤고 실제 비즈니스 로직에서 구현한 것을 통해 spin lock 형태를 만들어보자.
LettuceLockStockFacade.java
@Component
@Slf4j
@RequiredArgsConstructor
public class LettuceLockCouponFacade {
private final RedisLockRepository redisLockRepository;
private final CouponService couponService;
public void publishCoupon(long couponId) throws InterruptedException{
while (Boolean.FALSE.equals(redisLockRepository.lock(couponId))){
Thread.sleep(10);
}
couponService.publishCoupon(couponId); // couponId가 key값
redisLockRepository.unlock(couponId);
}
}
위 클래스는 외부에서 RedisLockRepository와 CouponService를 사용하여 쿠폰 발행 작업을 처리한다. 즉, 이 클래스는 서브시스템을 감싸고, 단순화된 인터페이스를 제공하는 Facade 역할을 한다.
CouponServiceTest.java
@Test
@DisplayName("10장의 쿠폰을 30명의 사용자가 동시에 예매하는 상황 - 분산 락 사용")
void lettuceTest() throws InterruptedException {
// given
int memberCount = 30;
int ticketAmount = 10;
Coupon coupon = couponRepository.save(new Coupon(ticketAmount));
ExecutorService executorService = Executors.newFixedThreadPool(memberCount);
CountDownLatch latch = new CountDownLatch(memberCount);
AtomicInteger successCount = new AtomicInteger();
AtomicInteger failCount = new AtomicInteger();
// when
for (int i = 0; i < memberCount; i++) {
executorService.submit(() -> {
try {
lettuceLockCouponFacade.publishCoupon(coupon.getCouponId()); // lettuce 사용
successCount.incrementAndGet();
} catch (Exception e) {
System.out.println(e.getMessage());
failCount.incrementAndGet();
} finally {
latch.countDown();
}
});
}
latch.await(); // 모든 스레드의 작업이 끝날 때까지 대기
System.out.println("successCount = " + successCount);
System.out.println("failCount = " + failCount);
// then
long reservationCount = reservationRepository.count();
assertThat(reservationCount)
.isEqualTo(Math.min(memberCount, ticketAmount));
}
실행 결과
결과는 성공!
이번에는 Redisson을 이용해서 동시성 처리를 구현해보자.
Redisson
build.gradle
// Redis (Redisson)
implementation 'org.redisson:redisson-spring-boot-starter:3.23.3'
RedissonLockCouponFacade.java
@Component
@Slf4j
@RequiredArgsConstructor
public class RedissonLockCouponFacade {
private final RedissonClient redissonClient;
private final CouponService couponService;
public void publishCoupon(Long couponId) throws InterruptedException {
RLock lock = redissonClient.getLock(couponId.toString()); // redisson에서 제공하는 lock 전용 객체
try {
// 락 획득 (락 획득을 대기할 타임아웃, 락이 만료되는 시간)
boolean available = lock.tryLock(10, 1, TimeUnit.SECONDS);
if (!available) {
System.out.println("lock 획득 실패");
return;
}
couponService.publishCoupon(couponId);
} catch (InterruptedException e) {
throw new RuntimeException(e);
} finally {
lock.unlock();
}
}
}
Redisson은 Lettuce와 달리 RLock이라는 Lock 전용 객체를 제공한다.
- 락 획득 실패 시 false를 반환한다. 락 획득 시 true를 반환하는데, unlock을 하지 않고 leaseTime만큼 잠금을 획득하는 방식
- redisson의 경우 leaseTime 설정을 통해 Lock에 타임 아웃을 명시하여 무한정 대기 상태로 빠질 수 있는 위험이 없다.
- 주의해야 할 점은, leaseTime을 잘못 잡으면 작업 도중에 Lock이 해제될 수도 있다. 이를 IllegalMonitorStateException 이라고 한다.
스핀락(Spin Lock)을 사용하지 않고 pub/sub 기능을 사용한다.
CouponServiceTest.java
@Test
@DisplayName("10장의 쿠폰을 30명의 사용자가 동시에 예매하는 상황 - redisson 분산 락 사용")
void redissonTest() throws InterruptedException {
// given
int memberCount = 30;
int ticketAmount = 10;
Coupon coupon = couponRepository.save(new Coupon(ticketAmount));
ExecutorService executorService = Executors.newFixedThreadPool(memberCount);
CountDownLatch latch = new CountDownLatch(memberCount);
AtomicInteger successCount = new AtomicInteger();
AtomicInteger failCount = new AtomicInteger();
// when
for (int i = 0; i < memberCount; i++) {
executorService.submit(() -> {
try {
redissonLockCouponFacade.publishCoupon(coupon.getCouponId()); // redisson 사용
successCount.incrementAndGet();
} catch (Exception e) {
System.out.println(e.getMessage());
failCount.incrementAndGet();
} finally {
latch.countDown();
}
});
}
latch.await(); // 모든 스레드의 작업이 끝날 때까지 대기
System.out.println("successCount = " + successCount);
System.out.println("failCount = " + failCount);
// then
Long reservationCount = reservationRepository.count();
assertThat(reservationCount)
.isEqualTo(Math.min(memberCount, ticketAmount));
}
실행 결과
결과는 마찬가지로 성공!
Redisson tryLock()이 락을 pub/sub 방식으로 획득하는 과정
- getMultiLock()이나 getSpinLock()을 사용하지 않을 경우, RedissonLock 구현체를 사용한다.
RedissonLock.class
public boolean tryLock(long waitTime, long leaseTime, TimeUnit unit) throws InterruptedException {
long time = unit.toMillis(waitTime);
long current = System.currentTimeMillis();
long threadId = Thread.currentThread().getId();
/*
* 1. 초기에 락 획득을 시도한다.
* 락 점유 시간이 null이라면 획득이 가능하다고 판단하여 true를 리턴한다.
*/
Long ttl = this.tryAcquire(waitTime, leaseTime, unit, threadId);
if (ttl == null) {
return true;
} else {
/*
* 2. 락에 대한 점유 시간이 아직 남아있다면 다시 락에 대한 획득을 시도하기 전에 waitTime(락 획득 시간)이 초과되지 않았는지 확인한다.
* 만약 이미 초과되었다면 락 획득은 실패로 리턴한다.
*/
time -= System.currentTimeMillis() - current;
if (time <= 0L) {
this.acquireFailed(waitTime, unit, threadId);
return false;
} else {
current = System.currentTimeMillis();
/*
* 3. waitTime이 남아있다면, 고유 threadId를 채널로 구독하여 waitTime 내에 락을 얻기 위해 대기한다.
* 만약 설정한 waitTime 동안 응답이 없을 경우엔 TimeoutException 발생
* 중요한 점은 subscribe 내부에는 세마포어를 사용해서 공유자원에 대한 점유를 수행한다는 것이다. 세마포어를 사용하여 공유자원을 점유하기 때문에 스핀락 보다는 레디스 I/O에 대한 부하를 줄일 수 있다
*/
CompletableFuture<RedissonLockEntry> subscribeFuture = this.subscribe(threadId);
try {
subscribeFuture.get(time, TimeUnit.MILLISECONDS); // CompleteFuture.get() 메서드를 호출하여 threadId로 구독한 채널로 lock 획득이 유효할때까지 대기한다
} catch (TimeoutException var21) {
if (!subscribeFuture.completeExceptionally(new RedisTimeoutException("Unable to acquire subscription lock after " + time + "ms. Try to increase 'subscriptionsPerConnection' and/or 'subscriptionConnectionPoolSize' parameters."))) {
subscribeFuture.whenComplete((res, ex) -> {
if (ex == null) {
this.unsubscribe(res, threadId);
}
});
}
this.acquireFailed(waitTime, unit, threadId);
return false;
} catch (ExecutionException var22) {
this.acquireFailed(waitTime, unit, threadId);
return false;
}
try {
time -= System.currentTimeMillis() - current;
if (time <= 0L) {
this.acquireFailed(waitTime, unit, threadId);
boolean var24 = false;
return var24;
} else {
boolean var16;
do {
long currentTime = System.currentTimeMillis();
/*
* 4. 다시 락 획득을 시도한다.
* 락 점유 시간이 null이라면 획득이 가능하다고 판단하여 true를 리턴한다.
*/
ttl = this.tryAcquire(waitTime, leaseTime, unit, threadId);
if (ttl == null) {
var16 = true;
return var16;
}
time -= System.currentTimeMillis() - currentTime;
if (time <= 0L) {
this.acquireFailed(waitTime, unit, threadId);
var16 = false;
return var16;
}
/*
* 5. threadId로 구독한 객체로 유효시간 또는 남은시간까지 락이 available한지 구독한다.
* ttl은 이전에 락이 점유되어 남아있던 시간을 의미한다.
* time은 시도할 수 있는 남은 시간을 의미한다.
*/
currentTime = System.currentTimeMillis();
if (ttl >= 0L && ttl < time) {
((RedissonLockEntry)this.commandExecutor.getNow(subscribeFuture)).getLatch().tryAcquire(ttl, TimeUnit.MILLISECONDS);
} else {
((RedissonLockEntry)this.commandExecutor.getNow(subscribeFuture)).getLatch().tryAcquire(time, TimeUnit.MILLISECONDS);
}
/*
* 6. 락을 시도할 수 있는 시간이 남아있는지 체크한다.
*/
time -= System.currentTimeMillis() - currentTime;
} while(time > 0L);
/*
* 그 이후에는 유효시간 동안 while 문으로 4~6번 과정을 반복한다.
* 재시도 과정에서 waitTime 내에 락을 얻지못하면 메서드는 false를 반환한다.
*/
this.acquireFailed(waitTime, unit, threadId);
var16 = false;
return var16;
}
} finally {
/*
* 락 획득에 성공하든 시간 초과로 실패하든, 구독한 채널을 해지한다. 이후에는 더 이상 알림을 받지 않게 된다.
*/
this.unsubscribe((RedissonLockEntry)this.commandExecutor.getNow(subscribeFuture), threadId);
}
}
}
}
protected CompletableFuture<RedissonLockEntry> subscribe(long threadId) {
return this.pubSub.subscribe(this.getEntryName(), this.getChannelName());
}
- subscribe 내부 로직을 간단히 살펴보면 세마포어를 사용하는 것을 확인할 수 있다
redisson은 왜 세마포어를 사용했는가?
공식문서를 살펴 보면 세마포어를 사용한 이유를 파악할 수 있다.
레디스는 싱글 쓰레드로 동작하기 때문에 공유 자원에 대해서 쓰레드 세이프하게 동작하기 위해 동기화 매커니즘을 수행하기 위한 용도로 사용되었다고 설명하고 있다
공식문서 참고 : https://redisson.org/glossary/java-semaphore.html
정리
tryLock(long waitTime, long leaseTime, TimeUnit unit) 메서드는 락을 획득하려 할 때, 최대 waitTime 시간 동안 대기한다. leaseTime 은 락을 성공적으로 획득했을 때 최대 유지 시간이다. 이 시간 동안 락을 얻지 못하면 메서드는 false를 반환한다.
메서드 내부 과정을 정리하자면,
- 초기 락 획득 시도 : tryLock 메서드 안에서 tryAcquire() 을 이용하여 락을 획득하려 시도하고, 락을 성공적으로 획득하면 true를 반환한다.
- 락 획득 실패 시 구독 준비 : 락 획득에 실패한 경우 락의 남은 ttl(time to live)을 확인한다. 락이 만료될 때 까지의 시간이 남아있다면, 대기 시간(waitTime) 내에 락을 얻기 위해 대기한다. 이 시점에서 스레드는 락이 해제될 때 알림을 받기 위해 특정 채널을 구독한다.
- 구독 시 내부적으로 PublishSubscribeService를 호출하여 channelName 에 대한 세마포어를 가지고 온다.
- 락 재시도 : 구독을 설정한 후, 스레드는 다시 락 획득을 시도한다. 이 때, 락 획득에 성공하면 true를 반환하고, 구독을 해지한다. 락 획득에 실패하면 락이 사용 가능해질 때까지 대기 상태에 들어간다.
- 알림 대기 : 락이 사용 가능하지 않다면, 스레드는 락이 해제되었다는 알림 메시지가 도착할 때까지 대기한다.
- 락 획득 재시도 실패 : 재시도 과정에서 waitTime 내에 락을 얻지못하면 메서드는 false를 반환한다. 이 경우 스레드는 락 획득을 포기하고 구독을 해지한다.
- 구독 해지 : 락 획득에 성공하든, 시간 초과로 실패하든, 구독한 채널을 해지한다. 이후에는 더 이상 알림을 받지 않게 된다.
결론
Lettuce
- 단순히 분산락을 구현하는 목적으로만 Redis를 사용한다면, 분산락에 대한 많은 고급 기능을 제공하는 Redisson 라이브러리는 불필요하게 무겁게 느껴질 수 있다. 따라서 이 경우에는 Lettuce를 사용하는 것도 좋다.
- 혹은 락을 걸고 해제하는 과정이 짧다면, 락에 대한 retry를 하기 위해 많은 루프를 돌 필요가 없으므로 spin lock 방식도 큰 부하 없이 사용할 수 있다.
Redisson
- 처음엔 redisson은 spin lock 로직이 없이 내부적으로 pub/sub 구조만 가지고 있는줄 알았다.
- 하지만 살펴보니 spin lock 개념은 완전히 걷어내지는 못했지만 그래도 lettuce로 spin lock을 구현하는 것보단 훨씬 적은 부하(cpu 소모)로 락을 획득할 수 있다고 보여진다
Ref.
https://eckrin.tistory.com/178
https://incheol-jung.gitbook.io/docs/q-and-a/spring/redisson-trylock
'✍️ 개발 기록' 카테고리의 다른 글
authenticate에서 발생하는 '자격 증명에 실패하였습니다' 문제 해결 (0) | 2024.06.19 |
---|---|
JPA Auditing으로 생성일/수정일 자동 갱신 (+date format이 동작하지 않는 문제 해결) (0) | 2024.06.14 |
synchronized 키워드를 사용하여 임계 영역 지정하기 (1) | 2024.05.17 |
동시성 문제 발생 확인 (0) | 2024.05.17 |
[🎵MIML] nGrinder로 성능테스트 하기 (0) | 2023.12.28 |