💡
Redisson을 이용한 Redis Lock 개발
Lettuce가 아니라Redisson을 사용한 이유를 설명할 수 있어야한다.
RLock을 사용하며, tryLock() 등 다양한 락 API를 지원함제한된 수량의 쿠폰을 발급 받으려 다수의 사용자가 몰려드는 상황.
쿠폰 수량: 1000개
발급 받으려는 유저: 1200명
발급된 쿠폰 수량(원래 수량 - 남은 수량) = 유저에게 발급된 쿠폰 수량 (발급된 userCoupon 개수)
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
# Redis
spring.data.redis.host=${REDIS_LOCALHOST}
spring.data.redis.password=${REDIS_PASSWORD}
spring.data.redis.port=${REDIS_PORT}
spring.data.redis.timeout=60000
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface WithRedissonLock {
long waitTime() default 3000L; // Lock 획득을 시도하는 최대 시간 (ms)
long leaseTime() default 5000L; // 락을 획득한 후, 점유하는 최대 시간 (ms)
TimeUnit timeUnit() default TimeUnit.MILLISECONDS;
}
@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
public @interface LockKey {
}
@Configuration
public class RedissonConfig {
@Value("${spring.data.redis.host}")
private String redisHost;
@Value("${spring.data.redis.port}")
private String redisPort;
private static final String REDISSON_HOST_PREFIX = "redis://";
@Bean
public RedissonClient redissonClient() {
Config config = new Config();
String address = REDISSON_HOST_PREFIX + redisHost + ":" + redisPort;
config.useSingleServer().setAddress(address);
return Redisson.create(config);
}
}
@Slf4j
@Aspect
@Component
@RequiredArgsConstructor
public class WithRedissonLockAspect {
private final RedissonClient redissonClient;
private final AopForTransaction forTransaction;
@Around("@annotation(org.example...annotation.WithRedissonLock)")
public void lockAroundExecution(ProceedingJoinPoint joinPoint) throws Throwable {
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
Method method = signature.getMethod();
Object[] args = joinPoint.getArgs();
Annotation[][] paramAnnotations = method.getParameterAnnotations();
Object lockKeyValue = null;
for (int i = 0; i < paramAnnotations.length; i++) {
for (Annotation annotation : paramAnnotations[i]) {
if (annotation.annotationType() == LockKey.class) {
lockKeyValue = args[i];
break;
}
}
}
// 호출한 메소드 파라미터에 @LockKey 가 없으면 오류 발생
if (lockKeyValue == null) {
throw new BaseException(ExceptionCode.INTERNAL_SERVER_ERROR);
}
String key = method.getName() + ":" + lockKeyValue;
WithRedissonLock annotation = method.getAnnotation(WithRedissonLock.class);
RLock rLock = redissonClient.getLock(key);
try {
boolean lockable = rLock.tryLock(
annotation.waitTime(), annotation.leaseTime(),
TimeUnit.MILLISECONDS);
if (!lockable) {
log.info("LOCK 획득 실패");
return;
}
log.info("LOCK 획득 ID: " + key);
forTransaction.proceed(joinPoint);
} catch (InterruptedException e) {
throw new RuntimeException("스레드 인터럽트 발생");
} finally {
rLock.unlock();
log.info("LOCK 해제 ID: " + key);
}
}
}
@WithLedissonLock이 걸린 메소드 실행 전후로 포인트컷 지정메소드 이름:lockKeyValue를 합쳐 락키(key) 사용lockKeyValue는 @LockKey 어노테이션을 활용해 동적으로 추출tryLock(waitTime, leaseTime): 내부적으로 약 100ms 간격으로 재시도 (획득 실패 시 예외 발생)waitTime(3000L) 기준 최대 약 30번 시도@Component
public class AopForTransaction {
@Transactional(propagation = Propagation.REQUIRES_NEW)
public Object proceed(final ProceedingJoinPoint joinPoint) throws Throwable {
return joinPoint.proceed();
}
}
propagation = Propagation.REQUIRES_NEW)으로 실행 시키기 위한 컴포넌트2025-05-22T20:14:44.573+09:00 INFO 6168 --- [FourChak] [ool-3-thread-10] o.e.fourchak.aop.WithRedissonLockAspect : LOCK 획득 ID: issueCouponRedissonWithAOP:55
2025-05-22T20:14:44.575+09:00 INFO 6168 --- [FourChak] [ool-3-thread-10] o.e.f.d.c.service.UserCouponService : NOW COUPON COUNT: 56
2025-05-22T20:14:44.589+09:00 INFO 6168 --- [FourChak] [pool-3-thread-3] o.e.fourchak.aop.WithRedissonLockAspect : REDISSON AOP TX START: true
2025-05-22T20:14:44.589+09:00 INFO 6168 --- [FourChak] [pool-3-thread-3] o.e.fourchak.aop.WithRedissonLockAspect : REDISSON AOP TX BEFORE WHILE : true
2025-05-22T20:14:44.591+09:00 INFO 6168 --- [FourChak] [ool-3-thread-10] o.e.fourchak.aop.WithRedissonLockAspect : LOCK 해제 ID: issueCouponRedissonWithAOP:55
2025-05-22T20:14:44.591+09:00 INFO 6168 --- [FourChak] [pool-3-thread-3] o.e.fourchak.aop.WithRedissonLockAspect : LOCK 획득 ID: issueCouponRedissonWithAOP:55
2025-05-22T20:14:44.591+09:00 INFO 6168 --- [FourChak] [pool-3-thread-8] o.e.fourchak.aop.WithRedissonLockAspect : LOCK 획득 실패
2025-05-22T20:14:44.593+09:00 INFO 6168 --- [FourChak] [pool-3-thread-8] o.e.fourchak.aop.WithRedissonLockAspect : REDISSON AOP TX BEFORE WHILE : true
2025-05-22T20:14:44.593+09:00 INFO 6168 --- [FourChak] [pool-3-thread-3] o.e.f.d.c.service.UserCouponService : NOW COUPON COUNT: 568
2025-05-22T20:14:44.600+09:00 INFO 6168 --- [FourChak] [ool-3-thread-10] o.e.fourchak.aop.WithRedissonLockAspect : REDISSON AOP TX START: true
2025-05-22T20:14:44.600+09:00 INFO 6168 --- [FourChak] [ool-3-thread-10] o.e.fourchak.aop.WithRedissonLockAspect : REDISSON AOP TX BEFORE WHILE : true
2025-05-22T20:14:44.600+09:00 INFO 6168 --- [FourChak] [pool-3-thread-3] o.e.fourchak.aop.WithRedissonLockAspect : LOCK 해제 ID: issueCouponRedissonWithAOP:55
2025-05-22T20:14:44.602+09:00 INFO 6168 --- [FourChak] [ool-3-thread-10] o.e.f.d.c.service.UserCouponService : NOW COUPON COUNT: 568
2025-05-22T20:14:44.611+09:00 INFO 6168 --- [FourChak] [pool-3-thread-3] o.e.fourchak.aop.WithRedissonLockAspect : REDISSON AOP TX START: true
2025-05-22T20:14:44.611+09:00 INFO 6168 --- [FourChak] [pool-3-thread-3] o.e.fourchak.aop.WithRedissonLockAspect : REDISSON AOP TX BEFORE WHILE : true
2025-05-22T20:14:44.611+09:00 INFO 6168 --- [FourChak] [ool-3-thread-10] o.e.fourchak.aop.WithRedissonLockAspect : LOCK 해제 ID:
➡️ 쿠폰 수량이 중복으로 읽히는 현상 확인
@Transactional과 @WithRedissonLock이 모두 AOP 기반, 그러나 @Transactional이 먼저 걸림REQUIRES_NEW는 기존 트랜잭션과 관계없이 새로운 트랜잭션 생성남은 쿠폰 수량: 0
유저 쿠폰 개수: 1000
성공: 1000 / 실패: 200