
이번엔 Redis 기반의 Java 분산 객체 및 서비스 제공 라이브러리인 Redisson에 대해서 알아보겠습니다.
이 라이브러리는 분산락 이외에도 분산 컬렉션이나 분산 세마포어 등 다양한 기능을 제공합니다.
또한 실제 운영 환경에서는 앞서 LockManager를 직접 구현해서 작업했던 것보다 훨씬 더 많은 기능을 제공해야 합니다. 그러니 훨씬 복잡해지죠.
Redisson은 이러한 복잡한 부분들을 처리해줄 수 있는 검증된 라이브러리입니다. 마치 직접 HTTP 통신 코드를 작성하는 것보다 RestTemplate이나 WebClient를 사용하는 것이 더 안전한 것처럼 말이죠😁
Redis를 활용한 직접구현방식과 Redisson을 활용한 방식은 다음과 같은 차이점이 있습니다.
| 기능 | 직접 구현 | Redisson |
|---|---|---|
| 기본 락 획득/해제 | ✅ 가능 | ✅ 가능 |
| 락 재진입 (같은 스레드가 중복 획득) | ❌ 추가 구현 필요 | ✅ 기본 지원 |
| 자동 락 연장 (Watch Dog) | ❌ 추가 구현 필요 | ✅ 기본 지원 |
| 공정한 락 (순서 보장) | ❌ 추가 구현 필요 | ✅ 기본 지원 |
| 다중 Redis 인스턴스 지원 | ❌ 추가 구현 필요 | ✅ Redlock 지원 |
| 비동기/리액티브 | ❌ 추가 구현 필요 | ✅ 기본 지원 |
| 테스트 검증 | ⚠️ 직접 검증 필요 | ✅ 수많은 프로덕션 환경에서 검증됨 |
Java 환경에서 보다 쉽게 분산락을 사용할 수 있도록 만들어주는 Redisson을 어떻게 사용하는지 한 번 알아보겠습니다.
일단 Redisson은 환경 설정이 필요합니다. redis 설정과 함께 redisson은 별도로 설정을 하게 됩니다.
# application.yml
spring:
redis:
host: localhost
port: 6379
# Redisson 설정 (별도 파일로 분리 가능)
redisson:
single-server-config:
address: 'redis://localhost:6379'
connection-pool-size: 10
connection-minimum-idle-size: 5
이후 RedissonConfig를 통해 코드레벨에서 설정할 수 있습니다.
package com.example.config;
import org.redisson.Redisson;
import org.redisson.api.RedissonClient;
import org.redisson.config.Config;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class RedissonConfig {
@Value("${spring.redis.host}")
private String redisHost;
@Value("${spring.redis.port}")
private int redisPort;
@Bean
public RedissonClient redissonClient() {
Config config = new Config();
config.useSingleServer()
.setAddress("redis://" + redisHost + ":" + redisPort)
.setConnectionPoolSize(10)
.setConnectionMinimumIdleSize(5);
return Redisson.create(config);
}
}
기본적으로 분산락 이라고 하는 것은 다음의 네 단계를 따릅니다.

코드레벨에서는 어떻게 Redisson을 활용할 수 있는지 알아보도록 하겠습니다.
package com.example.service;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.springframework.stereotype.Service;
import java.util.concurrent.TimeUnit;
@Slf4j
@Service
@RequiredArgsConstructor
public class PointServiceWithRedisson {
private final RedissonClient redissonClient;
private final PointRepository pointRepository;
/**
* Redisson을 이용한 포인트 차감
*/
public void deductPoints(Long userId, int amount) {
// 1. 락 객체 생성
String lockKey = "user:" + userId + ":point:lock";
RLock lock = redissonClient.getLock(lockKey);
try {
// 2. 락 획득 시도
// waitTime: 락 획득을 위해 대기하는 최대 시간
// leaseTime: 락을 보유하는 최대 시간 (자동 해제)
boolean acquired = lock.tryLock(
5, // waitTime: 5초 동안 락 획득 시도
30, // leaseTime: 30초 후 자동 해제
TimeUnit.SECONDS
);
if (!acquired) {
throw new RuntimeException("락 획득 실패: " + lockKey);
}
log.info("락 획득 성공: {}", lockKey);
// 3. 비즈니스 로직 실행
deductPointsInternal(userId, amount);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new RuntimeException("락 획득 중 인터럽트 발생", e);
} finally {
// 4. 락 해제 (자신이 보유한 경우에만)
if (lock.isHeldByCurrentThread()) {
lock.unlock();
log.info("락 해제 완료: {}", lockKey);
}
}
}
private void deductPointsInternal(Long userId, int amount) {
Point point = pointRepository.findByUserId(userId)
.orElseThrow(() -> new RuntimeException("포인트 정보 없음"));
if (point.getAmount() < amount) {
throw new RuntimeException("잔액 부족");
}
point.deduct(amount);
pointRepository.save(point);
}
}
위와 같이 사용하면 아주 쉽게 락을 획득하고 비즈니스 로직을 안정적으로 수행할 수 있습니다.
Redisson의 가장 강력한 기능 중 하나는 바로 Watch Dog입니다. leaseTime을 지정하지 않으면 락을 보유하는 동안 자동으로 락 시간을 연장해주죠.
Watch Dog의 가장 큰 역할은 자동으로 락 시간을 연장해준다는 것입니다. TTL이 너무 짧으면 작업 중 락이 만료되고, 너무 길면 장애시 복구가 느리게 되는데 Watch Dog의 경우 그 문제를 완벽하게 해결할 수 있습니다.
Watch Dog의 기본적인 설정은 아래와 같습니다.
| 설정 | 기본값 | 설명 |
|---|---|---|
| lockWatchdogTimeout | 30초 | 락의 기본 TTL (락 획득 시 설정되는 만료 시간) |
| 연장 주기 | TTL의 1/3 (10초) | Watch Dog이 락 상태를 확인하는 주기 |
| 연장 조건 | TTL < 2/3 (20초 미만) | 남은 TTL이 이 값보다 작으면 30초로 연장 |
위 속성을 가지고 만약 Watch Dog이 작업 진행을 계속 지켜보다 아직 작업이 진행이 되고 있다면 자동으로 락 시간을 연장해주는 역할을 하죠.
그럼 Watch Dog이 어떤 식으로 시간을 연장시켜주는걸까요? 동작 과정에 대해 도식화하여 한 번 살펴보겠습니다.

