저는 결제 시 상품 재고량의 동시성을 제어하기 위해 비관적 락(베타락)을 사용했습니다.
이번 글에서는 이 시스템을 분산락으로 전환한 과정에 대해 다루겠습니다.
분산락(Distributed Lock)이란 분산 시스템에서 리소스나 작업에 대한 접근을 제어하기 위해 사용되는 락 메커니즘입니다.
DB락(비관적 락, 낙관적 락 등)과는 달리, 분산락은 DB를 사용하지 않고 중앙 집중형 락 서비스(Zookeeper, Redis 등)를 통해 락을 관리합니다.
저는 결제 로직에서 상품 재고량 수정에 대한 기능을 비관적 락을 이용해 동시성을 제어했습니다. 테스트 코드와 실제 운영 테스트 결과, 비관적 락을 통해 충분히 동시성을 제어할 수 있었습니다.
그러나 운영적인 관점에서 보면, 상품이라는 도메인은 결제 외에도 상품 등록, 수정, 삭제, 구매 이력 등 다양한 기능에 연관되어 있습니다. 이러한 기능들이 복잡해짐에 따라 데드락이 발생할 가능성이 높아질 수 있습니다. 데드락이 발생할 경우, 문제를 해결하기 위해 데이터베이스에 직접 접근해 로그를 확인해야 하는 상황이 발생할 수 있습니다.
또한, 현재 캐싱을 위해 Redis를 이미 사용하고 있는 점도 고려했습니다. Redis는 분산락을 구현하는데 유용한 중앙 집중형 락 서비스로, 기존의 Redis 인프라를 활용하여 동시성 문제를 보다 효과적으로 관리할 수 있습니다. 이를 통해 데이터베이스에 대한 의존도를 줄이고, 더 안정적이고 확장 가능한 동시성 제어를 구현할 수 있습니다.
Redis 분산락을 적용하기 위한 라이브러리에는 크게 Lettuce와 Redisson이 있습니다.
저는 상품 재고량과 같은 높은 신뢰성을 요구하는 작업을 다루기 위해
Redisson을 선택했습니다. 상품 재고량은 정확성과 일관성이 매우 중요한 데이터이기 때문에, 분산락의 신뢰성을 보다 더 보장할 수 있는 클라이언트가 필요했습니다.
Kurly 분산락 글을 참고하였습니다.
implementation 'org.redisson:redisson-spring-boot-starter:3.33.0'
Redisson 라이브러리를 사용하기 위한 의존성을 추가합니다.
@Configuration
public class RedisConfig {
@Value("${spring.data.redis.host}")
private String host;
@Value("${spring.data.redis.port}")
private int port;
/**
* Redisson 설정
*/
@Bean
public RedissonClient redissonClient() {
Config config = new Config();
config.useSingleServer().setAddress("redis://" + host + ":" + port);
return Redisson.create(config);
}
}
RedissonClient를 사용하기 위해 설정입니다.
@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface DistributeLock {
/**
* 락의 이름
*/
String key();
/**
* 락의 시간 단위
*/
TimeUnit timeUnit() default TimeUnit.SECONDS;
/**
* 락을 기다리는 시간
*/
long waitTime() default 5L;
/**
* 락 임대 시간
*/
long leaseTime() default 3L;
}
락의 이름인 key는 필수, 나머지 값들은 기본값을 이용하거나 커스텀하게 설정할 수 있도록 합니다.
/**
* Spring Expression Language Parser Util
*/
@NoArgsConstructor(access = AccessLevel.PRIVATE)
public class 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의 이름을 Spring Expression Language로 파싱하여 읽어올 수 있도록 하는 유틸입니다.
/**
* AOP에서 트랜잭션 분리를 위한 클래스
*/
@Component
public class AopForTransaction {
@Transactional(propagation = Propagation.REQUIRES_NEW)
public Object proceed(final ProceedingJoinPoint joinPoint) throws Throwable {
return joinPoint.proceed();
}
}
분산락을 사용하는 메소드는 Propagation.REQUIRED_NEW를 통해 별도의 트랜잭션으로 동작하도록 설정합니다. 반드시 트랜잭션이 커밋된 후 락이 해제되어야 합니다. 락의 해제가 먼저 이루어진다면 다른 사용자는 커밋되기 이전의 데이터를 가지고 수정을 하기 때문에 데이터의 정합성이 깨질 수 있습니다.
/**
* @DistributionLock Annotation을 사용한 메소드에 대한 AOP
*/
@Aspect
@Component
@RequiredArgsConstructor
@Slf4j
public class DistributeLockAop {
private final RedissonClient redissonClient;
private final AopForTransaction aopForTransaction;
@Around("@annotation(potatowoong.potatomallback.global.config.redis.DistributeLock)")
public Object lock(final ProceedingJoinPoint joinPoint) throws Throwable {
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
Method method = signature.getMethod();
DistributeLock distributeLock = method.getAnnotation(DistributeLock.class);
final String key = CustomSpringELParser.getDynamicValue(signature.getParameterNames(), joinPoint.getArgs(), distributeLock.key()).toString();
RLock rLock = redissonClient.getLock(key);
try {
boolean available = rLock.tryLock(distributeLock.waitTime(), distributeLock.leaseTime(), distributeLock.timeUnit());
if (!available) { // 락을 획득하지 못한 경우
return false;
}
return aopForTransaction.proceed(joinPoint); // 락을 획득한 경우
} catch (InterruptedException e) {
// 락을 획득하는 도중에 Interrupted Exception이 발생한 경우
log.error("Interrupted Exception", e);
throw new InterruptedException();
} finally {
// 락을 해제
rLock.unlock();
}
}
}
@DistributedLock 어노테이션 선언 시 수행되는 AOP 클래스입니다.
@DistributedLock 어노테이션의 Key값(락의 이름)을 파싱해 RLock 인스턴스를 가져옵니다.@DistributedLock 어노테이션이 선언된 메소드를 별도의 트랜잭션으로 실행합니다. /**
* 상품 재고량 감소 + 분산락
*/
@DistributeLock(key = "T(java.lang.String).format('Product%d', #productId)")
public void decreaseProductQuantityWithLock(final long productId, final int quantity) {
Product product = productRepository.findById(productId)
.orElseThrow(() -> new CustomException(ErrorCode.PRODUCT_NOT_FOUND));
// 재고량 검증
if (quantity > product.getStockQuantity()) {
throw new CustomException(ErrorCode.PRODUCT_STOCK_NOT_ENOUGH);
}
product.decreaseStockQuantity(quantity);
productRepository.save(product);
}
상품 ID를 락의 이름으로 설정해 분산락을 적용합니다.