1. DistributedLock(분산락) - Spin Lock 방식
서비스를 구현하다보면 많은 문제가 발생하고는 합니다. 그 중 가장 많이 대두되는 문제중 하나는 바로
동시성 문제(Race Condition) 입니다. 분산환경 즉 여러대의 서버 혹은 인스턴스가 동일한 자원(DB, 파일, 캐시 등)을 동시에 접근하려 할때 발생하는 문제로 경우에 따라 크리티컬한 문제를 발생시킬 수 있는 요소입니다.
이런 동시성 문제를 방지하기 위해 "Lock" 이라는 개념이 필요하게 되며, Lock 을 구현하는 방법중 하나인 Redis 를 이용하게 되고, Lock 구현 방식에 따라 어떤 Redis 라이브러리를 선택해야 하는지 결정을 해야만 합니다.
분산락을 구현하는 방법들은 다음과 같습니다.
Redis 기반, Zookeeper, DB
해당 포스팅에서는 Redis 기반의 Lock 을 구현할것이며, Lettuce 를 이용한 Spin Lock 을 구현할 예정입니다.
- DistributedLock(분산락) 이 필요한 이유
현대의 웹 서비스는 대부분 여러대의 서버(인스턴스)로 구성된 분산 시스템 구조입니다.
서버가 한대로만 구성되어있는 서비스라면 Java 의 synchronized, volatile 등 사용이 가능하지만 이는 각 서버에만 적용이 가능한 방법이기 때문에 분산환경에서는 적절하지 않은 동시성 제어 방법입니다.
- 한정 수량의 재고가 감소되는 로직
- 파일, 특정 리소스에 대한 접근 제어
- Batch Job 중복 실행 방지
위와 같은 비지니스 로직에서 분산 Lock 을 사용 하기 적합합니다.
- 그렇다면 DB 레벨에서 Lock 을 걸면 안될까?
개발에는 정답이 없듯 DB 레벨에서 Lock 을 거는것도 하나의 방법이 될 수 있다.
Lock 을 구현하기 위해, Redis, Zookeeper 인스턴스를 띄우게 된다면 이는 오버 엔지니어링이 될 수 있으므로 좋은 선택이 아닐 수 있다.
단일 서버, 단일 DB 일 경우 Row-level Lock 만으로도 해결될 수 있겠지만, 확장성에는 제한이 있다 또한 Transaction 범위를 넘어가는 작업이라면 DB Lock 으로는 해결이 불가하고 DB 가 받는 부하, Connection Pool 관리를 생각했을때
서비스가 분산환경이라면 분산락은 확장성, 안정성 면에서 좋은 선택이라고 보여진다.
2. Redis Lock
필자는 이미 Redis 를 사용중이며, 싱글 인스턴스로 사용하고 있기에 인스턴스 추가에 대한 부담이 없었고, Redis 여러대를 통한 분산락 환경 또한 생각하지 않아도 되기에 Redis 를 통한 Lock 을 생각했다. Zookeeper 를 사용한다면 순서보장(공정성), 안정성 등을 더 높게 가져갈 수 있지만 러닝커브 및 인스턴스 관리 등의 이유로 오버 엔지니어링이라 판단하여 배제하였다.
- Redis 라이브러리
Redis 로 Lock 을 구현하는 방법에는 크게 2가지가 존재한다 Spin Lock, Redisson 이 제공하는 Lock Interface 사용
결국 Redis 라이브러리인 Lettuce, Redisson 둘중 하나를 선택해서 구현해야 한다.
각각의 장단점에 대해서 얘기해 보자면 아래와 같다.
- Lettuce 라이브러리
Lock Interface 를 제공하지 않기에 개발자가 Lock 에 대한 로직을 직접 구현해야 한다.
이때 Lock 의 구현은 Spin Lock 방식으로, 해당 Lock 이 점유중인지 계속 질의를 하는 방식이라 Redis 에 부하가 발생한다.
또한 DeadLock 발생 가능성이 존재하기에 Lock 점유 해제를 필수로 고려해야한다.
다만 Lock 점유 시간이 짧은 경우, 경쟁이 낮은 경우에는 Spin Lock 을 사용하는게 유리할 수 있다.
- Redisson 라이브러리
기본적으로 Lock Interface 를 제공하며, pub/sub 방식을 사용하여 Lock 이 해제됬을 경우 Subscriber 에게 알림을 줘서 Lock 점유 시도를 하게 하는 방식으로 Spin Lock 에 비해서 안정적이며 손쉽게 적용 가능하다
각 라이브러리의 장단점을 보게 된다면 Lock 을 사용함에 있어 Redisson 라이브러리를 사용하는게 좋은 방법이지만
현재 서비스 모듈에서는 Lettuce 라이브러리를 사용중이었고, 경쟁이 심하지 않다고 판단해 Lettuce Spin Lock 방식 사용을 선택하게 되었다.
3. 구현
Redis Spin Lock 을 구현하려면 어떤 것들을 고려해야 하는지 부터 살펴보도록 하자.
- Lock Name 으로 Lock 을 커스텀하게 처리
- DeadLock 방지를 위해 재시도횟수 및 재시도 시간 설정, 만료시간 설정가능
- Lock 은 Lock 을 점유한 사용자만 해제 가능
- 비지니스 로직과는 분리
결국에는 안정성을 높이며, 상황에 맞게 사용할 수 있도록 커스텀 요소들을 설정할 수 있게 하는것을 목표로 한다.
- RedisLockService
@Service
@RequiredArgsConstructor
public class RedisLockService {
private final RedisTemplate redisTemplate;
private static final String LOCK_PREFIX = "LOCK:";
private static final long SPIN_INTERVAL_MIN = 100; // 최소 재시도 간격 (100ms)
private static final long SPIN_INTERVAL_MAX = 500; // 최대 재시도 간격 (500ms)
/**
* Lock 획득
*/
public boolean acquireLock(String lockKey, String requestId, long expireTime) {
Boolean isSet = redisTemplate.opsForValue().setIfAbsent(getLockKey(lockKey), requestId, expireTime, TimeUnit.MILLISECONDS);
return Boolean.TRUE.equals(isSet); // 성공시 true, 실패시 false
}
/**
* Lock 해제
*/
public void releaseLock(String lockKey, String requestId) {
String storedRequestId = (String) redisTemplate.opsForValue().get(getLockKey(lockKey));
if (requestId.equals(storedRequestId)) {
redisTemplate.delete(getLockKey(lockKey));
}
}
/**
* Lock 획득 실패시 - 재시도
* - Random 한 재시도 시간을 두고 Lock 획득 시도
* 최대 20회 재시도 후 Lock 획득 실패시 false 반환
* @param requestId = UUID.randomUUID().toString()
* @param expireTime = Lock Expire Time(ms, Millisecond)
* @return boolean = false
*/
public boolean acquireLockRetry(String lockKey, String requestId, long expireTime) {
int retryCount = 0;
int maxRetryCount = 20;
boolean isLock = false;
while(!isLock && retryCount < maxRetryCount){
isLock = this.acquireLock(lockKey, requestId, expireTime);
if(!isLock) {
try {
Thread.sleep(getRandomSleepTime());
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return false;
}
}
retryCount ++;
}
return isLock;
}
/**
* @package : com.boot.redis.about_redis.lock
* @name : RedisLockService.java
* @date : 2025. 4. 15. 오전 12:39
* @author : lucaskang(swings134man)
* @Description: 재시도 횟수를 지정하여 Lock 획득 시도
**/
public boolean acquireLockRetry(String lockKey, String requestId, int retryCount, long expireTime) {
int count = 0;
boolean isLock = false;
while(!isLock && count < retryCount){
isLock = this.acquireLock(lockKey, requestId, expireTime);
if(!isLock) {
try {
Thread.sleep(getRandomSleepTime());
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return false;
}
}
count ++;
}
return isLock;
}
/**
* 랜덤한 재시도 시간 반환 (100ms ~ 500ms) - BackOff Time(Busy Waiting)
*/
private long getRandomSleepTime() {
return SPIN_INTERVAL_MIN + (long) (Math.random() * (SPIN_INTERVAL_MAX - SPIN_INTERVAL_MIN));
}
private String getLockKey(String key) {
return LOCK_PREFIX + key;
}
}
Lock 을 사용하기 위한 구현 코드이다.
사용자의 lock name 을 통해 LOCK:{name} 형식으로 여러개의 커스텀한 Lock 을 생성하며
재시도 횟수 지정, 재시도 사이 간격 설정을 통한 부하 최소화를 고려 하였다.
DeadLock 을 방지하기 위한 ExpireTime 또한 설정 가능하게 했으며 Lock 의 경우 Lock 을 점유한 사용자만이 해제 가능하도록 구현하였다.
이때 100ms ~ 500ms 사이의 랜덤한 재시도 시간을 부여함으로서 Redis 에 요청이 몰리지 않도록 구현하였다.
다만 이 방법은 공정성 즉 순서를 보장하지 않는 방법이기에 순서를 필수적으로 부여해야 한다면, Redis 의 List 를 통해 순서기반으로 구현을 다시해야 한다.
- RedisLockService 사용 예제
@Transactional
public void lockTask() {
String lockKey = "theLock";
String requestId = UUID.randomUUID().toString();
long expireTime = 5000L;
boolean isLock = redisLockService.acquireLock(lockKey, requestId, expireTime);
if(isLock){
// Lock 획득
try {
// TODO : Lock 획득 후 처리할 비즈니스 로직?
} finally {
// Lock 해제
redisLockService.releaseLock(lockKey, requestId);
}
}else {
log.warn("Redis Lock 획득 실패!");
}//else
}
Lock 을 구현한 Class 를 사용하기 위해서는 위와 같은 코드를 필수적으로 작성해야만 한다.
Exception 이 발생하거나 혹은 Lock 을 해제하지 못하는 경우를 대비하여 finally 키워드로 항상 Lock 을 해제하도록 해줘야 하고,
Lock 에 대한 설정을 해야하기에 설정값들을 정의해줘야 한다.
우선 해당 Lock 구현체가 제대로 동작하는지 Test 를 해보도록 하자
가장 중요한 기능3가지 테스트 코드만을 올리도록 하겠다
@Test
@DisplayName("Lock - 동일한 LockKey에 중복 획득 불가")
void 동일한_LockKey에_중복_획득_불가_테스트() {
// Given
String lockKey = "duplicate-lock";
String requestId1 = UUID.randomUUID().toString();
String requestId2 = UUID.randomUUID().toString();
long expireTime = 5000;
// When: 첫 번째 요청이 Lock 획득
boolean firstLock = redisLockService.acquireLock(lockKey, requestId1, expireTime);
// Then: 첫 번째 요청은 Lock을 획득해야 함
assertThat(firstLock).isTrue();
// When: 두 번째 요청이 동일한 LockKey로 Lock 시도
boolean secondLock = redisLockService.acquireLock(lockKey, requestId2, expireTime);
// Then: 두 번째 요청은 Lock 획득 실패해야 함
assertThat(secondLock).isFalse();
}
@Test
@DisplayName("Lock - TTL 만료 후 테스트")
void Lock_TTL_만료후_다른_프로세스가_Lock_획득_가능한지_테스트() throws InterruptedException {
// Given
String lockKey = "ttl-lock";
String requestId1 = UUID.randomUUID().toString();
String requestId2 = UUID.randomUUID().toString();
long expireTime = 2000; // 2초 후 만료
// When: 첫 번째 요청이 Lock 획득
boolean firstLock = redisLockService.acquireLock(lockKey, requestId1, expireTime);
// Then: 첫 번째 요청이 Lock을 획득해야 함
assertThat(firstLock).isTrue();
// 2초 후 TTL 만료 확인
Thread.sleep(2500);
assertThat(redisTemplate.opsForValue().get("LOCK:" + lockKey)).isNull();
// When: 두 번째 요청이 Lock 획득 시도
boolean secondLock = redisLockService.acquireLock(lockKey, requestId2, expireTime);
// Then: 두 번째 요청이 Lock을 획득해야 함
assertThat(secondLock).isTrue();
}
@Test
@DisplayName("Lock - 여러 Thread 동시 Lock 획득 테스트")
void 여러_스레드가_동시에_Lock을_획득하려고_할때_하나만_획득해야_함() throws InterruptedException {
// Given
String lockKey = "concurrent-lock";
long expireTime = 5000; // 5초
int threadCount = 10;
ExecutorService executorService = Executors.newFixedThreadPool(threadCount);
CountDownLatch latch = new CountDownLatch(threadCount);
String[] successfulRequestId = new String[1];
// When: 여러 개의 스레드가 동시에 Lock을 획득 시도
for (int i = 0; i < threadCount; i++) {
executorService.execute(() -> {
String requestId = UUID.randomUUID().toString();
boolean acquired = redisLockService.acquireLock(lockKey, requestId, expireTime);
if (acquired) {
successfulRequestId[0] = requestId;
}
latch.countDown();
});
}
latch.await(); // 모든 스레드 대기
// Then: 하나의 스레드만 Lock을 획득해야 함
assertThat(successfulRequestId[0]).isNotNull();
assertThat(redisTemplate.opsForValue().get("LOCK:" + lockKey)).isEqualTo(successfulRequestId[0]);
}
간단한 테스트를 통해서 의도된 Lock 기능들이 동작하는것을 확인할 수 있다.
- Lock 로직 분리
Lock 을 사용해야하는 Method 마다 Lock 에 관련된 로직을 넣는것은 비지니스 로직에 대해서 오류를 발생시킬수도 있고
중복된 코드가 계속 발생하는 만큼 효율적이지 못하게 된다.
AOP 기반으로 커스텀 어노테이션을 만들어 Lock 을 공통화 하여 로직을 분리 해보도록 하겠다.
- DistributedLock.@Interface
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface DistributeLock {
String lockKey();
long expireTime() default 5000L;
int retryCount() default 20; // Retry Count
}
우선 Lock 을 적용하게 될 Method 에 대해 @DistributedLock 이라는 어노테이션으로 정의해 주었다.
- DistributedLockAop.class
@Aspect
@Component
@RequiredArgsConstructor
@Slf4j
@Order(-1) // Transaction 보다 Lock 취득을 우선함.
public class DistributeLockAop {
private final RedisLockService redisLockService;
@Around("@annotation(com.boot.redis.config.annotation.DistributeLock)")
public Object lock(final ProceedingJoinPoint joinPoint) throws Throwable{
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
Method method = signature.getMethod();
DistributeLock annotation = method.getAnnotation(DistributeLock.class);
// 1. For Lock Values
String lockKey = (String) CustomSpringElParser.getDynamicValue(signature.getParameterNames(), joinPoint.getArgs(), annotation.lockKey());
String requestId = UUID.randomUUID().toString();
int retryCount = annotation.retryCount();
long expireTime = annotation.expireTime(); // ms(Default 5sec)
// 2. Get Lock
boolean isLock = redisLockService.acquireLockRetry(lockKey, requestId, retryCount, expireTime);
if(!isLock) {
return false;
}
// 3. Proceed
try {
return joinPoint.proceed();
} catch (Throwable e) {
log.error("Error occurred while executing method: {}", method.getName(), e);
throw e;
} finally {
// 4. Release Lock
redisLockService.releaseLock(lockKey, requestId);
}
}
}
커스텀 어노테이션을 통해 수행할 Lock 로직을 적용해주었다.
이때 @Order(-1) 을 설정하여 @Transactional 어노테이션 보다 우선순위에 두어 Lock 을 점유한 이후에
Transactional 을 시작하도록 하였다. 락 미획득 시 트랜잭션 자원 낭비 방지, 락이 선행되지 않은 트랜잭션 진입 방지를 위해서이다.
트랜잭션이 먼저 시작되어버린다면, 동시성 충돌이 발생하여 오히려 데이터 정합성 충돌이 발생할 수 있다.
또한 Transaction Commit 이후 Lock 이 해제되어야 하기에 메서드 수행 이후 finally 키워드로 Lock 을 해제하도록 하게해줘야 한다.
트랜잭션 커밋 이후에 Lock 이 해제되지 않으면, 잘못된 데이터로 작업을 수행할 수 있기 때문에 주의해야 하는 부분일것이다.
- CustomSpringElParser.class
public class CustomSpringElParser {
public CustomSpringElParser() {
}
public static Object getDynamicValue(String[] parameterNames, Object[] args, String key) {
ExpressionParser parser = new SpelExpressionParser();
StandardEvaluationContext context = new StandardEvaluationContext();
for (int i = 0; i < parameterNames.length; i++) {
context.setVariable(parameterNames[i], args[i]);
}
return parser.parseExpression(key).getValue(context, Object.class);
}
}
사용자가 파라미터로 넘긴 Lock Key 값을 어노테이션에서 파싱할 수 있도록 컴포넌트 클래스를 작성해 주었다.
@DistributeLock(lockKey = "#lockName", retryCount = 100, expireTime = 7000L)
@Transactional
public void decreaseSyncValue(String lockName) {
// TODO: Something
}
최종적으로 위와 같이 Lock Name 을 파싱하기 위한 컴포넌트이다.
4. Test
최종적으로 Redis 의 Spin Lock 을 테스트해보도록 하겠다.
- 테스트 코드
@BeforeEach
void setUp() {
syncJpaRepository.deleteAll();
// Init DB Row Data
syncObject = new SyncObject();
syncObject.setName("lock_one");
syncObject.setValue(100);
syncJpaRepository.save(syncObject);
}
@Test
@DisplayName("1. Distributed Lock Test: Annotation")
void 특정값차감_분산락_동시성_100개() throws InterruptedException {
int numberOfThreads = 100; // 동시성 테스트를 위한 스레드 수
ExecutorService executorService = Executors.newFixedThreadPool(numberOfThreads);
CountDownLatch latch = new CountDownLatch(numberOfThreads);
for (int i = 0; i < numberOfThreads; i++) {
executorService.submit(() -> {
try {
// 분산락 적용 메서드 호출 (락의 key는 Object의 name으로 설정)
redisService.decreaseSyncValue(syncObject.getName(), syncObject.getName());
} finally {
latch.countDown();
}
});
}
latch.await();
// 최종 확인
SyncObject result = syncJpaRepository.findByName(syncObject.getName())
.orElseThrow(IllegalArgumentException::new);
assertThat(result.getValue()).isZero();
System.out.println("Final Value: " + result.getValue());
}
@Test
@DisplayName("2. 분산락 적용 안한 동시성 테스트")
void 특정값차감_동시성_100개() throws InterruptedException {
int numberOfThreads = 100; // 동시성 테스트를 위한 스레드 수
ExecutorService executorService = Executors.newFixedThreadPool(numberOfThreads);
CountDownLatch latch = new CountDownLatch(numberOfThreads);
for (int i = 0; i < numberOfThreads; i++) {
executorService.submit(() -> {
try {
redisService.decreaseValue(syncObject.getName());
} finally {
latch.countDown();
}
});
}
latch.await();
// 최종 확인
SyncObject result = syncJpaRepository.findByName(syncObject.getName())
.orElseThrow(IllegalArgumentException::new);
assertThat(result.getValue()).isZero();
System.out.println("Final Value: " + result.getValue());
}
- 사용 Method
@DistributeLock(lockKey = "#lockName", retryCount = 100, expireTime = 7000L)
@Transactional
public void decreaseSyncValue(String lockName, String syncName) {
SyncObject syncObject = syncJpaRepository.findByName(syncName).orElseThrow(IllegalArgumentException::new);
syncObject.decrease();
}
@Transactional
public void decreaseValue(String syncName) {
SyncObject syncObject = syncJpaRepository.findByName(syncName).orElseThrow(IllegalArgumentException::new);
syncObject.decrease();
}
분산락을 적용한 코드와, 적용하지 않은 로직으로 테스트 하도록 하겠다.
100개의 Thread 가 동시에 value 값을 -1 씩 진행하도록 했다.
우선 분산락을 적용하지 않은 테스트 코드의 경우 DB 의 값이 14개만 차감이 된것을 확인할 수 있고
분산락을 적용한 테스트 코드의 경우 아래와 같이 100개 모두 차감된것을 확인할 수 있다.
이처럼 분산환경에서 분산락(Spin Lock)을 적용 도입하는 예시를 알아봤으며, AOP 를 활용하여 비지니스 로직과 분리 적용하는 방법을 알아보았다. MSA 환경에서 하게된다면 해당 로직들을 라이브러리화 하여 공통으로 사용하게 하여 개발 편의성을 제공함과 동시에 일관된 Lock 알고리즘을 적용하게 할 수 있다.
'DB > Redis' 카테고리의 다른 글
[Redis] (STOMP + pub/sub) 채팅 기능(서버 다중화) (0) | 2025.03.04 |
---|---|
[Redis] Spring Boot - Redis Pub, Sub 구현&응용 (3) | 2024.03.29 |
[Redis] Redis를 이용한 임시번호 발급(OTP, 임시비밀번호, 인증문자) - Spring Boot (0) | 2024.03.23 |
[Redis] Redis - pub/sub 이란? (1) | 2023.07.16 |
[Redis] Redis + Spring boot 연동 (2) (2) | 2023.03.30 |
댓글