Redisson 분산락

개발자 팀·2026년 1월 25일

self-study-series

목록 보기
8/16
post-thumbnail

이번엔 Redis 기반의 Java 분산 객체 및 서비스 제공 라이브러리인 Redisson에 대해서 알아보겠습니다.
이 라이브러리는 분산락 이외에도 분산 컬렉션이나 분산 세마포어 등 다양한 기능을 제공합니다.
또한 실제 운영 환경에서는 앞서 LockManager를 직접 구현해서 작업했던 것보다 훨씬 더 많은 기능을 제공해야 합니다. 그러니 훨씬 복잡해지죠.
Redisson은 이러한 복잡한 부분들을 처리해줄 수 있는 검증된 라이브러리입니다. 마치 직접 HTTP 통신 코드를 작성하는 것보다 RestTemplate이나 WebClient를 사용하는 것이 더 안전한 것처럼 말이죠😁

Redis를 활용한 직접구현방식과 Redisson을 활용한 방식은 다음과 같은 차이점이 있습니다.

기능직접 구현Redisson
기본 락 획득/해제✅ 가능✅ 가능
락 재진입 (같은 스레드가 중복 획득)❌ 추가 구현 필요✅ 기본 지원
자동 락 연장 (Watch Dog)❌ 추가 구현 필요✅ 기본 지원
공정한 락 (순서 보장)❌ 추가 구현 필요✅ 기본 지원
다중 Redis 인스턴스 지원❌ 추가 구현 필요✅ Redlock 지원
비동기/리액티브❌ 추가 구현 필요✅ 기본 지원
테스트 검증⚠️ 직접 검증 필요✅ 수많은 프로덕션 환경에서 검증됨

Java 환경에서 보다 쉽게 분산락을 사용할 수 있도록 만들어주는 Redisson을 어떻게 사용하는지 한 번 알아보겠습니다.

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에서 분산락 사용하는 방법

기본적으로 분산락 이라고 하는 것은 다음의 네 단계를 따릅니다.

코드레벨에서는 어떻게 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

Watch Dog의 가장 큰 역할은 자동으로 락 시간을 연장해준다는 것입니다. TTL이 너무 짧으면 작업 중 락이 만료되고, 너무 길면 장애시 복구가 느리게 되는데 Watch Dog의 경우 그 문제를 완벽하게 해결할 수 있습니다.
Watch Dog의 기본적인 설정은 아래와 같습니다.

설정기본값설명
lockWatchdogTimeout30초락의 기본 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와 커스텀 어노테이션을 통하여 손쉽게 사용할 수 있는 환경을 구축할 수도 있습니다.

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 어노테이션 구현 방법에 대해 학습해보겠습니다.

@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;
}

DistributedLockAspect (AOP 구현)

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을 통한 자동 락 시간 연장등의 기능에 대해서도 알아보았고, 어노테이션을 통해 보다 쉽게 분산락을 활용하는 방법에 대해 알아보았습니다.
읽어주셔서 감사드립니다 🫡

profile
공부하고 기록하고 공유하는 개발자 팀(Tim) 입니다. 늘끄적입니다.

0개의 댓글