Annotation 기반의 간단한 분산락
// DistributedLockAspect.java
@Order(Ordered.LOWEST_PRECEDENCE - 1)
@Aspect
@Component
public class DistributedLockAspect {
private static final String REDISSON_LOCK_PREFIX = "LOCK:";
private final RedissonClient redissonClient;
public DistributedLockAspect(RedissonClient redissonClient) {
this.redissonClient = redissonClient;
}
@Around("@annotation(com.kurly.product.partner3p.application.lock.DistributedLock)")
public Object lock(final ProceedingJoinPoint joinPoint) throws Throwable {
var methodSignature = (MethodSignature) joinPoint.getSignature();
var method = methodSignature.getMethod();
DistributedLock distributedLock = method.getAnnotation(DistributedLock.class);
var key = REDISSON_LOCK_PREFIX + CustomSpringELParser.getDynamicValue(methodSignature.getParameterNames(), joinPoint.getArgs(), distributedLock.key());
var rLock = redissonClient.getLock(key);
try {
boolean available = rLock.tryLock(distributedLock.waitTime(), distributedLock.leaseTime(), distributedLock.timeUnit());
if (!available) {
throw new DistributedLockFailureException("[%s] 분산락 획득에 실패하였습니다. ".formatted(this.getMethodSignatureName(joinPoint)));
}
return joinPoint.proceed();
} finally {
try {
rLock.unlock();
} catch (IllegalMonitorStateException e) {
log.debug("Redisson Lock Already UnLock {} {}",
kv("serviceName", method.getName()),
kv("key", key)
);
}
}
}
private String getMethodSignatureName(JoinPoint joinPoint) {
var signature = (MethodSignature) joinPoint.getSignature();
var declaringTypeName = signature.getDeclaringTypeName();
var methodName = signature.getMethod().getName();
var argsTypeName = Arrays.stream(signature.getParameterTypes()).map(Class::getName).collect(Collectors.joining(","));
return String.format("%s.%s(%s)", declaringTypeName, methodName, argsTypeName);
}
}
// DistributedLock.java
@Target(METHOD)
@Retention(RUNTIME)
public @interface DistributedLock {
/**
* 락의 이름
*/
String key();
/**
* 락의 시간 단위
*/
TimeUnit timeUnit() default TimeUnit.SECONDS;
/**
* 락을 기다리는 시간 (default - 5s)
* 락 획득을 위해 waitTime 만큼 대기한다
*/
long waitTime() default 5L;
/**
* 락 임대 시간 (default - 3s)
* 락을 획득한 이후 leaseTime 이 지나면 락을 해제한다
*/
long leaseTime() default 3L;
}
위와 같은 애노테이션 기반의 분산락을 이용하여 트랜잭션보다 먼저 분산락을 적용시켜, DB 가 아니라, 분산 서버로 구성된 애플리케이션 내에서 락을 획득/대기/반환 할 수 있도록 했다.
예제 사용코드는 아래와 같다.
@DistributedLock(key = "#command.getKey()", leaseTime = 2L)
@Transactional
public void handle(ReturnClaimAdminCloseCommand command) {
var claim = returnClaimRepository.findById(command.getReturnClaimId())
.orElseThrow(() -> new NotFoundClaimException(command.getReturnClaimId()));
returnClaimRepository.save(command.closeReturnClaimByAdmin(claim));
}
AOP 로 인해, 기대하는 실행 흐름은 아래와 같다.
2초후 해제되는 분산락 획득 ➡️ 트랜잭션 시작 ➡️ 비즈니스 로직 수행 ➡️ 트랜잭션 커밋/롤백 ➡️ 2초후 또는 트랜잭션 종료 이후 락 반환
현실은 이상만 있지 않는법
그러나, 위에서 소개한 분산락에는 여러가지 위험이 있다.
1. 갱신 분실(Lost Update) 문제 : 너무 짧은 leaseTime 또는 예기치 못한 DB 의 지연으로 인한 문제이다.
미처 트랜잭션이 종료되지 않았지만, 분산락이 반환되어 동일한 테이블 row 에 대해 접근이 가능하고, 이로인해 우리가 원하는 최종 데이터로 갱신되지 않을 수도 있다.
2. 데드락(Deadlock) 문제 : 락을 획득하기 위해 무한정 대기하는 문제이다.
개발자가 실수로 leaseTime 을 9999L 과 같이 큰 숫자로 개발했다고 가정하자.
그리고 먼저 분산락을 획득했던 쓰레드에서 트랜잭션이 종료되지 않는다면, 동일한 분산락 키로 획득하려는 다음 쓰레드는 분산락을 획득하기 위해 9999초나 대기해야 하는 상황이 벌어진다.
3. 큰 waitTime 으로 인한 느린 실패 문제 : 만약 병목이 생겨서 waitTime 만큼 대기를 할 때 너무 느린 실패로 인한 문제이다.
해결방안
갱신 분실(Lost Update) 문제 및 데드락(Deadlock) 문제 해결방법
- rLock.tryLock() 할 때, leaseTime 을 커스텀하게 받지 않고, 0으로하여 watchdog 매커니즘을 이용해 자동으로 연장되게 한다.
- Watchdog 매커니즘은 락을 획득한 스레드가 작업을 계속 수행 중인지 감시하다가, leaseTime이 만료되기 전에 자동으로 락의 만료 시간을 연장해 줍니다.
큰 waitTime 으로 인한 느린 실패 문제 해결방법
아래와 같은 적절한 waitTime 적용이 필요하다.
- waitTime < DB CP connection-timeout (필수)
- DB 커넥션을 얻지 못해 실패할 요청이라면, 그 전에 분산 락 단계에서 먼저 실패하도록 유도해야 합니다.
- 따라서 waitTime은 connection-timeout 보다 짧아야 좋다 생각합니다.
- waitTime < API Client Timeout (권장)
- 애플리케이션 앞단에 API 게이트웨이나 로드밸런서가 있다면, 해당 시스템의 타임아웃(보통 15초~60초)도 고려해야 합니다. 클라이언트가 이미 연결을 끊었는데 서버 혼자 계속 락을 기다리는 것은 자원 낭비입니다.
- 전체 요청 처리 시간(락 대기 + 비즈니스 로직)이 클라이언트 타임아웃을 넘지 않도록 waitTime을 보수적으로 설정해야 합니다.
- 구체적인 권장 값: 3~5초
- 대부분의 사용자 대면(User-facing) 서비스에서 3~5초 이상 응답이 지연되면 사용자는 이탈하거나 요청을 재시도합니다.
- 이 시간을 waitTime의 최대치로 설정하면, 시스템 부하 상황에서 과도한 대기를 막고 안정적으로 실패 처리를 할 수 있습니다.