본문 바로가기
DB/Redis

[Redis] DistributedLock(Spin Lock) - Spring boot

by lucas_owner 2025. 4. 16.

1. DistributedLock(분산락) - Spin Lock 방식

서비스를 구현하다보면 많은 문제가 발생하고는 합니다. 그 중 가장 많이 대두되는 문제중 하나는 바로 
동시성 문제(Race Condition) 입니다. 분산환경 즉 여러대의 서버 혹은 인스턴스가 동일한 자원(DB, 파일, 캐시 등)을 동시에 접근하려 할때 발생하는 문제로 경우에 따라 크리티컬한 문제를 발생시킬 수 있는 요소입니다. 
이런 동시성 문제를 방지하기 위해 "Lock" 이라는 개념이 필요하게 되며, Lock 을 구현하는 방법중 하나인 Redis 를 이용하게 되고, Lock 구현 방식에 따라 어떤 Redis 라이브러리를 선택해야 하는지 결정을 해야만 합니다.
 
분산락을 구현하는 방법들은 다음과 같습니다.
Redis 기반, Zookeeper, DB 
해당 포스팅에서는 Redis 기반의 Lock 을 구현할것이며, Lettuce 를 이용한 Spin Lock 을 구현할 예정입니다.
 
 
 

- DistributedLock(분산락) 이 필요한 이유

현대의 웹 서비스는 대부분 여러대의 서버(인스턴스)로 구성된 분산 시스템 구조입니다. 
서버가 한대로만 구성되어있는 서비스라면 Java 의 synchronized, volatile 등 사용이 가능하지만 이는 각 서버에만 적용이 가능한 방법이기 때문에 분산환경에서는 적절하지 않은 동시성 제어 방법입니다.
 

  1. 한정 수량의 재고가 감소되는 로직
  2. 파일, 특정 리소스에 대한 접근 제어
  3. Batch Job 중복 실행 방지

위와 같은 비지니스 로직에서 분산 Lock 을 사용 하기 적합합니다.
 
 

- 그렇다면 DB 레벨에서 Lock 을 걸면 안될까?

개발에는 정답이 없듯 DB 레벨에서 Lock 을 거는것도 하나의 방법이 될 수 있다. 
Lock 을 구현하기 위해, Redis, Zookeeper 인스턴스를 띄우게 된다면 이는 오버 엔지니어링이 될 수 있으므로 좋은 선택이 아닐 수 있다.
단일 서버, 단일 DB 일 경우 Row-level Lock 만으로도 해결될 수 있겠지만, 확장성에는 제한이 있다 또한 Transaction 범위를 넘어가는 작업이라면 DB Lock 으로는 해결이 불가하고 DB 가 받는 부하, Connection Pool 관리를 생각했을때
서비스가 분산환경이라면 분산락은 확장성, 안정성 면에서 좋은 선택이라고 보여진다.
 
 
 

2. Redis Lock

필자는 이미 Redis 를 사용중이며, 싱글 인스턴스로 사용하고 있기에 인스턴스 추가에 대한 부담이 없었고, Redis 여러대를 통한 분산락 환경 또한 생각하지 않아도 되기에 Redis 를 통한 Lock 을 생각했다. Zookeeper 를 사용한다면 순서보장(공정성), 안정성 등을 더 높게 가져갈 수 있지만 러닝커브 및 인스턴스 관리 등의 이유로 오버 엔지니어링이라 판단하여 배제하였다.
 
 

- Redis 라이브러리

Redis 로 Lock 을 구현하는 방법에는 크게 2가지가 존재한다 Spin Lock, Redisson 이 제공하는 Lock Interface 사용
결국 Redis 라이브러리인 Lettuce, Redisson 둘중 하나를 선택해서 구현해야 한다.
각각의 장단점에 대해서 얘기해 보자면 아래와 같다.
 

- Lettuce 라이브러리

Lock Interface 를 제공하지 않기에 개발자가 Lock 에 대한 로직을 직접 구현해야 한다.
이때 Lock 의 구현은 Spin Lock 방식으로, 해당 Lock 이 점유중인지 계속 질의를 하는 방식이라 Redis 에 부하가 발생한다.
또한 DeadLock 발생 가능성이 존재하기에 Lock 점유 해제를 필수로 고려해야한다.

다만 Lock 점유 시간이 짧은 경우, 경쟁이 낮은 경우에는 Spin Lock 을 사용하는게 유리할 수 있다.
- Redisson 라이브러리

