분산 시스템은 인터넷을 통해 연결된 컴퓨터가 하나의 시스템처럼 동작하는 환경을 말한다.
따라서, 높은 처리량과 확장성, 고가용성을 보장해 준다. 하지만 분산 시스템은 데이터의 일관성 유지와 트랜잭션 관리 등 여러 복잡한 문제를 해결해야 하는 경우가 존재한다.
특히, 여러 노드가 동시에 공유 데이터를 변경하려고 할 때, 동시성 문제가 발생하여 데이터의 일관성이 무너지게 된다. 이를 방지하기 위해서는 분산락을 사용할 수 있고, 분산 락은 여러 노드가 동시에 접근 가능한 자원에 대한 접근을 하나의 노드만 접근할 수 있도록 제한한다.
이번 포스팅에서는
[Inflearn] 재고시스템으로 알아보는 동시성이슈 해결방법 에서 배운 Redis를 활용하여 분산락을 구현하는 2가지 방법과
유익한 블로그 글이었던 Kurly Tech Blog - 풀필먼트 입고 서비스팀에서 분산락 사용 방법 에서 배운 어노테이션 기반 분산락 구현을 실습 및 기록하고자 한다.
Redis로 분산락 구현
분산락은 Redis의 클라이언트인 Lettuce와 Redisson 을 통해 구현 가능하다.
Lettuce
Lettuce는 Netty 기반의 Redis 클라이언트이며, 별도의 라이브러리 없이 spring-data-redis 의존성을 추가했다면 사용할 수 있다.
Redis의 setnx 명령어를 통해 분산락을 구현할 수 있다.
*setnx : key-value로 데이터를 관리하는 레디스에서 key 값이 존재하지 않으면 데이터를 set 하는 atomic 한 명령어이다.
별도의 설정 없이 간단히 구현할 수 있는 반면에 스핀락 방식으로 Redis에게 락이 해제되었는지에 대한 요청을 지속적으로 보내 요청이 많을 경우 Redis의 부하가 생기게 된다.
@Component
@RequiredArgsConstructor
public class RedisLockRepository {
private final RedisTemplate<String, String> redisTemplate;
public Boolean lock(Long key) {
return redisTemplate
.opsForValue()
.setIfAbsent(generateKey(key), "lock", Duration.ofMillis(3_000));
}
public Boolean unlock(Long key) {
return redisTemplate.delete(generateKey(key));
}
private static String generateKey(Long key) {
return key.toString();
}
}
lock 메서드 내의 setIfAbsent(key, value, timeout) 메서드는 레디스의 setnx를 사용할 수 있는 메서드이며, 키가 존재하지 않아 데이터를 set 하면 true를 리턴하고, 이미 락이 걸린 상태 (키가 세팅되어 있는 상태) 라면 false를 리턴한다.
unlock 은 key 값을 삭제함으로 쉽게 구현가능하다.
@Component
@RequiredArgsConstructor
public class LettuceLockStockFacade {
private final RedisLockRepository redisLockRepository;
private final StockService stockService;
public void decrease(Long productId, Long quantity) throws InterruptedException {
while (!redisLockRepository.lock(productId)) {
Thread.sleep(100);
}
try {
stockService.decrease(productId, quantity);
} finally {
redisLockRepository.unlock(productId);
}
}
}
lettuce를 사용할 때는 스핀락(while) 을 통해 구현이 가능하다. 이미 락 걸려 있을 경우, 잠시 sleep 했다가 다시 락을 획득할 수 있는지 확인한다. 따라서 lettuce의 경우 요청이 많을 경우 Redis에 큰 부하를 줄 수 있다.
Redisson
별도의 Redisson 라이브러리를 사용해야 하지만, Lock 인터페이스를 지원한다. 따라서 Lock에 대한 retry와 timeout 같은 설정을 쉽고 안전하게 사용할 수 있다.
Redisson의 경우 스핀락을 사용하지 않고, Pub/Sub 방식을 이용한다. 락이 해제되면 락을 subscribe 하는 클라이언트는 락이 해제되었다는 신호를 받고 락을 획득 시도한다.
Spring Boot를 사용하는 경우 아래 링크에서 의존성을 가져와 build.gradle에 추가해야 한다.
https://mvnrepository.com/artifact/org.redisson/redisson-spring-boot-starter
dependencies {
implementation 'org.redisson:redisson-spring-boot-starter:3.23.5'
}
@Component
@Slf4j
@RequiredArgsConstructor
public class RedissonLockStockFacade {
private final RedissonClient redissonClient;
private final StockService stockService;
public void decrease(Long productId, Long quantity) {
RLock lock = redissonClient.getLock(productId.toString());
try {
boolean available = lock.tryLock(15, 1, TimeUnit.SECONDS);
if (!available) {
log.info("lock 획득 실패");
return;
}
stockService.decrease(productId, quantity);
} catch (InterruptedException e) {
throw new RuntimeException(e);
} finally {
lock.unlock();
}
}
}
redissonClient.getLock() 을 통해 락을 획득하고, tryLock() 을 통해 락 획득을 시도한다.
락 획득을 실패했을 경우, 스핀락과 같이 레디스에 재확인하는 것이 아니라 대기 상태로 들어가 메시지가 오기를 기다린다.
tryLock(long waitTime, long leaseTime, TimeUnit unit)
- waitTime : 락 획득을 대기하는 시간
- leaseTime : 락을 점유할 수 있는 최대 시간
- unit : 각 시간의 단위
waitTime 시간 동안 Lock 획득을 시도하며 Lock을 획득 후 leaseTime 시간 이후 자동으로 UnLock 된다. 따라서 락 획득에 대한 재시도 로직과 락 획득에 대한 오류, 타임아웃 실패로 인한 무한 대기 등에 대해서 걱정하지 않아도 된다.
분산락에 대해서는 내용은 끝났지만, 위의 코드들은 몇가지 문제점이 있다.
- 비즈니스 로직과 관련 없는 분산락을 획득/해제하는 로직이 위아래로 사용되고 있기 때문에 코드가 이해하기 어렵고 복잡하다.
- 추가로 다른 로직에도 분산락이 필요할 경우, 분산락을 획득/해제하는 로직을 또 추가해야 한다.
위 문제를 해결하기 위해서 Spring AOP를 사용할 수 있다.
저번에 AOP와 어노테이션 기반 AOP에 대해서 공부(Annotation 기반 AOP 관련 포스팅) 하였기에
Kurly Tech Blog - 풀필먼트 입고 서비스팀에서 분산락 사용 방법 에 대해서 쉽게 이용하고 적용할 수 있었다.
1. 어노테이션 생성
@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는 필수이고, 나머지는 각 데이터의 특성에 맞게 커스텀이 가능하다.
2. Aspect 추가
@Aspect
@Component
@RequiredArgsConstructor
@Slf4j
public class DistributedLockAspect {
private static final String REDISSON_LOCK_PREFIX = "LOCK:";
private final RedissonClient redissonClient;
private final AopForTransaction aopForTransaction;
@Around("@annotation(labs.minnseong.stocksystemconcurrencyissue.annotation.DistributedLock)")
public Object lock(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) {
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 어노테이션이 붙은 메서드를 호출할 때, 분산락을 획득/해제하는 부가적인 작업을 할 수 있도록 하는 Aspect이다.
분산락을 획득하고 해제하는 코드 베이스는 블로그 상단에 Redisson을 활용한 분산락 구현과 동일하다.
추가로, 흥미로웠던 부분이 있었다.
@Service
@Transactional
@RequiredArgsConstructor
public class StockService {
private final StockRepository stockRepository;
@DistributedLock(key = "#productId")
public void decrease(Long productId, Long quantity) {
Stock stock = stockRepository.findByProductId(productId).orElseThrow();
stock.decrease(quantity);
}
}
기존 코드에서 분산락의 Key가 되는 값은 decrease 메서드의 productId이다.
productId를 통해 하나의 product 레코드에 대해서 락을 걸어 데이터의 일관성을 보장한다.
만약에 CustomSpringELParser를 사용하지 않았더라면 매개변수인 productId를 동적으로 가져올 수 없다.
모든 product에 대해서 Key는 LOCK:#productId 가 된다. 그러면 하나의 product에 락을 걸었을 경우 다른 product도 락이 걸리는 문제가 된다.
CustomSpringELParser를 사용한다면, 분산락의 Key를 LOCK:1로 정상적으로 가져올 수 있다.
또한, 아래와 같은 Spring Expression Language를 파싱 해서 읽어오기도 가능하여 자유롭게 키 설정이 가능하다.
@DistributedLock(key = "#model.getName().concat('-').concat(#model.getShipmentOrderNumber())")
CustomSpringELParser 코드는 아래와 같다. 사실 아래 코드를 완전히 이해는 못했지만.. 대략적으로 어떤 역할을 하는 코드인지 알 수 있었다.
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);
}
}
또 다른 부분은 아래 코드를 통해 별도의 트랜잭션으로 동작하게 한 것이다. 반드시 트랜잭션 커밋 이후 락이 해제되어야 한다.
락의 해제가 트랜잭션 커밋 보다 먼저 된다면 데이터의 정합성이 깨질 수 있다.
@Component
public class AopForTransaction {
@Transactional(propagation = Propagation.REQUIRES_NEW)
public Object proceed(final ProceedingJoinPoint joinPoint) throws Throwable {
return joinPoint.proceed();
}
}
@DistributedLock 어노테이션이 붙은 메서드에 대해서 테스트 코드로 검증해 본 결과 분산락이 잘 되었음을 확인할 수 있었다.
@Service
@Transactional
@RequiredArgsConstructor
public class StockService {
private final StockRepository stockRepository;
@DistributedLock(key = "#productId")
public void decrease(Long productId, Long quantity) {
Stock stock = stockRepository.findByProductId(productId).orElseThrow();
stock.decrease(quantity);
}
}
AOP를 통해서 비즈니스 로직과 분산락 처리 코드를 분리할 수 있었고, 이제는 Annotation 만 붙인다면 쉽게 분산락을 구현할 수 있는 구조가 되었다.
참고
'개발 > Spring' 카테고리의 다른 글
[Spring] 선착순 쿠폰 발급 요구사항 개발 (Redis, Kafka) (1) | 2024.05.16 |
---|---|
[Spring] JPA 낙관적 락 & 비관적 락 (0) | 2024.05.08 |
[Spring] 커스텀 Annotation 대상 AOP 적용하기 (0) | 2024.04.13 |
[MSA] 서킷브레이커 적용 (Resilience4j) (4) | 2024.01.23 |
[Spring] MyBatis 사용법 및 동적 쿼리 정리 (1) | 2024.01.09 |