(2) 스프링 Redisson 분산락
진행 중인 프로젝트는 여러 개의 서버가 nginx로 로드밸런싱 되고 있고, DB는 Master-Slave 구조로 이중화되어 있다. 이런 환경에서는 어떤 락을 사용해서 동시성 문제를 해결해야 할까 ?
목차
1. 분산락이란
2. 다른 방식의 락은 동시성 문제를 해결할 수 없나 ?
3. Redisson vs Lettuce
4. AOP를 이용한 분산락
5. 낙관적 락, 비관적 락, 분산 락 테스트
6. 결론
1. 분산락이란
분산락은 여러 서버 또는 DB에서 공유되는 데이터의 동시성 문제를 해결하기 위해 사용하는 기술이다.
lock을 획득한 프로세스 or 스레드가 공유되는 데이터 or critical section에 접근할 수 있도록 하여 race condition을 방지한다.
2. 다른 방식의 락은 동시성 문제를 해결할 수 없나?
결론부터 말하면 있다.
비관적 락으로 DB의 데이터에 x-lock을 걸거나, 낙관적 락을 사용해도 여러 서버에서 동시성 문제를 해결할 수 있다.
하지만 왜 분산락을 사용하는 것인가 ?
비관적 락
비관적 락을 사용한다면, 여러 테이블에 대한 삽입 및 갱신이 일어나는 트랜잭션으로, 모든 데이터에 lock을 잡아버린다면 성능 저하와 데드락 지옥을 피할 수가 없다.
낙관적 락
동시에 발생하는 트랜잭션들은 별도의 대기 없이 실패하게 되거나, 별도의 재시도 구현이 필요하다. 트랜잭션 재시도 구현은 재시도 자체의 실패 등 여러 케이스들을 고려해야 하며, 이는 곧 코드의 복잡도 상승으로 이어질 것이다.
named 락
lock이 자동으로 해제되지 않기 때문에, 별도의 명령어로 해제를 수행해주거나 lock의 획득과 반납에 대한 로직을 철저하게 구현해야 한다. 또한 lock을 위한 정보가 일시적으로 DB에 저장되고, 이를 획득과 반납의 과정에서 불필요한 커넥션으로 부하와 connection pool이 부족해질 수 있다.
Redis 사용한 분산락은 대부분의 상황에서 정상적으로 동작하고, in-memory DB로 빠른 속도를 보장하기 때문에 Redis를 사용하여 분산락을 구현하고자 한다.
하지만 기존에 Redis를 사용하고 있지 않으면 추가적인 인프라 구성 비용이 들기 때문에 어느정도 trade-off를 고려해야 한다.
3. Redisson vs Lettuce
Lettuce
Lettuce는 Setnx 명령어를 사용하여 Spin Lock 방식으로 분산락을 구현한다.
Spin Lock이란 ?
public class SpinLock {
private boolean isLocked = false;
public void lock() {
while (isLocked) {
// 잠금을 얻을 때까지 계속 시도
}
}
public void unlock() {
isLocked.set(false);
}
}
여러 스레드가 lock을 획득할 때까지, 계속 반복하여 확인하는 것이다.
while 문으로 계속해서 반복한다면, lock을 획득할 때까지 한 개의 스레드가 계속 실행될 것이고 싱글 스레드로 동작하는 Redis에도 부하를 주게 되어 성능 저하와 서버 자원이 부족해질 것이다.
이를 해결하기 위해 Thread.sleep()으로 스레드를 일정 간격으로 작업하도록 할 수 있지만, 다른 작업을 위해 context switching하는 비용도 고려해야 하고 Redis는 싱글 스레드로 동작하기 때문에 부하가 생길 수 있는 것은 여전한다.
Redisson
Redis는 메시지 브로커 역할로 pub/sub 기능을 지원한다.
Redisson은 이 메시지 브로커를 활용하여 분산락을 구현하고, lock이 해제되면 기다리고 있는 스레드에게 메시지를 준다. 메시지를 받은 스레드가 다시 락 획득을 시도하며, 타임아웃시까지 반복한다.
4. AOP를 이용한 분산락
- lock을 획득한다.
- 비즈니스 로직을 수행한다.
- lock을 반납한다.
위 과정에서 1번과 3번은 분산락을 사용하는 비즈니스 로직에서 공통되는 부분이 생긴다. 이를 AOP를 이용해 컴포넌트를 만든다면 비즈니스 로직이 오염되지 않게 분리해서 사용할 수 있다.
build.gradle
dependencies {
implementation 'org.redisson:redisson-spring-boot-starter:3.27.2'
implementation 'org.springframework.boot:spring-boot-starter-aop'
}
스프링 부트 버전 별로 사용해야 하는 Redisson의 버전이 다르기 때문에, 아래 Redisson github을 참고한다.
https://github.com/redisson/redisson/blob/master/redisson-spring-boot-starter/README.md
redisson/redisson-spring-boot-starter/README.md at master · redisson/redisson
Redisson - Easy Redis Java client with features of In-Memory Data Grid. Sync/Async/RxJava/Reactive API. Over 50 Redis based Java objects and services: Set, Multimap, SortedSet, Map, List, Queue, De...
github.com
RedissonConfig.java
@Configuration
public class RedissonConfig {
@Value("${spring.data.redis.host}")
private String redisHost;
@Value("${spring.data.redis.port}")
private int redisPort;
private static final String REDISSON_HOST_PREFIX = "redis://";
@Bean
public RedissonClient redissonClient() {
Config config = new Config();
config.useSingleServer().setAddress(REDISSON_HOST_PREFIX + redisHost + ":" + redisPort);
return Redisson.create(config);
}
}
RedissonClient를 사용하기 위해 Bean으로 등록한다.
DistrubutedLock.java
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface DistributedLock {
String key();
TimeUnit timeUnit() default TimeUnit.SECONDS;
long waitTime() default 5L;
long leaseTime() default 3L;
}
@DistributedLock 어노테이션으로 분산락을 적용하기 위해 애노테이션을 만든다.
key는 필수이며, 나머지 값들은 직접 설정 가능하게 한다.
DistributedLockAop.java
@Aspect
@Component
@RequiredArgsConstructor
@Slf4j
public class DistributedLockAop {
private static final String REDISSON_LOCK_PREFIX = "LOCK:";
private final RedissonClient redissonClient;
private final AopForTransaction aopForTransaction;
@Pointcut("@annotation(com.blocker.blocker_server.commons.config.annotation.DistributedLock)")
private void cut(){}
@Around("cut()")
public Object lock(final ProceedingJoinPoint joinPoint) throws Throwable {
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
Method method = signature.getMethod();
DistributedLock distributedLock = method.getAnnotation(DistributedLock.class);
String key = REDISSON_LOCK_PREFIX + CustomSpringELParser.getDynamicValue(signature.getParameterNames(), joinPoint.getArgs(), distributedLock.key());
RLock rLock = redissonClient.getLock(key);
try {
boolean available = rLock.tryLock(distributedLock.waitTime(), distributedLock.leaseTime(), distributedLock.timeUnit());
if (!available) {
log.info("fail try lock serviceName={} key {}", method.getName(), key);
return false;
}
return aopForTransaction.proceed(joinPoint);
} catch (InterruptedException e) {
throw new InterruptedException();
} finally {
try {
rLock.unlock();
} catch (IllegalMonitorStateException e) {
log.info("Redisson Lock Already UnLock serviceName={} key={}",
method.getName(), key);
}
}
}
}
@DistributedLock 애노테이션을 적용 시 수행되는 AOP 클래스다.
애노테이션의 파라미터 값을 가져와 분산락을 획득하고, 적용된 메서드를 실행한다.
- 락의 이름으로 RLock 인스턴스를 가져온다.
- 정의된 waitTime까지 획득을 시도한다, 정의된 leaseTime이 지나면 잠금을 해제한다.
- DistributedLock 어노테이션이 선언된 메서드를 별도의 트랜잭션으로 실행한다.
- 종료 시 무조건 락을 해제한다.
CustomSpringELParser.java
public class CustomSpringELParser {
private 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);
}
}
@DistributedLock의 파라미터에 적용된 Lock의 이름을 Spring Expression Language로 파싱하여 읽어온다.
AopForTransaction.java
@Component
public class AopForTransaction {
@Transactional(propagation = Propagation.REQUIRES_NEW)
public Object proceed(final ProceedingJoinPoint joinPoint) throws Throwable {
return joinPoint.proceed();
}
}
@DistributedLock이 적용된 메서드는 REQUIRED_NEW 옵션으로, 부모 트랜잭션과 상관없이 별도의 트랜잭션으로 동작하게 설정한다.
왜 ?
트랜잭션의 격리 수준에 의해서이다.
만약 lock의 반환이 트랜잭션 커밋 시점보다 빠르면, 커밋 이전에 다른 스레드에서 공유 데이터에 접근하여, 업데이트 되지 않은 데이터를 가져오기에 여전히 동시성 문제가 발생한다.
즉, lock 반환을 트랜잭션 커밋 이후에 반환하도록 하기 위해서이다.
5. 낙관적 락, 비관적 락, Redisson 분산락 테스트
- 로컬
- 스프링 인스턴스 2개 (8080, 8081)
- MySQL
- Redis
낙관적 락
2개의 스프링 서버에서 각 50명의 사용자가 동시에 같은 게시글을 조회하고, 낙관적 락이 적용되어 있다.
낙관적 락 결과
조회수가 100으로 동시성 문제는 발생하지 않았으며, 평균 응답 시간이 0.116초 걸렸다.
비관적 락
2개의 스프링 서버에서 각 50명의 사용자가 동시에 같은 게시글을 조회하고, 비관적 락이 적용되어 있다.
비관적 락 결과
조회수가 100으로 동시성 문제는 발생하지 않았으며, 평균 응답 시간이 0.011초 걸렸으며, 낙관적 락보다 빠른 성능을 보였다.
분산락
2개의 스프링 서버에서 각 50명의 사용자가 동시에 같은 게시글을 조회하고, 분산락이 적용되어 있다.
분산락 결과
조회수가 100으로 동시성 문제는 발생하지 않았으며, 평균 응답 시간이 0.017초 걸렸다.
결론
- 낙관적 락은 충돌이 잦지 않을 것이라 예상하여 사용하는 lock인데, 한 번에 100번의 트래픽이 발생하여 오버헤드 때문에 가장 낮은 성능을 보였다.
- 비관적 락은 분산락과 비슷한 성능을 보였다. 1개의 게시글만 lock을 획득하기 때문이라 예상하고, 그렇기 때문에 데드락 또한 발생하지 않은 것이다.
- 분산락은 비관적 락과 함께 빠른 성능을 보여줬다.
- 하지만 더 많은 트래픽과 비즈니스 로직, 로컬이 아닌 운영 환경에서의 서버 구조를 가진다면 분산락이 가장 빠른 성능과 데드락 문제 등 더 안정적으로 동시성 문제를 해결할 수 있을 것이다.
6. 마무리
개발하면서 동시성 문제를 계속 생각하고 있었는데, CS와 DB 지식을 다시 복습하고 새로운 지식을 학습할 수 있었다. 그리고 '그냥 이렇게 하면 되겠지'라는 것보다, 직접 구현하여 실행해보고 성능까지 테스트하면서 좋은 경험이었다.
토스에서는 해외 주식이 사용자에게 전달되기까지 동시성 문제를 해결하기 위해 '낙관적 락 + Redis 분산락'을 사용한다고 한다. 상황에 맞는 lock을 사용하기 위해 각각의 원리와 장단점을 알아야, 해결 방법을 판단하고 적용할 수 있기에 정답은 없다.
Reference
https://helloworld.kurly.com/blog/distributed-redisson-lock/
풀필먼트 입고 서비스팀에서 분산락을 사용하는 방법 - Spring Redisson
어노테이션 기반으로 분산락을 사용하는 방법에 대해 소개합니다.
helloworld.kurly.com
https://www.youtube.com/watch?v=UOWy6zdsD-c