[TIL] Redis Lettuce 동시성 제어

YJin·2025년 5월 19일

[내배캠 Spring 6기_TIL]

목록 보기
37/56
post-thumbnail

Redis Lettuce

Redis의 Lettuce는 Java에서 Redis에 비동기로 접근할 수 있게 해주는 Redis 클라이언트 라이브러리 이다.

  • 동기, 비동기 모두 지원
  • non-blocking 으로 처리 가능하며 확장성이 뛰어남
  • 스프링부트 2.x 이상: 기본 Redis 클라이언트 Lettuce

분산락

여러 서버나 쓰레드가 공용 자원에 동시에 접근하지 못하도록 막는 기법.

Java의 synchronized나 DB의 트랜잭션 락 등으로 동기화를 할 수 있지만 분산 환경에서는 프로세스 간 메모리나 자원 공유가 되지 않기 때문에, 자원에 대한 락을 분산된 환경에서도 일관되게 제어할 수 있는 방법이 필요하다.

분산락의 원칙

1. 선점: 먼저 락을 획득한 서버(또는 프로세스)만 자원에 접근할 수 있음.

2. TTL(만료 시간): 락이 무한히 유지되지 않도록 자동 해제 타이머 부여.

3.식별자(Token): 락 해제 시, 자신이 획득한 락만 해제할 수 있도록 식별값 사용.

4.원자성 보장: 락 획득과 확인은 반드시 원자적으로 처리해야 함.

Redis를 락 구현에 사용하는 이유

  • 빠른 속도 : 모든 연산이 메모리에서 동작하므로 락 획득/해제 속도가 매우 빠름.
  • 단일 스레드 → 원자성 보장
  • 원격 데이터 저장소, 분산 환경에 적합

💡 Redis Lock 을 구현할 때 고려해야할 것
1) Lock 획득에 실패했을 때 어떻게 할 것인가?
2) Redis 를 이용해 Lock 을 구현한 이유는 무엇일까?
3) Redis 에서 Lock 을 걸때 Key 로 어떤 값을 사용했고, 왜 해당 Key 를 이용해 Lock 을 만들었을까?



Lettuce 기반 Redis 분산 락 구현

구현 개요

  • Redis 명령어: SETNX를 활용한 락 획득
  • 락 구조: key-value로 잠금 상태 표시, DEL로 해제
  • 락 key 구성: coupon:<couponId> 형태 (자원 기준 식별자)
  • 락 획득 실패 시: 일정 시간 대기 후 재시도 (스핀락)
  • 최종 구조: AOP + @WithLock 어노테이션 기반 분산락 처리



문제 상황

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

테스트 시나리오

쿠폰 수량: 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

RedisConfig

@Configuration
@Profile({"redis"})
public class RedisCacheConfig {

    @Value("${spring.data.redis.host}")
    private String redisHost;

    @Value("${spring.data.redis.port}")
    private int redisPort;
    
    @Value("${spring.redis.password}")
    private String redisPassword;

    @Bean
    public RedisConnectionFactory redisConnectionFactory() {
        RedisStandaloneConfiguration redisStandaloneConfiguration = new RedisStandaloneConfiguration(
            redisHost, redisPort);
        return new LettuceConnectionFactory(redisStandaloneConfiguration);
    }

    @Bean
    public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory connectionFactory) {
        RedisTemplate<String, Object> template = new RedisTemplate<>();
        template.setConnectionFactory(connectionFactory);

        // 직렬화 설정
        template.setKeySerializer(new StringRedisSerializer());
        template.setValueSerializer(new GenericJackson2JsonRedisSerializer());
        template.setHashKeySerializer(new StringRedisSerializer());
        template.setHashValueSerializer(new GenericJackson2JsonRedisSerializer());

        template.afterPropertiesSet();
        return template;
    }

}



RedisLockRepository

@Slf4j
@Repository
@RequiredArgsConstructor
public class RedisLockRepository {

    private final RedisTemplate<String, String> redisTemplate;


    public Boolean lock(Object key) {
    	String lockId = UUID.randomUUID().toString();
        return redisTemplate
            .opsForValue()
            .setIfAbsent(key.toString(), lockId, Duration.ofMillis(3000));  // 3초 동안 락 유효
    }

