Intro
공유 자원에 대해 다수의 쓰레드가 경합 상황 발생 시 동시성 제어를 위해 Redis 기반 분산락을 사용하곤 한다.
개인적으론, 어디까지나 DB Lock으로 해결할 수 있다면 Redis 기반 분산락 처리까진 필요 없다 생각이 든다.
분산락을 꼭 도입해야 하는 케이스와 아닌 케이스는 보통 아래와 같다.
- DB 데이터가 아닌 값에 대한 정합성이 필요할 때 분산락이 필요함.
- 로컬 캐시와 글로벌 캐시 동기화에 사용되는 케이스처럼 DB 기반이 아닌 다른 인프라나 저장소를 기반으로 처리해야 하는 경우 동시성 이슈를 막기 위해 분산락이 필요함.
- 비즈니스 로직 상 한 DB테이블의 항목에 대한 write 연산이라면 DB Query의 write lock을 잡고 처리해도 무방함.
- ex) 게시글 단건에 대한 단순 수정의 경우 (어드민 메모, ...)
- 비즈니스 로직 상 테이블의 릴레이션이 많아 조합해서 로직을 처리하는 경우 DB lock으로만 해결하기 어려운 케이스들이 존재함.
- ex) 신청서 등록 시 사용자가 프로젝트, 서버를 선택하여 신청하는데 해당 대상을 누군가 중간에 삭제 했다면 보장이 안됨.
이전에 수행했던 프로젝트에선 레거시 코드 상 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가 되는 값을 유의미하되, 잠금 대상이 되는 데이터에 대해서만 지정할 수 있도록 네이밍 규칙을 잘 지정해서 처리해야 실수가 덜 일어날 것이다.
'트러블 슈팅 > BE' 카테고리의 다른 글
RestTemplate으로 외부 API 호출 이후 예외 응답시 body가 유실되는 현상에 대해 (0) | 2025.04.11 |
---|