기본적으로 Lock Interface 를 제공하며, pub/sub 방식을 사용하여 Lock 이 해제됬을 경우 Subscriber 에게 알림을 줘서 Lock 점유 시도를 하게 하는 방식으로 Spin Lock 에 비해서 안정적이며 손쉽게 적용 가능하다

 
각 라이브러리의 장단점을 보게 된다면 Lock 을 사용함에 있어 Redisson 라이브러리를 사용하는게 좋은 방법이지만
현재 서비스 모듈에서는 Lettuce 라이브러리를 사용중이었고, 경쟁이 심하지 않다고 판단해 Lettuce Spin Lock 방식 사용을 선택하게 되었다. 
 
 

3. 구현

Redis Spin Lock 을 구현하려면 어떤 것들을 고려해야 하는지 부터 살펴보도록 하자.

  1. Lock Name 으로 Lock 을 커스텀하게 처리
  2. DeadLock 방지를 위해 재시도횟수 및 재시도 시간 설정, 만료시간 설정가능
  3. Lock 은 Lock 을 점유한 사용자만 해제 가능
  4. 비지니스 로직과는 분리

결국에는 안정성을 높이며, 상황에 맞게 사용할 수 있도록 커스텀 요소들을 설정할 수 있게 하는것을 목표로 한다.
 
 

- RedisLockService

@Service
@RequiredArgsConstructor
public class RedisLockService {

    private final RedisTemplate redisTemplate;

    private static final String LOCK_PREFIX = "LOCK:";
    private static final long SPIN_INTERVAL_MIN = 100; // 최소 재시도 간격 (100ms)
    private static final long SPIN_INTERVAL_MAX = 500; // 최대 재시도 간격 (500ms)

    /**
     * Lock 획득
     */
    public boolean acquireLock(String lockKey, String requestId, long expireTime) {
        Boolean isSet = redisTemplate.opsForValue().setIfAbsent(getLockKey(lockKey), requestId, expireTime, TimeUnit.MILLISECONDS);
        return Boolean.TRUE.equals(isSet); // 성공시 true, 실패시 false
    }

    /**
     * Lock 해제
     */
    public void releaseLock(String lockKey, String requestId) {
        String storedRequestId = (String) redisTemplate.opsForValue().get(getLockKey(lockKey));
        if (requestId.equals(storedRequestId)) {
            redisTemplate.delete(getLockKey(lockKey));
        }
    }

    /**
     * Lock 획득 실패시 - 재시도
     * - Random 한 재시도 시간을 두고 Lock 획득 시도
     *   최대 20회 재시도 후 Lock 획득 실패시 false 반환
     * @param requestId = UUID.randomUUID().toString()
     * @param expireTime = Lock Expire Time(ms, Millisecond)
     * @return boolean = false
     */
    public boolean acquireLockRetry(String lockKey, String requestId, long expireTime) {
        int retryCount = 0;
        int maxRetryCount = 20;
        boolean isLock = false;

        while(!isLock && retryCount < maxRetryCount){
            isLock = this.acquireLock(lockKey, requestId, expireTime);

            if(!isLock) {
                try {
                    Thread.sleep(getRandomSleepTime());
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                    return false;
                }
            }
            retryCount ++;
        }
        return isLock;
    }

    /**
     * @package : com.boot.redis.about_redis.lock
     * @name : RedisLockService.java
     * @date : 2025. 4. 15. 오전 12:39
     * @author : lucaskang(swings134man)
     * @Description: 재시도 횟수를 지정하여 Lock 획득 시도
    **/
    public boolean acquireLockRetry(String lockKey, String requestId, int retryCount, long expireTime) {
        int count = 0;
        boolean isLock = false;

        while(!isLock && count < retryCount){
            isLock = this.acquireLock(lockKey, requestId, expireTime);

            if(!isLock) {
                try {
                    Thread.sleep(getRandomSleepTime());
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                    return false;
                }
            }
            count ++;
        }
        return isLock;
    }


    /**
     * 랜덤한 재시도 시간 반환 (100ms ~ 500ms) - BackOff Time(Busy Waiting)
     */
    private long getRandomSleepTime() {
        return SPIN_INTERVAL_MIN + (long) (Math.random() * (SPIN_INTERVAL_MAX - SPIN_INTERVAL_MIN));
    }

    private String getLockKey(String key) {
        return LOCK_PREFIX + key;
    }

}

 
Lock 을 사용하기 위한 구현 코드이다. 
 