Watch Dog의 핵심 포인트는 아래와 같습니다.

Watch Dog은 서버가 정상적으로 동작하면 지속적으로 락을 연장하게 됩니다. 하지만 서버 장애 시엔 Watch Dog도 멈추기 때문에 락은 30초 후 자동으로 해제되게 됩니다.
Java 환경에서 Watch Dog을 활성화 하는 방법은 다음과 같습니다.
/**
* Watch Dog 활성화 방법: leaseTime을 지정하지 않음
*/
public void deductPointsWithWatchDog(Long userId, int amount) {
String lockKey = "user:" + userId + ":point:lock";
RLock lock = redissonClient.getLock(lockKey);
try {
// leaseTime을 -1로 설정하면 Watch Dog 활성화
// 락을 명시적으로 해제할 때까지 자동 연장
boolean acquired = lock.tryLock(5, -1, TimeUnit.SECONDS);
if (!acquired) {
throw new RuntimeException("락 획득 실패");
}
// 아무리 오래 걸리는 작업도 안전하게 처리
processLongRunningTask(userId, amount);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new RuntimeException("인터럽트 발생", e);
} finally {
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
이번에는 실무에서 사용할 수 있는 분산락 어노테이션 구현 방법에 대해 스터디 해보겠습니다.
실무에서 매번 락 코드를 작성하는 것은 매우 번거로운 일이죠. 그래서 AOP와 커스텀 어노테이션을 통하여 손쉽게 사용할 수 있는 환경을 구축할 수도 있습니다.
AOP를 이용한 분산락을 사용하는 것과 사용하지 않는 것의 차이점은 아래와 같습니다.

SpEL(Spring Expression Language)를 통해 표현하며 아래와 같이 사용하게 됩니다.
// SpEL 예시
@DistributedLock(key = "'user:' + #userId + ':lock'")
public void process(Long userId) { ... }
// userId가 123이면 → 락 키는 "user:123:lock"
// userId가 456이면 → 락 키는 "user:456:lock"
이제 실제로 사용되는 @DistributedLock 어노테이션 구현 방법에 대해 학습해보겠습니다.
package com.example.lock.annotation;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import java.util.concurrent.TimeUnit;
/**
* 분산락을 적용하는 어노테이션
*
* 사용 예시:
* @DistributedLock(key = "'user:' + #userId + ':point:lock'")
* public void deductPoints(Long userId, int amount) { ... }
*/
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface DistributedLock {
/**
* 락 키 (SpEL 표현식 지원)
*
* 예시:
* - 고정 키: "payment-lock"
* - 동적 키: "'user:' + #userId + ':lock'"
* - 객체 필드: "#request.orderId"
*/
String key();
/**
* 락 획득을 위해 대기하는 최대 시간
*/
long waitTime() default 5;
/**
* 락을 보유하는 최대 시간
* -1로 설정하면 Watch Dog 활성화 (작업 완료까지 자동 연장)
*/
long leaseTime() default 30;
/**
* 시간 단위
*/
TimeUnit timeUnit() default TimeUnit.SECONDS;
}
package com.example.lock.aspect;
import com.example.lock.annotation.DistributedLock;
import com.example.lock.exception.LockAcquisitionException;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.reflect.MethodSignature;
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.springframework.core.DefaultParameterNameDiscoverer;
import org.springframework.core.annotation.Order;
import org.springframework.expression.EvaluationContext;
import org.springframework.expression.ExpressionParser;
import org.springframework.expression.spel.standard.SpelExpressionParser;
import org.springframework.expression.spel.support.StandardEvaluationContext;
import org.springframework.stereotype.Component;
import java.lang.reflect.Method;
/**
* @DistributedLock 어노테이션을 처리하는 AOP Aspect
*/
@Slf4j
@Aspect
@Component
@RequiredArgsConstructor
@Order(1) // 트랜잭션 AOP보다 먼저 실행되도록 설정
public class DistributedLockAspect {
private final RedissonClient redissonClient;
private final ExpressionParser parser = new SpelExpressionParser();
private final DefaultParameterNameDiscoverer nameDiscoverer =
new DefaultParameterNameDiscoverer();
@Around("@annotation(distributedLock)")
public Object around(
ProceedingJoinPoint joinPoint,
DistributedLock distributedLock) throws Throwable {
// 1. 락 키 파싱 (SpEL 표현식 처리)
String lockKey = parseKey(joinPoint, distributedLock.key());
// 2. 락 객체 생성
RLock lock = redissonClient.getLock(lockKey);
boolean acquired = false;
try {
// 3. 락 획득 시도
acquired = lock.tryLock(
distributedLock.waitTime(),
distributedLock.leaseTime(),
distributedLock.timeUnit()
);
if (!acquired) {
throw new LockAcquisitionException(
"락 획득 실패: " + lockKey
);
}
log.debug("분산락 획득 성공: key={}", lockKey);
// 4. 실제 메서드 실행
return joinPoint.proceed();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new LockAcquisitionException("락 획득 중 인터럽트 발생", e);
} finally {
// 5. 락 해제
if (acquired && lock.isHeldByCurrentThread()) {
lock.unlock();
log.debug("분산락 해제 완료: key={}", lockKey);
}
}
}
/**
* SpEL 표현식을 파싱하여 실제 락 키를 생성합니다.
*/
private String parseKey(ProceedingJoinPoint joinPoint, String keyExpression) {
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
Method method = signature.getMethod();
Object[] args = joinPoint.getArgs();
// 파라미터 이름 추출
String[] parameterNames = nameDiscoverer.getParameterNames(method);
// SpEL 컨텍스트 생성
EvaluationContext context = new StandardEvaluationContext();
if (parameterNames != null) {
for (int i = 0; i < parameterNames.length; i++) {
context.setVariable(parameterNames[i], args[i]);
}
}
// SpEL 표현식 파싱 및 실행
return parser.parseExpression(keyExpression)
.getValue(context, String.class);
}
}
package com.example.service;
import com.example.lock.annotation.DistributedLock;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Slf4j
@Service
@RequiredArgsConstructor
public class PointService {
private final PointRepository pointRepository;
/**
* 포인트 차감 - 사용자별 분산락 적용
*/
@DistributedLock(
key = "'user:' + #userId + ':point:lock'",
waitTime = 5,
leaseTime = 30
)
@Transactional
public void deductPoints(Long userId, int amount) {
Point point = pointRepository.findByUserId(userId)
.orElseThrow(() -> new RuntimeException("포인트 정보 없음"));
if (point.getAmount() < amount) {
throw new InsufficientBalanceException("잔액 부족");
}
point.deduct(amount);
pointRepository.save(point);
log.info("포인트 차감 완료: userId={}, amount={}", userId, amount);
}
/**
* 주문 결제 처리 - 주문별 분산락 적용
*/
@DistributedLock(
key = "'order:' + #orderId + ':payment:lock'",
waitTime = 10,
leaseTime = 60
)
@Transactional
public void processPayment(Long orderId, PaymentRequest request) {
// 결제 처리 로직
log.info("결제 처리: orderId={}", orderId);
}
/**
* 재고 차감 - 상품별 분산락 + Watch Dog 활성화
*/
@DistributedLock(
key = "'product:' + #productId + ':stock:lock'",
waitTime = 5,
leaseTime = -1 // Watch Dog 활성화
)
@Transactional
public void decreaseStock(Long productId, int quantity) {
// 재고 차감 로직
log.info("재고 차감: productId={}, quantity={}", productId, quantity);
}
}
이번엔 Redisson 라이브러리를 활용하여 분산락을 보다 쉽게 구현하는 방법에 대해 알아보았습니다. 또한 Watch Dog을 통한 자동 락 시간 연장등의 기능에 대해서도 알아보았고, 어노테이션을 통해 보다 쉽게 분산락을 활용하는 방법에 대해 알아보았습니다.
읽어주셔서 감사드립니다 🫡