    public void lockLimitTry(String key, int maxRetry) {
        int retry = 0;

        while (!lock(key)) {
            log.info("LOCK 획득 실패");
            if (++retry == maxRetry) {
                throw new BaseException(ExceptionCode.LOCK_EXCEPTION);
            }

            try {
                Thread.sleep(100);
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
        }
    }

    public void unlock(Object key) {
        String luaScript =
            "if redis.call('GET', KEYS[1]) == ARGV[1] then " +
            "   return redis.call('DEL', KEYS[1]) " +
            "else " +
            "   return 0 " +
            "end";

        DefaultRedisScript<Long> redisScript = new DefaultRedisScript<>();
        redisScript.setScriptText(luaScript);
        redisScript.setResultType(Long.class);

        Long result = redisTemplate.execute(
            redisScript,
            Collections.singletonList(key), // KEYS[1]
            myUniqueId                     // ARGV[1]
        );

        if (result != null && result == 1L) {
            log.info("LOCK 해제 성공");
        } else {
            log.warn("LOCK 해제 실패: 락 주인 불일치");
        }
    }
}

* lua 스크립트는 추후 보충하였음.

  • lock : 키-값 ( key.toString()-lockId ) 으로 락 등록. TTL은 3초
  • lockLimitTry : 최대 maxRetry만큼 락 획득 시도. 실패 시 100ms만큼 대기 후 재시도. 최대 시도 횟수를 초과하면 예외 처리.
  • unlock : lua 스크립트로 락 해제 제어
  • lua 스크립트: 자신이 소유한 락인지 확인 후 해제 (lockId 비교)
    • 내가 만든 락이 아닌데 해제하려고 했을 때 (ex. TTL 만료 후 다른 스레드가 재선점)



구현

1️⃣ LockService 방식

@Slf4j
@Component
@RequiredArgsConstructor
public class LockService {

    private final RedisLockRepository redisLockRepository;

    // 스핀락 방식
    public void executeWithLock(String key, Runnable logic) throws InterruptedException {
        // Lock 획득
        while (!redisLockRepository.lock(key)) {
            log.info("LOCK 획득 실패");
            Thread.sleep(100);      // 100ms 대기 후 재시도
        }

        // 락 획득에 성공하면 유저 쿠폰 발급
        log.info("LOCK 획득 ID: " + key);
        try {
            logic.run();
        } finally {
            // 로직이 모두 수행되었다면 Lock 해제
            redisLockRepository.unlock(key);
            log.info("LOCK 해제 ID: " + key);
        }
    }
}
  • Redis 기반 분산락 + 락 획득 시까지 반복 대기(스핀 락)
  • 각 도메인 서비스에서 LockService를 주입받아 사용

예시

public class UserCouponService {
	@Lazy
    @Autowired
    private UserCouponService self;

	public void issueCouponLettuceWithService(User user, Long couponId) {
        String key = "coupon:" + couponId;
        try {
            lockService.executeWithLock(key, () -> {
                self.issueCoupon(user, couponId);
            });
        } catch (InterruptedException e) {
            throw new BaseException(ExceptionCode.INTERNAL_SERVER_ERROR);
        }
    }
}
  • @Lazy를 사용하여 순환참조를 방지하고 @Transactional이 적용된 서비스 자기 자신의 프록시 객체를 주입 받는다.
  • Spring AOP는 프록시를 통해 외부에서 호출되는 메서드에만 적용되기 때문에, 내부에서 this.method()로 호출하면 트랜잭션이 동작하지 않는다.



2️⃣ AOP 방식

어노테이션

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface WithLettuceLock {

}
  • 해당 어노테이션이 붙은 메소드를 타겟으로 LettuceLock AOP를 적용한다.



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

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



WithLettuceLockAspect

@Slf4j
@Aspect
@Component
@RequiredArgsConstructor
public class WithLettuceLockAspect {

    private final RedisLockRepository redisLockRepository;

    @Around("@annotation(org.example...annotation.WithLettuceLock)")
    public Object 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;

        redisLockRepository.lockLimitTry(key, 5);

        // 락 획득에 성공하면 로직 실행
        log.info("LOCK 획득 ID: " + key);
        try {
            Object result = joinPoint.proceed();	// 비즈니스 로직 실행
            
            TransactionSynchronizationManager.registerSynchronization(
                new TransactionSynchronizationAdapter() {
                    @Override
                    public void afterCommit() {
                        redisLockRepository.unlock(key); // 트랜잭션 커밋 이후에 락 해제
                        log.info("LOCK 해제 ID: " + key);
                    }
                });

            return result;
        } 
    }
}
  • @WithLettuceLock이 걸린 메소드 실행 전후로 포인트컷 지정
  • 메소드 이름 + lockKeyValue를 합쳐 락키 사용
    • lockKeyValue@LockKey 어노테이션을 활용해 동적으로 추출
  • 최대 5번까지 락 획득 시도 (획득 실패 시 예외 발생)
  • TransactionSynchronizationManager.registerSynchronization: 현재 트랜잭션에 후처리 작업 등록
  • afterCommit()을 통해 트랜잭션 커밋 이후 안전하게 락을 해제하여 정합성 보장



예시

    @Transactional
    @WithLettuceLock
    public void issueCouponLettuceWithAOP(User user, @LockKey Long couponId) {
        ...
    }



