본문 바로가기
트러블 슈팅/BE

Redis 분산락 컴포넌트 리팩토링 적용기

by Ramos 2025. 1. 4.

Intro

공유 자원에 대해 다수의 쓰레드가 경합 상황 발생 시 동시성 제어를 위해 Redis 기반 분산락을 사용하곤 한다.

개인적으론, 어디까지나 DB Lock으로 해결할 수 있다면 Redis 기반 분산락 처리까진 필요 없다 생각이 든다.

분산락을 꼭 도입해야 하는 케이스와 아닌 케이스는 보통 아래와 같다.

  • DB 데이터가 아닌 값에 대한 정합성이 필요할 때 분산락이 필요함.
  • 로컬 캐시와 글로벌 캐시 동기화에 사용되는 케이스처럼 DB 기반이 아닌 다른 인프라나 저장소를 기반으로 처리해야 하는 경우 동시성 이슈를 막기 위해 분산락이 필요함.
  • 비즈니스 로직 상 한 DB테이블의 항목에 대한 write 연산이라면 DB Query의 write lock을 잡고 처리해도 무방함.
    • ex) 게시글 단건에 대한 단순 수정의 경우 (어드민 메모, ...)
  • 비즈니스 로직 상 테이블의 릴레이션이 많아 조합해서 로직을 처리하는 경우 DB lock으로만 해결하기 어려운 케이스들이 존재함.
    • ex) 신청서 등록 시 사용자가 프로젝트, 서버를 선택하여 신청하는데 해당 대상을 누군가 중간에 삭제 했다면 보장이 안됨.

이미지 출처 : if(kakao AI) 2024 - 카카오페이는 어떻게 수천만 결제를 처리할까? 우아한 결제 분산락 노하우

 

이전에 수행했던 프로젝트에선 레거시 코드 상 Redisson Client의 API를 단순 호출 처리만 하고 있는 상황으로 다음과 같은 이슈가 있었다.

  • 별도의 락 해제 처리 실패 케이스에 대한 코드 처리가 안되어 있었어서 AOP 기반 분산락을 적용하도록 개선하면 좋을 거라 판단했다.
  • 다른 AOP들에 대해 Order 애노테이션으로 순서를 지정하고 있던 레거시 코드 상 AOP 기반 분산락이 순서에 맞게 동작하지 못했다.

 

해당 코드 도입을 위해 당시 테스트 했던 코드는 아래와 같다.

https://github.com/alanhakhyeonsong/redisson-distributed-lock

 

GitHub - alanhakhyeonsong/redisson-distributed-lock: AOP로 Redis 분산락을 적용할 때, Transactional 전파 옵션 여부

AOP로 Redis 분산락을 적용할 때, Transactional 전파 옵션 여부와 AOP의 Order를 지정하는 케이스들을 테스트한다. - alanhakhyeonsong/redisson-distributed-lock

github.com

 

최근 수행했던 신규 프로젝트에선, Redisson 라이브러리를 사용하지 않고 Lua Script와 Redis Pub/Sub을 직접 조작해서 커스텀 분산락을 라이브러리로써 사용자에게 제공해보라는 미션이 주어졌었지만, 일정상의 이슈로 Redisson 라이브러리를 재활용 하기로 결정했다.

 

본래 시도하려고 했던 사항의 목적은 다음과 같다.

  • 락을 얻기 위한 Redis 호출을 최소화하되, Pub/Sub으로 락 획득을 대기중인 쓰레드에게 notify하여 즉각적으로 처리가 가능했으면 좋겠다는 요구사항이 있었음.
  • 관련 Redis 명령어를 Lua Script로 묶어 atomic하게 제어해야 함.
  • 경합 상태에 놓여있을 경우, 사용자의 순서 보장이 가능한 옵션도 제공하면 사용성도 좋아질 것으로 생각함.

 

결국, Redisson 라이브러리를 재활용하는 방향으로 가되, 분산락을 사용하는 사용자는 다음 두 가지 방식 중 하나를 선택하여 사용할 수 있도록 컴포넌트를 제공함.

  • AOP 기반 분산락
  • 컴포넌트 기반 분산락

컴포넌트 기반 분산락을 제공한 목적은, 만약 기존처럼 분산락 AOP를 around로 처리한 경우 @Transactional 기반으로 DB Transaction으로 묶인 비즈니스 로직의 범위가 넓을 경우 성능적인 이슈가 발생한다 생각하여 사용하는 클라이언트가 딱 필요한 만큼의 범위만 분산락으로 처리할 수 있도록 하고 싶었다.

 

개선 사항에 대해 요약하자면, 앞서 첨부한 예제 코드에서 컴포넌트 뎁스를 한 단계 더 만들고, Functional Interface로 callback 처리를 수행하도록 리팩토링 시킨게 코드 구조상의 개선 포인트였다.

라이브러리

  • Redisson Client
// build.gradle
implementation 'org.redisson:redisson-spring-boot-starter:3.39.0'

