[TIL] Redis Redisson 동시성 제어

YJin·2025년 5월 22일

[내배캠 Spring 6기_TIL]

목록 보기
39/56

💡 Redisson 을 이용한 Redis Lock 개발

  • Lettuce 가 아니라 Redisson 을 사용한 이유를 설명할 수 있어야한다.

Redis Redisson

Redisson이란?

  • Redis의 고급 클라이언트 라이브러리로, Java에서 분산 락, 세마포어, 블루밍 필터 등 다양한 구조를 제공
  • 내부적으로 Lua 스크립트를 사용하여 안전한 락 획득/해제를 구현함
  • Lettuce와 달리 분산 락 기능이 내장되어 있어 직접 구현하지 않아도 됨
  • 주로 RLock을 사용하며, tryLock() 등 다양한 락 API를 지원함


Lettuce 대신 Redisson을 사용한 이유

  • Lettuce는 기본 Redis 클라이언트로 빠르고 가볍지만 분산 락은 직접 구현해야 함
  • Redisson은 분산 락 기능을 고급 API로 추상화해줘서, 락 관련 코드가 간결하고 안정성이 높음
  • 스핀락이 아닌 watchdog 기반 자동 연장도 가능 (기본 TTL 30초)


Redisson 기반 Redis 분산 락 구현

문제 상황

제한된 수량의 쿠폰을 발급 받으려 다수의 사용자가 몰려드는 상황.

테스트 시나리오

쿠폰 수량: 1000개
발급 받으려는 유저: 1200명

기대 결과

발급된 쿠폰 수량(원래 수량 - 남은 수량) = 유저에게 발급된 쿠폰 수량 (발급된 userCoupon 개수)



초기 설정

build.gradle

implementation 'org.springframework.boot:spring-boot-starter-data-redis'

application.properties

# 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;
}
  • 해당 어노테이션이 붙은 메소드를 타겟으로 LedissonLock AOP를 적용한다.


@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
public @interface LockKey {

}
  • 락키를 지정하는 어노테이션



RedissonConfig

@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);
    }
}

WithRedissonLockAspect

@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번 시도
    • 서비스의 성능이나 안정성에 따라 조정



AopForTransaction

@Component
public class AopForTransaction {

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public Object proceed(final ProceedingJoinPoint joinPoint) throws Throwable {
        return joinPoint.proceed();
    }
}
  • 서비스 로직을 새로운 트랜잭션(propagation = Propagation.REQUIRES_NEW)으로 실행 시키기 위한 컴포넌트



트러블 슈팅

문제 1: 유저 쿠폰 수량이 중복 발행됨

상황

  • 발급 제한 수량이 1000개인 쿠폰 테스트에서 1200건이 모두 발급되는 문제 발생
  • 동시에 여러 스레드가 쿠폰 수량을 조회 → 동일 수량(예: 568개)을 기준으로 중복 발급
  • 여러 스레드가 동일한 수량을 읽음 → 락이 제대로 작동하지 않음

로그

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이 먼저 걸림
  • 이로 인해 락을 획득하기 전에 트랜잭션이 시작되어 DB를 조회하게 됨
  • 결과적으로 락을 획득하지 않은 여러 트랜잭션이 동일한 쿠폰 수량을 읽음

해결

  • 락을 감싸는 AOP 메소드 안에서 강제적으로 트랜잭션을 새로 시작하도록 분리
  • REQUIRES_NEW는 기존 트랜잭션과 관계없이 새로운 트랜잭션 생성
  • 결과적으로 락을 먼저 획득하고 나서 트랜잭션 시작

결과

남은 쿠폰 수량: 0
유저 쿠폰 개수: 1000
성공: 1000 / 실패: 200
  • 락이 정확히 동작하면서 정상적으로 쿠폰이 발급됨

추가 분석

🔗 Spring AOP 동시성 제어 문제

  • 정확한 원인 분석은 다른 포스트에 정리해두었음.




참고

profile
백엔드 개발도 락이다

0개의 댓글