
Redis의 Lettuce는 Java에서 Redis에 비동기로 접근할 수 있게 해주는 Redis 클라이언트 라이브러리 이다.
여러 서버나 쓰레드가 공용 자원에 동시에 접근하지 못하도록 막는 기법.
Java의 synchronized나 DB의 트랜잭션 락 등으로 동기화를 할 수 있지만 분산 환경에서는 프로세스 간 메모리나 자원 공유가 되지 않기 때문에, 자원에 대한 락을 분산된 환경에서도 일관되게 제어할 수 있는 방법이 필요하다.
1. 선점: 먼저 락을 획득한 서버(또는 프로세스)만 자원에 접근할 수 있음.
2. TTL(만료 시간): 락이 무한히 유지되지 않도록 자동 해제 타이머 부여.
3.식별자(Token): 락 해제 시, 자신이 획득한 락만 해제할 수 있도록 식별값 사용.
4.원자성 보장: 락 획득과 확인은 반드시 원자적으로 처리해야 함.
💡 Redis Lock 을 구현할 때 고려해야할 것
1) Lock 획득에 실패했을 때 어떻게 할 것인가?
2) Redis 를 이용해 Lock 을 구현한 이유는 무엇일까?
3) Redis 에서 Lock 을 걸때 Key 로 어떤 값을 사용했고, 왜 해당 Key 를 이용해 Lock 을 만들었을까?
SETNX를 활용한 락 획득DEL로 해제coupon:<couponId> 형태 (자원 기준 식별자)AOP + @WithLock 어노테이션 기반 분산락 처리제한된 수량의 쿠폰을 발급 받으려 다수의 사용자가 몰려드는 상황.
쿠폰 수량: 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
@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;
}
}
@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 스크립트로 락 해제 제어lockId 비교)@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);
}
}
}
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이 적용된 서비스 자기 자신의 프록시 객체를 주입 받는다.this.method()로 호출하면 트랜잭션이 동작하지 않는다.@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface WithLettuceLock {
}
@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
public @interface LockKey {
}
@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 어노테이션을 활용해 동적으로 추출TransactionSynchronizationManager.registerSynchronization: 현재 트랜잭션에 후처리 작업 등록afterCommit()을 통해 트랜잭션 커밋 이후 안전하게 락을 해제하여 정합성 보장 @Transactional
@WithLettuceLock
public void issueCouponLettuceWithAOP(User user, @LockKey Long couponId) {
...
}
@BeforeEach에서 DB 초기화 누락됨deleteAll()로 명시 초기화 추가String key = user.getId().toString() + UUID.randomUUID();
즉, 락의 의미가 사라지고, 경쟁 상태(race condition)가 그대로 발생함
userId + UUID → coupon:<couponId>로 key 수정
issueCouponWithLock(), cancelCouponWithLock() 등 별도 메서드를 반복 정의해야 함LockService.executeWithLock(String key, Runnable logic) 구조로 리팩토링lockService.executeWithLock("coupon:" + couponId, () -> self.issueCoupon(user, couponId));
LockService에 위임하여 관심사 분리self.issueCoupon() 내부에 @Transactional이 선언되어 있음this.issueCoupon()은 AOP 프록시를 우회하므로 트랜잭션 미적용 문제 발생@Lazy + self 주입을 통해 프록시 객체를 통한 자기 자신 호출 유도@Lazy
@Autowired
private UserCouponService self;
self.issueCoupon() 호출 시 프록시를 경유하여 @Transactional 정상 적용[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 블록에서 수행하며, 비즈니스 로직 종료만 기준으로 락 해제TransactionSynchronizationManager.registerSynchronization() 이용afterCommit()에서 락 해제 처리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 격리 수준에서는 커밋된 수량만 읽을 수 있지만, 그 시점에 커밋이 안 됐으면 이전 값을 읽게 됨 ➡️ 충돌afterCommit() 사용이 결정적➡️ 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