Class Diagram

  • DistributedLockAspect
    • 분산락을 애노테이션 기반 AOP로 적용하기 위한 컴포넌트
    • DistributedLock 애노테이션이 달려있는 메소드에 around 형태로 동작
      • DistributedLock 애노테이션에서 받는 필드는 아래와 같음
        • String key : 분산락의 키
        • TimeUnit timeUnit : 락의 시간 단위
        • long waitTime : 락 대기 시간 (default 5L)
        • long leaseTime : 락 임대 시간. 이 시간이 지나면 락을 자동으로 해제함 (default 3L)
  • DistributedLockManager
    • Redis 분산락 획득 및 해제를 자동으로 수행해주는 위임 컴포넌트
    • AOP 기반 분산락 외에도 사용자가 이 컴포넌트를 직접 호출하여 분산락을 처리할 수 있도록 제공함.
/**
 * Redis 분산락 획득 및 해제를 자동으로 수행해주는 위임 컴포넌트
 *
 * @author HakHyeon Song
 */
@Slf4j
@Component
@RequiredArgsConstructor 
public class DistributedLockManager {

    private static final String REDISSON_LOCK_PREFIX = "LOCK:";

    private final TransactionExecutor transactionExecutor;
    private final RedissonClient redissonClient;

    public <R> R executeWithLock(String key, long waitTime, long leaseTime, TimeUnit timeUnit, LockCallback<R> callback) throws Throwable {
        String lockKey = REDISSON_LOCK_PREFIX + key;
        // lock 획득
        RLock rLock = redissonClient.getLock(lockKey);

        try {
            // lock의 타임아웃 체크
            boolean available = rLock.tryLock(waitTime, leaseTime, timeUnit);
            if (!available) {
                throw new IllegalStateException("Could not acquire lock for key: " + lockKey);
            }

            log.info("Distributed lock acquired for key : {}", lockKey);

            // REQUIRES_NEW 전파 + callback 실행
            return transactionExecutor.execute(callback);
        } catch (InterruptedException e) {
            log.error("Interrupted while trying to acquire lock for key: {}", lockKey);
            throw e;
        } finally {
            try {
                // lock 해제
                rLock.unlock();
                log.info("Distributed lock released for key : {}", lockKey);
            } catch (IllegalMonitorStateException e) {
                log.info("Distributed lock already unLocked for key: {}", lockKey);
            }
        }
    }
}
  • LockCallback
    • Redis 분산락을 걸고 비즈니스 로직을 수행하기 위한 Functional Interface
    • 임계 영역으로 지정할 비즈니스 로직을 lambda로 전달하기 위해 사용됨.
/**
 * Redis 분산락을 걸고 비즈니스 로직을 수행하기 위한 Functional Interface
 *
 * @author HakHyeon Song
 * @param <R> callback 수행 시 반환하는 값
 */
@FunctionalInterface
public interface LockCallback<R> {

    /**
     * 수행할 비즈니스 로직에 대한 function을 실행
     *
     * @return 비즈니스 로직의 리턴값
     * @throws Throwable
     */
    R execute() throws Throwable;
}
  • TransactionExecutor
    • Transaction 전파 레벨을 분리하기 위한 Callback Executor
    • 임계 영역으로 지정한 비즈니스 로직을 LockCallback functional interface으로 전달받아 해당 부분을 이 컴포넌트에서 수행
    • 트랜잭션 전파 레벨을 REQUIRES_NEW 로 수행되므로 분산락 잠금/해제와는 별도의 트랜잭션으로 동작하여 비즈니스 로직에서 예외가 발생하더라도 분산락 해제는 반드시 수행되도록 보장됨.

Sequence Diagram

  • AOP 기반 분산락의 경우 위 다이어그램에서의 클라이언트가 AOP Aspect가 됨.
  • LockCallback이 사용자가 임계 영역으로 지정한 비즈니스 로직에 해당.

사용 예시

AOP 기반 분산락

// AOP 기반 분산락 사용 예시
@DistributedLock(key = "#lockName")
public void decrease(String lockName, Long productId, Long quantity) {
    Stock stock = stockRepository.findByProductId(productId).orElseThrow();

    stock.decrease(quantity);

    stockRepository.saveAndFlush(stock);
}

컴포넌트 기반 분산락

public void example(String key) {
  try {
    distributedLockManager.executeWithLock(
      key,
      5,
      3,
      TimeUnit.SECONDS,
      () -> {
        // 비즈니스 로직 실행 (REQUIRES_NEW 트랜잭션으로 수행됨)
        System.out.println("비즈니스 로직 실행");
        return null;
      }
    );
  } catch (Throwable throwable) {
    // 적절한 예외 처리 필요
    throw new RuntimeException("락을 걸어 수행한 로직 실패", throwable);
  }
}

 

끝으로, 분산락 도입 시 실무 팁을 남긴다면 락의 key가 되는 값을 유의미하되, 잠금 대상이 되는 데이터에 대해서만 지정할 수 있도록 네이밍 규칙을 잘 지정해서 처리해야 실수가 덜 일어날 것이다.