사용자의 lock name 을 통해 LOCK:{name} 형식으로 여러개의 커스텀한 Lock 을 생성하며
재시도 횟수 지정, 재시도 사이 간격 설정을 통한 부하 최소화를 고려 하였다. 
DeadLock 을 방지하기 위한 ExpireTime 또한 설정 가능하게 했으며 Lock 의 경우 Lock 을 점유한 사용자만이 해제 가능하도록 구현하였다. 
 
이때 100ms ~ 500ms 사이의 랜덤한 재시도 시간을 부여함으로서 Redis 에 요청이 몰리지 않도록 구현하였다.
다만 이 방법은 공정성 즉 순서를 보장하지 않는 방법이기에 순서를 필수적으로 부여해야 한다면, Redis 의 List 를 통해 순서기반으로 구현을 다시해야 한다.
 
 

- RedisLockService 사용 예제

@Transactional
public void lockTask() {
    String lockKey = "theLock";
    String requestId = UUID.randomUUID().toString();
    long expireTime = 5000L;

    boolean isLock = redisLockService.acquireLock(lockKey, requestId, expireTime);
    if(isLock){
        // Lock 획득
        try {
            // TODO : Lock 획득 후 처리할 비즈니스 로직?
        } finally {
            // Lock 해제
            redisLockService.releaseLock(lockKey, requestId);
        }
    }else {
        log.warn("Redis Lock 획득 실패!");
    }//else
}

Lock 을 구현한 Class 를 사용하기 위해서는 위와 같은 코드를 필수적으로 작성해야만 한다. 
Exception 이 발생하거나 혹은 Lock 을 해제하지 못하는 경우를 대비하여 finally 키워드로 항상 Lock 을 해제하도록 해줘야 하고,
Lock 에 대한 설정을 해야하기에 설정값들을 정의해줘야 한다. 
 
우선 해당 Lock 구현체가 제대로 동작하는지 Test 를 해보도록 하자
가장 중요한 기능3가지 테스트 코드만을 올리도록 하겠다

@Test
@DisplayName("Lock - 동일한 LockKey에 중복 획득 불가")
void 동일한_LockKey에_중복_획득_불가_테스트() {
    // Given
    String lockKey = "duplicate-lock";
    String requestId1 = UUID.randomUUID().toString();
    String requestId2 = UUID.randomUUID().toString();
    long expireTime = 5000;

    // When: 첫 번째 요청이 Lock 획득
    boolean firstLock = redisLockService.acquireLock(lockKey, requestId1, expireTime);

    // Then: 첫 번째 요청은 Lock을 획득해야 함
    assertThat(firstLock).isTrue();

    // When: 두 번째 요청이 동일한 LockKey로 Lock 시도
    boolean secondLock = redisLockService.acquireLock(lockKey, requestId2, expireTime);

    // Then: 두 번째 요청은 Lock 획득 실패해야 함
    assertThat(secondLock).isFalse();
}

@Test
@DisplayName("Lock - TTL 만료 후 테스트")
void Lock_TTL_만료후_다른_프로세스가_Lock_획득_가능한지_테스트() throws InterruptedException {
    // Given
    String lockKey = "ttl-lock";
    String requestId1 = UUID.randomUUID().toString();
    String requestId2 = UUID.randomUUID().toString();
    long expireTime = 2000; // 2초 후 만료

    // When: 첫 번째 요청이 Lock 획득
    boolean firstLock = redisLockService.acquireLock(lockKey, requestId1, expireTime);

    // Then: 첫 번째 요청이 Lock을 획득해야 함
    assertThat(firstLock).isTrue();

    // 2초 후 TTL 만료 확인
    Thread.sleep(2500);
    assertThat(redisTemplate.opsForValue().get("LOCK:" + lockKey)).isNull();

    // When: 두 번째 요청이 Lock 획득 시도
    boolean secondLock = redisLockService.acquireLock(lockKey, requestId2, expireTime);

    // Then: 두 번째 요청이 Lock을 획득해야 함
    assertThat(secondLock).isTrue();
}