트러블 슈팅

문제 1: 성공 횟수와 유저 쿠폰 수량 불일치

상황

  • 테스트 결과: 성공 횟수와 유저 쿠폰 수량 불일치
  • 로그에 여러 스레드가 동시에 락을 획득한 것으로 표시

분석

  • 테스트 전 @BeforeEach에서 DB 초기화 누락됨

해결

  • deleteAll()로 명시 초기화 추가

문제 2: 락 키 설계 오류

상황

String key = user.getId().toString() + UUID.randomUUID();
  • UUID를 포함한 key로 인해 모든 요청이 서로 다른 key를 사용
  • 결과적으로 모든 스레드가 개별 락을 획득하여 동시성 제어 실패

분석

  • 분산락에서 key는 공유 자원(락을 걸 대상)의 식별하기 위한 값이어야 함.
  • 그러나 위의 코드처럼 요청 주체(userId + UUID)를 기준으로 key를 생성하면 공유 자원을 기준으로 락을 설정하지 못함

즉, 락의 의미가 사라지고, 경쟁 상태(race condition)가 그대로 발생함

해결

  • userId + UUIDcoupon:<couponId>로 key 수정
  • 공유 자원 단위로 동일한 key를 사용하도록 변경하여 같은 쿠폰 자원에 대해 하나의 락으로 직렬화 처리가 가능하게 함

문제 3: 락 서비스로 리팩토링

상황

  • 동시성 제어가 필요한 메서드마다
    issueCouponWithLock(), cancelCouponWithLock() 등 별도 메서드를 반복 정의해야 함
  • 락 획득/해제 로직이 서비스마다 중복되어 중복 코드 증가

분석

  • 템플릿 메소드 패턴 도입
    ➡️ LockService.executeWithLock(String key, Runnable logic) 구조로 리팩토링
    ➡️ 락 획득/해제는 공통 처리하고, 실행할 로직은 함수형 인자로 주입
lockService.executeWithLock("coupon:" + couponId, () -> self.issueCoupon(user, couponId));
  • 결과적으로 비즈니스 로직은 서비스에 그대로 두고, 락 제어 흐름만 LockService에 위임하여 관심사 분리

추가 문제

  • Runnable로 넘긴 self.issueCoupon() 내부에 @Transactional이 선언되어 있음
  • 그러나 자기 호출 this.issueCoupon()은 AOP 프록시를 우회하므로 트랜잭션 미적용 문제 발생

해결

  • @Lazy + self 주입을 통해 프록시 객체를 통한 자기 자신 호출 유도
@Lazy
@Autowired
private UserCouponService self;
  • 이렇게 하면 self.issueCoupon() 호출 시 프록시를 경유하여 @Transactional 정상 적용

문제 4: AOP 기반 분산락 적용 시 쿠폰 수량 불일치

상황

[TEST END] 남은 쿠폰 수량: 345
유저 쿠폰 개수: 1200
성공 횟수: 1200
실패 횟수: 0
2025-05-21T19:35:28.060+09:00  INFO 24704 --- [FourChak] [ool-3-thread-10] o.e.fourchak.aop.WithRedisLockAspect     : LOCK 해제 ID: issueCoupon27
2025-05-21T19:35:28.060+09:00  INFO 24704 --- [FourChak] [pool-3-thread-4] o.e.fourchak.aop.WithRedisLockAspect     : LOCK 획득 ID: issueCoupon27
2025-05-21T19:35:28.066+09:00  INFO 24704 --- [FourChak] [pool-3-thread-3] o.e.fourchak.aop.WithRedisLockAspect     : LOCK 획득 실패
2025-05-21T19:35:28.066+09:00  INFO 24704 --- [FourChak] [pool-3-thread-1] o.e.fourchak.aop.WithRedisLockAspect     : LOCK 획득 실패
2025-05-21T19:35:28.066+09:00  INFO 24704 --- [FourChak] [pool-3-thread-7] o.e.fourchak.aop.WithRedisLockAspect     : LOCK 획득 실패
2025-05-21T19:35:28.074+09:00  INFO 24704 --- [FourChak] [pool-3-thread-4] o.e.fourchak.aop.WithRedisLockAspect     : LOCK 해제 ID: issueCoupon27
2025-05-21T19:35:28.074+09:00  INFO 24704 --- [FourChak] [ool-3-thread-10] o.e.fourchak.aop.WithRedisLockAspect     : LOCK 획득 ID: issueCoupon27
  • 락 획득과 해제는 로그상 제대로 동작하나 쿠폰 수량이 실제 성공 횟수와 맞지 않음
  • 락 동작은 정상적이나 동시에 여러 스레드가 동일한 쿠폰 수량을 읽고 처리함 ➡️ 쿠폰 수량 초과 발급 현상 발생

