시스템 설계

분산락 어떻게 사용할까

배워서, 남주자 2025. 9. 14. 01:05

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 에 대해 접근이 가능하고, 이로인해 우리가 원하는 최종 데이터로 갱신되지 않을 수도 있다.

ref.haon blog

 

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의 최대치로 설정하면, 시스템 부하 상황에서 과도한 대기를 막고 안정적으로 실패 처리를 할 수 있습니다.