@Test
@DisplayName("Lock - 여러 Thread 동시 Lock 획득 테스트")
void 여러_스레드가_동시에_Lock을_획득하려고_할때_하나만_획득해야_함() throws InterruptedException {
    // Given
    String lockKey = "concurrent-lock";
    long expireTime = 5000; // 5초

    int threadCount = 10;
    ExecutorService executorService = Executors.newFixedThreadPool(threadCount);
    CountDownLatch latch = new CountDownLatch(threadCount);

    String[] successfulRequestId = new String[1];

    // When: 여러 개의 스레드가 동시에 Lock을 획득 시도
    for (int i = 0; i < threadCount; i++) {
        executorService.execute(() -> {
            String requestId = UUID.randomUUID().toString();
            boolean acquired = redisLockService.acquireLock(lockKey, requestId, expireTime);

            if (acquired) {
                successfulRequestId[0] = requestId;
            }

            latch.countDown();
        });
    }

    latch.await(); // 모든 스레드 대기

    // Then: 하나의 스레드만 Lock을 획득해야 함
    assertThat(successfulRequestId[0]).isNotNull();
    assertThat(redisTemplate.opsForValue().get("LOCK:" + lockKey)).isEqualTo(successfulRequestId[0]);
}

 

 
간단한 테스트를 통해서 의도된 Lock 기능들이 동작하는것을 확인할 수 있다. 
 
 
 

- Lock 로직 분리

Lock 을 사용해야하는 Method 마다 Lock 에 관련된 로직을 넣는것은 비지니스 로직에 대해서 오류를 발생시킬수도 있고 
중복된 코드가 계속 발생하는 만큼 효율적이지 못하게 된다. 
 
AOP 기반으로 커스텀 어노테이션을 만들어 Lock 을 공통화 하여 로직을 분리 해보도록 하겠다.
 
 

- DistributedLock.@Interface

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface DistributeLock {
    String lockKey();

    long expireTime() default 5000L;

    int retryCount() default 20; // Retry Count
}

우선 Lock 을 적용하게 될 Method 에 대해 @DistributedLock 이라는 어노테이션으로 정의해 주었다. 
 
 

- DistributedLockAop.class

@Aspect
@Component
@RequiredArgsConstructor
@Slf4j
@Order(-1) // Transaction 보다 Lock 취득을 우선함.
public class DistributeLockAop {

    private final RedisLockService redisLockService;

    @Around("@annotation(com.boot.redis.config.annotation.DistributeLock)")
    public Object lock(final ProceedingJoinPoint joinPoint) throws Throwable{
        MethodSignature signature = (MethodSignature) joinPoint.getSignature();
        Method method = signature.getMethod();

        DistributeLock annotation = method.getAnnotation(DistributeLock.class);

        // 1. For Lock Values
        String lockKey = (String) CustomSpringElParser.getDynamicValue(signature.getParameterNames(), joinPoint.getArgs(), annotation.lockKey());
        String requestId = UUID.randomUUID().toString();
        int retryCount = annotation.retryCount();
        long expireTime = annotation.expireTime(); // ms(Default 5sec)

        // 2. Get Lock
        boolean isLock = redisLockService.acquireLockRetry(lockKey, requestId, retryCount, expireTime);

        if(!isLock) {
            return false;
        }

        // 3. Proceed
        try {
            return joinPoint.proceed();
        } catch (Throwable e) {
            log.error("Error occurred while executing method: {}", method.getName(), e);
            throw e;
        } finally {
            // 4. Release Lock
            redisLockService.releaseLock(lockKey, requestId);
        }
    }
}

 
커스텀 어노테이션을 통해 수행할 Lock 로직을 적용해주었다. 
이때 @Order(-1) 을 설정하여 @Transactional 어노테이션 보다 우선순위에 두어 Lock 을 점유한 이후에 
Transactional 을 시작하도록 하였다. 락 미획득 시 트랜잭션 자원 낭비 방지, 락이 선행되지 않은 트랜잭션 진입 방지를 위해서이다.
트랜잭션이 먼저 시작되어버린다면, 동시성 충돌이 발생하여 오히려 데이터 정합성 충돌이 발생할 수 있다.
 
또한 Transaction Commit 이후 Lock 이 해제되어야 하기에 메서드 수행 이후 finally 키워드로 Lock 을 해제하도록 하게해줘야 한다.
트랜잭션 커밋 이후에 Lock 이 해제되지 않으면, 잘못된 데이터로 작업을 수행할 수 있기 때문에 주의해야 하는 부분일것이다.
 
 
 

- CustomSpringElParser.class

public class CustomSpringElParser {
    public CustomSpringElParser() {
    }