분석

finally {
            // 로직이 모두 수행되었다면 Lock 해제
            redisLockRepository.unlock(key);
            log.info("LOCK 해제 ID: " + key);
        }
  • 락 해제를 finally 블록에서 수행하며, 비즈니스 로직 종료만 기준으로 락 해제
  • 하지만 이 시점은 트랜잭션 커밋보다 먼저임
  • 결과적으로 트랜잭션 커밋 전에 락이 풀리고, 다음 스레드가 동일한 수량을 조회하여 처리하는 race condition 발생

해결

  • TransactionSynchronizationManager.registerSynchronization() 이용
  • 트랜잭션 커밋 이후 afterCommit()에서 락 해제 처리

문제 5: 락 재시도 무한 대기 문제

상황

  • 쿠폰 수량이 모두 소진된 이후에도 스레드가 계속 락을 요청하며 무한 루프 발생

분석

  • 락 획득 실패 시 while(true) 루프 사용으로 테스트 코드가 무한히 실행됨

해결

  • 재시도 횟수 제한 (maxRetry) 도입
  • 일정 횟수 이상 실패 시 예외 발생(LOCK_EXCEPTION)하도록 처리

데드락과 무한 대기는 서로 다른 개념

  • 데드락: 둘 이상의 스레드가 서로의 자원을 기다리며 영원히 멈추는 상태 (교착 상태)
  • 무한 대기: 장시간 대기로 인한 시스템 리소스 고갈을 막기 위한 무한 대기 방지 전략

기타: 트랜잭션 격리 수준 적용 시도

상황

  • 트랜잭션이 커밋되기 전에 락이 먼저 해제되면서, 다른 스레드가 커밋 이전의 데이터를 읽고 작업을 수행하는 문제 발생

시도

  • @Transactional(isolation = Isolation.READ_COMMITTED) 설정을 통해 다른 트랜잭션의 커밋된 데이터만 읽도록 격리 수준 조정 시도

내가 기대한 것
1. A 스레드가 락 획득 → 쿠폰 수량 차감(401→400) → 트랜잭션 커밋 중
2. 락 해제 (커밋 직전 또는 직후)
3. B 스레드가 락을 획득
4. READ_COMMITTED 격리 수준이니까
→ 아직 A의 커밋이 끝나지 않았으므로 B는 수량을 못 읽음
→ A 커밋이 끝난 뒤 B가 400을 읽고 정확하게 처리함

➡️ 격리 수준이 커밋 전 데이터를 막아주니 안전할 거라고 생각

결론

실제 DB 동작
1. A 스레드가 락 획득 → 쿠폰 수량 차감 → 트랜잭션 커밋 중 (아직 DB 반영 안됨)
2. 락이 먼저 해제됨 → B 스레드가 락을 획득하고 즉시 실행 시작
3. B는 가장 마지막으로 커밋된 값인 401을 읽음
→ A의 트랜잭션은 아직 커밋 완료 전이므로, B는 A의 변경사항을 볼 수 없음
4. B도 401을 기준으로 쿠폰을 발급 → 결과적으로 중복 차감 발생

➡️ READ_COMMITTED은 커밋된 값만 읽는 건 맞지만, 락이 먼저 풀리면 너무 일찍 실행이 시작돼서 문제가 생김

  • 격리 수준 변경은 다른 트랜잭션의 커밋된 데이터만 읽음을 보장할 뿐
  • READ_COMMITTED 격리 수준에서는 커밋된 수량만 읽을 수 있지만, 그 시점에 커밋이 안 됐으면 이전 값을 읽게 됨 ➡️ 충돌
  • 커밋되지 않은 변경사항은 무시하고 그 이전에 커밋된 값만 보여준다는 것
  • =커밋된 버전 중 가장 최신 것만 보여줘 (=커밋 안된 건 알 바 아님)
  • Redis 락 자체는 DB 격리 수준보다는 락 해제 시점, 트랜잭션 동기화에 영향을 받음
  • 격리 수준 조정보다는 afterCommit() 사용이 결정적

A가 트랜잭션 도중에 락을 먼저 해제하고, 아직 커밋이 끝나지 않은 상태에서 B가 락을 선점하고 트랜잭션을 시작하면

➡️ B는 READ_COMMITTED에 따라 커밋 전이므로 이전 값(예: 401)을 읽음
➡️ 그 값을 기반으로 처리하게 되므로 race Condition 발생
➡️ 결과적으로 중복 처리, 수량 차감 오류 발생 가능


참고

https://velog.io/@gale4739/Spring-Boot-Redis-%EC%A0%81%EC%9A%A9%EA%B8%B0-With-lettuce
https://sol-b.tistory.com/95

profile
백엔드 개발도 락이다

0개의 댓글