동시성 (Concurrency) 문제는 여러 상황과 관점에서 다양하게 나타날 수 있다.
앞에서는 동일한 레코드 즉, 동일한 Table 의 Row 를 동시에 접근했을 때의 제어 방법으로 낙관적 (Optimistic) 락과 비관적 (Pessimistic) 락에 대해 알아보았다.
2024.02.15 - [Database] - 동시성 제어 - 락 (Lock) - 낙관적 락 & 비관적 락
하지만 DB 가 여러 서버에 분산 저장되어 있는 환경에서는 어떨까?
1번 DB 의 레코드에 Lock 을 걸어도 2번 DB 로 동일한 요청이 들어온다면, 서버끼리 Lock 을 공유하진 않으므로 레코드 접근이 가능해진다.
그렇다면 DB 진입 전에 요청을 순차적으로 처리할 수 있는 단일진입점이 하나 있으면 해결 할 수 있지 않을까?
그 '단일진입점'이 되는 것이 바로 분산 락 (Distribution Lock) 이다.
분산 락 (Distribution Lock) 이란?
분산 락은 서로 다른 프로세스가 상호 배타적인 방식으로 공유 리소스와 함께 작동해야 하는 환경에서 유용한 Lock 기법이다.
꼭 분산 DB 환경이 아니더라도 단일 DB 환경에서도 사용할 수 있다.
구현방법은 아래와 같다.
- Redis 로 구현하는 방법
- DB 가 MySQL 이라면, User-Level Lock 을 활용하는 방법
- Zookeeper 로 구현하는 방법
그 중 분산 DB 환경에 적용하기에 가장 간단하고 보편적인 Redis 로 구현하는 방법에 대해서 알아보자.
1) Redis Lettuce 로 구현하는 방법
Java 용 Redis Client 라이브러리로 Jedis, Lettuce, Redisson 등이 있다.
Spring Redis 를 이미 서비스에서 사용하고 있다면 보통 Lettuce 를 사용하고 있을 것이므로, Lettuce 로 분산 락을 구현한다면 가장 쉽고 빠르게 구현할 수 있다.
// https://mvnrepository.com/artifact/org.springframework.boot/spring-boot-starter-data-redis
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
Lettuce 로 분산락을 설정하는 방법은 아래 명령어와 같다.
# SET {key} {value} NX PX {timeout milis}
SET resource_name my_random_value NX PX 30000
위 명령어를 해석해보면,
- SET : "resource_name" 이라는 redis key 에 "my_random_value" 라는 value 를 저장한다.
- NX : 이미 동일한 key 가 있는 경우에는 저장되지 않고 "nil" 을 리턴하고, 그 외의 경우에 저장하고 OK 를 리턴한다. 이미 점유하고 있다면 "resource_name" key 가 존재할테니 락을 취득할 수 없다.
- PX : 지정한 밀리초 이후로 데이터가 자동으로 삭제된다. 즉, 5분 후에 "resource_name" 이 삭제된다.
자원을 점유하기 위해서 위 명령어로 "OK" 를 리턴할 때 까지 반복하여 수행하면 된다.
위를 Spring 으로 구현하면 다음과 같이 할 수 있다.
void doProcess() {
try {
while (!tryLock(lockKey)) {
try {
Thread.sleep(50);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
// ...
} finally {
unlock(lockKey);
}
}
boolean tryLock(String key) {
return command.setnx(key, new Random().nextInt());
}
void unlock(String key) {
command.del(key);
}
단점
- For-loop 으로 계속 락을 취득할 수 있는지 확인하는 Spin lock 방식으로 구현되어 있다. Spin lock 방식은 지속적으로 Redis 명령어를 수행해야 하므로 Redis 에 많은 부담을 줄 수 있다.
- Spring 에서 사용하는 setnx() 함수는 timeout 기능을 제공하지 않는다. 만약의 경우에도 저절로 락이 해제되지 않으므로, 락을 획득하지 못한다면 무한 루프에 빠질 수 있다.
2) Redisson 으로 구현하는 방법
Redisson 은 앞서 얘기한 바와 같이, Jedis, Lettuce 와 같은 Java Redis 클라이언트 라이브러리이다.
Redisson 의존성은 아래와 같이 추가 할 수 있다.
// https://mvnrepository.com/artifact/org.redisson/redisson
implementation 'org.redisson:redisson'
프로젝트가 Spring 으로 구성되어 있다면 auto-configuration 기능을 제공하는 spring-boot-starter 도 있으니 적용할 수 있다.
// https://mvnrepository.com/artifact/org.redisson/redisson-spring-boot-starter
implementation 'org.redisson:redisson-spring-boot-starter'
주의!
하지만 기존에 spring-boot-starter-data-redis 로 Lettuce 기반의 Redis 를 이미 사용하고 있었다면
RedisTemplate 등 Bean 들이 Redisson 의 것들로 교체되는 이슈가 있다.
원인은 redssion-spring-boot-starter 에 포함되는 RedissonAutoConfiguration 이 Lettuce RedisAutoConfiguration 보다 @AutoConfigureBefore 처리되어 있기 때문이다.// redisson-spring-boot-starter 시 추가되는 configuration 클래스 @AutoConfigureBefore(RedisAutoConfiguration.class) public class RedissonAutoConfiguration { ... }
Lettuce 와 Redisson 은 구조적 차이가 있어 기존에 동작하던 기능들에 영향이 있을 수 있으므로,
이 경우 redisson-spring-boot-starter 가 아닌 그냥 redisson 의존성을 사용하자.
또 두 의존성 차이점이 RedissonAutoConfiguration 의 유무가 가장 크다.
특징
- Redis Command (GET, DEL, SETNX 등..) 를 직접 제공하지 않고, Bucket 이나 Map 과 같은 자료구조나 Lock 같은 인터페이스 형태로 제공한다.
- 클러스터 모드에서도 안정성과 활동성을 보장하도록 Redlock Argorithm 으로 구현되어 있다.
- Lettuce 와 마찬가지로 Netty 를 사용한 non-blocking I/O 를 지원한다.
- ex) RedissonReactiveClient, RedissonRxClient
Spring 으로 구현하면 아래와 같이 작성할 수 있다.
void doProcess() {
RLock lock = redissonClient.getLock(LOCK_NAME);
try {
if (lock.tryLock(WAIT_TIME, LEASE_TIME, TimeUnit.SECONDS)) {
// ... 락을 취득함
}
} finally {
lock.unlock();
}
}
- tryLock() 으로 락을 취득하고,
- 작업이 완료된 후에는 꼭 finally 문으로 락을 unlock() 시켜주어야 한다.
- timeout 설정을 추가하여 실수로 unlock() 해주지 않았을 때 락을 무한 대기하는 상황을 방지할 수 있다.
장점
- Redisson 은 Lettuce 의 Spin lock 방식과 달리 Pub/Sub 방식을 사용하여 Redis 에 부하가 적다.
즉, Redis 에서 락을 취득할 수 있을 때 어플리케이션에 획득 신호를 보내주므로, 어플리케이션이 For-loop 로써 락을 취득할 수 있는지 계속 확인하지 않아도 된다.
- Timeout 기능을 제공한다.
RLock 객체는 java 의 java.util.concurrent.locks.Lock 인터페이스를 구현하고 있어, waitTime, leaseTime 등 여러 timeout 기능을 제공한다.
boolean tryLock(long waitTime, long leaseTime, TimeUnit unit) throws InterruptedException;
- waitTime (the maximum time to acquire the lock) : 락이 걸려 있는 경우, subscribe 해둔 후 락 획득까지 최대 대기시간
- leaseTime : 락 설정 후 자동으로 락이 만료되기까지의 시간
- timeUnit : 위 timeout 의 시간단위
- 내부적으로 Lua 스크립트를 사용하여 성능에 이점이 있다.
아래와 같이 긴 String 에 있는 코드가 Lua 스크립트 이다.
// RedissonClient 내부 메소드 중 일부 발췌
protected RFuture<Boolean> unlockInnerAsync(long threadId) {
return evalWriteAsync(getRawName(), LongCodec.INSTANCE, RedisCommands.EVAL_BOOLEAN,
"if (redis.call('hexists', KEYS[1], ARGV[3]) == 0) then " +
"return nil;" +
"end; " +
"local counter = redis.call('hincrby', KEYS[1], ARGV[3], -1); " +
"if (counter > 0) then " +
"redis.call('pexpire', KEYS[1], ARGV[2]); " +
"return 0; " +
"else " +
"redis.call('del', KEYS[1]); " +
"redis.call('publish', KEYS[2], ARGV[1]); " +
"return 1; " +
"end; " +
"return nil;",
Arrays.asList(getRawName(), getChannelName()), LockPubSub.UNLOCK_MESSAGE, internalLockLeaseTime, getLockName(threadId));
}
Redis 에서 복합 연산으로 트랜잭션을 atomic 하게 구현하는게 쉽지 않은데, Lua 스크립트를 사용하면 atomic 을 보장하는 스크립트를 쉽게 구현할 수 있다. (좀 더 자세한 내용이 해당 글에 있다.)
AOP 를 사용하여 좀 더 관점 지향적으로 구현하기
메소드가 시작할 때 tryLock() 으로 락을 취득하고 메소드를 빠져나올 때 unlock() 으로 락을 해제하는 작업을 Aspect 로 구현할 수 있다.
@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface DistributedLock {
/**
* 락 key suffix 값. Spring Expression Language (SpEL) 표현식으로 입력.
*/
String key();
/**
* 락 취득까지 최대 대기시간
*
* @throws DistributedLockNotAcquiredException waitTime 초과까지 락 취득을 하지 못할 경우
*/
long waitTime() default 9000L;
/**
* 락이 자동으로 만료되기까지의 시간
*/
long leaseTime() default 5000L;
/**
* waitTime, leaseTime 의 시간단위
*/
TimeUnit timeUnit() default TimeUnit.MILLISECONDS;
}
@Slf4j
@Aspect
@Component
@RequiredArgsConstructor
public class DistributedLockAspect {
private static final ExpressionParser EXPRESSION_PARSER = new SpelExpressionParser();
private final RedissonClient redissonClient;
@Around("@annotation(com.test.application.lock.DistributedLock)")
Object processLock(final ProceedingJoinPoint joinPoint) throws Throwable {
final var signature = (MethodSignature) joinPoint.getSignature();
final var method = signature.getMethod();
final var parameterNames = signature.getParameterNames();
DistributedLock distributedLock = method.getAnnotation(DistributedLock.class);
// spring Expression 패턴을 사용하여 reflection 으로 가져온 파라미터값을 파싱하여 Lock Key 로 사용합니다.
final var lockKey = parseKeyInParameters(distributedLock.key(), parameterNames, joinPoint.getArgs());
RLock rLock = null;
try {
rLock = redissonClient.getLock(lockKey);
if (rLock.tryLock(distributedLock.waitTime(), distributedLock.leaseTime(), distributedLock.timeUnit())) {
return joinPoint.proceed();
}
throw new DistributedLockNotAcquiredException(lockKey, distributedLock.waitTime(), distributedLock.timeUnit());
} catch (InterruptedException e) {
currentThread().interrupt();
throw new DistributedLockNotAcquiredException(e);
} finally {
if (rLock != null) {
rLock.forceUnlock();
}
}
}
private String parseKeyInParameters(final String keyExpression, String[] parameterNames, Object[] args) {
StandardEvaluationContext context = new StandardEvaluationContext();
for (int i = 0; i < parameterNames.length; i++) {
context.setVariable(parameterNames[i], args[i]);
}
return String.valueOf(EXPRESSION_PARSER.parseExpression(keyExpression).getValue(context, Object.class));
}
}
- Object 형태의 파라미터에서도 lock key 를 추출할 수 있도록 SpEL (Spring Expression Language) 를 활용해서 파라미터를 파싱한다.
위에서 정의한 @DistributedLock 어노테이션을 아래와 같이 사용할 수 있다.
@DistributedLock(key = "#entityId")
void doProcess(String entityId) {
var entity = repository.findById(entityId);
...
}
주의!
@DistributedLock 과 다른 어노테이션을 함께 사용할 때 락 취득을 가장 먼저 처리하고 싶다면 DistributedLockAspect 의 @Order 를 조정해야 한다.
특히나 @Transactional 과 @DistributedLock 을 같이 쓰는 경우 트랜잭션을 먼저 시작하고 뒤늦게 락을 취득할 수 있다.
@DistributedLock(key = "#entityId") @Transcational void doProcess(String entityId) { var entity = repository.findById(entityId); ... }
@Aspect @Order(value = Ordered.LOWEST_PRECEDENCE - 1) public class DistributedLockAspect { ... }
조금 길지만 다음 글에서 Redis Redlock 알고리즘이 분산락을 통해 데이터 정확성을 얻고자 할 때는 적합하지 않다고 하니 궁금하다면 읽어보자.
https://martin.kleppmann.com/2016/02/08/how-to-do-distributed-locking.html
How to do distributed locking — Martin Kleppmann’s blog
How to do distributed locking Published by Martin Kleppmann on 08 Feb 2016. As part of the research for my book, I came across an algorithm called Redlock on the Redis website. The algorithm claims to implement fault-tolerant distributed locks (or rather,
martin.kleppmann.com
Redis 를 이용한 분산락 관련해서 자세한 내용은 Redis 공식문서를 참고하자.
https://redis.io/docs/manual/patterns/distributed-locks/
Distributed Locks with Redis
A distributed lock pattern with Redis
redis.io
'Database' 카테고리의 다른 글
[동시성 제어] 락 (Lock) - 낙관적 락 & 비관적 락 (0) | 2024.02.15 |
---|