    public static Object getDynamicValue(String[] parameterNames, Object[] args, String key) {
        ExpressionParser parser = new SpelExpressionParser();
        StandardEvaluationContext context = new StandardEvaluationContext();

        for (int i = 0; i < parameterNames.length; i++) {
            context.setVariable(parameterNames[i], args[i]);
        }

        return parser.parseExpression(key).getValue(context, Object.class);
    }
}

 
사용자가 파라미터로 넘긴 Lock Key 값을 어노테이션에서 파싱할 수 있도록 컴포넌트 클래스를 작성해 주었다.
 

@DistributeLock(lockKey = "#lockName", retryCount = 100, expireTime = 7000L)
@Transactional
public void decreaseSyncValue(String lockName) {
	// TODO: Something
}

최종적으로 위와 같이 Lock Name 을 파싱하기 위한 컴포넌트이다.
 
 

4. Test

최종적으로 Redis 의 Spin Lock 을 테스트해보도록 하겠다.
 

- 테스트 코드

@BeforeEach
void setUp() {
    syncJpaRepository.deleteAll();

    // Init DB Row Data
    syncObject = new SyncObject();
    syncObject.setName("lock_one");
    syncObject.setValue(100);

    syncJpaRepository.save(syncObject);
}



@Test
@DisplayName("1. Distributed Lock Test: Annotation")
void 특정값차감_분산락_동시성_100개() throws InterruptedException {
    int numberOfThreads = 100; // 동시성 테스트를 위한 스레드 수
    ExecutorService executorService = Executors.newFixedThreadPool(numberOfThreads);
    CountDownLatch latch = new CountDownLatch(numberOfThreads);

    for (int i = 0; i < numberOfThreads; i++) {
        executorService.submit(() -> {
            try {
                // 분산락 적용 메서드 호출 (락의 key는 Object의 name으로 설정)
                redisService.decreaseSyncValue(syncObject.getName(), syncObject.getName());
            } finally {
                latch.countDown();
            }
        });
    }

    latch.await();

    // 최종 확인
    SyncObject result = syncJpaRepository.findByName(syncObject.getName())
            .orElseThrow(IllegalArgumentException::new);

    assertThat(result.getValue()).isZero();
    System.out.println("Final Value: " + result.getValue());
}

@Test
@DisplayName("2. 분산락 적용 안한 동시성 테스트")
void 특정값차감_동시성_100개() throws InterruptedException {
    int numberOfThreads = 100; // 동시성 테스트를 위한 스레드 수
    ExecutorService executorService = Executors.newFixedThreadPool(numberOfThreads);
    CountDownLatch latch = new CountDownLatch(numberOfThreads);

    for (int i = 0; i < numberOfThreads; i++) {
        executorService.submit(() -> {
            try {
                redisService.decreaseValue(syncObject.getName());
            } finally {
                latch.countDown();
            }
        });
    }

    latch.await();

    // 최종 확인
    SyncObject result = syncJpaRepository.findByName(syncObject.getName())
            .orElseThrow(IllegalArgumentException::new);

    assertThat(result.getValue()).isZero();
    System.out.println("Final Value: " + result.getValue());
}

 
 

- 사용 Method

@DistributeLock(lockKey = "#lockName", retryCount = 100, expireTime = 7000L)
@Transactional
public void decreaseSyncValue(String lockName, String syncName) {
    SyncObject syncObject = syncJpaRepository.findByName(syncName).orElseThrow(IllegalArgumentException::new);

    syncObject.decrease();
}


@Transactional
public void decreaseValue(String syncName) {
    SyncObject syncObject = syncJpaRepository.findByName(syncName).orElseThrow(IllegalArgumentException::new);

    syncObject.decrease();
}

 
분산락을 적용한 코드와, 적용하지 않은 로직으로 테스트 하도록 하겠다.
 
100개의 Thread 가 동시에 value 값을 -1 씩 진행하도록 했다.

 
우선 분산락을 적용하지 않은 테스트 코드의 경우 DB 의 값이 14개만 차감이 된것을 확인할 수 있고 
 
분산락을 적용한 테스트 코드의 경우 아래와 같이 100개 모두 차감된것을 확인할 수 있다.

 
 
 
이처럼 분산환경에서 분산락(Spin Lock)을 적용 도입하는 예시를 알아봤으며, AOP 를 활용하여 비지니스 로직과 분리 적용하는 방법을 알아보았다. MSA 환경에서 하게된다면 해당 로직들을 라이브러리화 하여 공통으로 사용하게 하여 개발 편의성을 제공함과 동시에 일관된 Lock 알고리즘을 적용하게 할 수 있다. 

반응형

댓글