Redisson은 Java 기반의 Redis 클라이언트 라이브러리로, Redis의 다양한 기능을 Java 객체 형태로 쉽게 사용할 수 있도록 추상화 계층을 제공합니다. 이를 통해 분산 락, 트랜잭션, 캐싱, pub/sub 등 Redis의 고급 기능을 활용하여 분산 시스템을 구축할 수 있으며, Netty 기반의 비동기 방식으로 높은 성능을 자랑합니다.
Lua Script는 경량의 고급 프로그래밍 언어인 Lua를 Redis에서 사용할 수 있도록 해주는 기능입니다. Redis는 기본적으로 한 줄씩 단일 명령어를 처리하는 방식으로 동작하지만, Lua Script를 사용하면 Redis 내에서 여러 명령어를 하나의 원자적 작업으로 처리할 수 있습니다.
현재 시스템에서는 선착순 쿠폰 발급 기능을 구현하기 위해 Redis를 공통 객체로 사용하고 있습니다. 하지만, 동시성 제어가 적용되지 않아 여러 요청이 동시에 처리될 때 쿠폰의 수량이 과다하게 감소하는 문제가 발생하고 있습니다. 특히, Kafka를 활용한 메시징 시스템에서는 높은 동시성 문제를 해결하기 위한 별도의 동기화 메커니즘이 필요합니다.
public class CouponToUserConsumerServiceImpl implements CouponToUserConsumerService {
private final CouponRepository couponRepository;
private final RedisOperationService redisOperationService;
@Transactional
@Override
public KafkaUserInfoMessage producerMessage(KafkaUserInfoMessage message) {
try {
Coupon coupon = couponRepository.findByIdAndDeletedAtIsNull(message.getCouponId())
.orElseThrow(() -> new CouponException(ResponseCode.COUPON_NOT_FOUND));
Long updatedQuantity = handleCouponQuantity(message.getCouponId(), coupon);
handleUserIssuance(message.getCouponId(), message.getUserId());
coupon.updateQuantity(updatedQuantity, message.getUserId(), coupon);
return createSuccessMessage(message, coupon);
} catch (RuntimeException e) {
return createFailedMessage(message);
}
}
10000 건의 요청 단위 테스트 진행 결과 - 총 3번 진행
동시성 제어를 적용하지 않고 확인한 결과, 여러 스레드에서 동시에 재고를 차감하고 있기 때문에 필요한 수량보다 더 많이 차감되는 것을 확인할 수 있었습니다.
이제 Redisson과 Lua Script를 적용해서 동시성 제어를 시도해보겠습니다.
public class CouponToUserConsumerServiceImpl implements CouponToUserConsumerService {
private final CouponRepository couponRepository;
private final RedisOperationService redisOperationService;
private final RedissonClient redissonClient;
private static final String LUA_SCRIPT = """
-- KEYS[1] : 쿠폰 수량을 저장하는 Redis 키
-- KEYS[2] : 이미 쿠폰을 발급받은 유저들을 저장하는 Redis Set 키
-- ARGV[1] : 발급하려는 유저의 ID
local qtyKey = KEYS[1] -- 스크립트가 실행될 때 전달받은 첫 번째 키
local userKey = KEYS[2] -- Redis Set의 키
local userId = ARGV[1] -- 스크립트 실행 시 전달받은 첫 번째 인수 -> 동적인 값
local current = redis.call('GET', qtyKey)
-- tonumber : 값을 숫자로 변환하는 함수
if current == nil or tonumber(current) <= 0 then
return -1 -- -1을 반환하여 쿠폰 수량 부족을 알림
end
-- SISMEMBER : Redis의 Set 자료형에서 특정 값이 존재하는지 확인
if redis.call('SISMEMBER', userKey, userId) == 1 then
return -2 -- -2를 반환하여 이미 발급된 유저임을 알림
end
redis.call('DECR', qtyKey)
redis.call('SADD', userKey, userId)
-- 쿠폰 발급 후, 남은 수량을 반환 (현재 수량에서 하나 감소한 값)
return tonumber(current) - 1
""";
@Transactional
@Override
public KafkaUserInfoMessage producerMessage(KafkaUserInfoMessage message) {
UUID couponId = message.getCouponId();
UUID userId = message.getUserId();
String redisKey = "couponId:" + couponId;
String issuedUserKey = "issuedUsers:" + couponId;
// 초기 수량 확인
Long currentQuantity = redisOperationService.getCurrentQuantity(redisKey);
if (currentQuantity == null || currentQuantity <= 0) {
log.warn("쿠폰이 모두 소진되었습니다. couponId: {}, currentQuantity: {}", couponId, currentQuantity);
return createFailedMessage(message);
}
String lockKey = "lock:coupon:" + couponId;
RLock lock = redissonClient.getLock(lockKey);
try {
if (lock.tryLock(1, 3, TimeUnit.SECONDS)) {
try {
Coupon coupon = couponRepository.findByIdAndDeletedAtIsNull(couponId)
.orElseThrow(() -> new CouponException(ResponseCode.COUPON_NOT_FOUND));
// Redis 키 초기화
if (!redisOperationService.hasKey(redisKey)) {
redisOperationService.initializeQuantity(redisKey, coupon.getQuantity());
}
// Lua 스크립트 실행
Long updatedQuantity = redisOperationService.evalScript(
LUA_SCRIPT, redisKey, issuedUserKey, userId.toString());
if (updatedQuantity == -1) {
log.warn("쿠폰 수량이 부족합니다. couponId: {}", couponId);
throw new CouponException(ResponseCode.COUPON_QUANTITY_EXCEPTION);
}
if (updatedQuantity == -2) {
log.error("이미 발급에 성공한 유저입니다. userId: {}", userId);
throw new IllegalArgumentException("중복된 userId 입니다.");
}
coupon.updateQuantity(updatedQuantity, userId, coupon);
return createSuccessMessage(message, coupon);
} finally {
lock.unlock();
}
} else {
log.warn("락 획득 실패: couponId: {}", couponId);
return createFailedMessage(message);
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return createFailedMessage(message);
} catch (Exception e) {
return createFailedMessage(message);
}
}
이제 똑같이 10000건의 요청을 시도해보면
동시성 문제를 해결한 것을 볼 수 있다. 추가로 100000건의 요청도 테스트 해본 결과
정상적으로 발급에 성공한 것을 확인할 수 있었습니다.
'🔥스파르타 TIL (Spring)' 카테고리의 다른 글
MapStruct 사용해보기 (3) | 2025.04.26 |
---|---|
커스텀 어노테이션과 AOP를 사용해서 권한 체크해보기 (2) | 2025.04.25 |
Redis를 활용한 BlackList 관리 중 깨달은 문제 (0) | 2025.04.23 |
Transaction 이란? (0) | 2025.03.14 |
JPA Auditing 이란? (1) | 2025.03.07 |