저는 결제 시 상품 재고량의 동시성을 제어하기 위해 비관적 락(베타락)을 사용했습니다.
이번 글에서는 이 시스템을 분산락으로 전환한 과정에 대해 다루겠습니다.
분산락(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를 락의 이름으로 설정해 분산락을 적용합니다.