2024.10.16 - [Spring] - [Spring Data] Redis Lock을 적용해보자
[Spring Data] Redis Lock을 적용해보자
`백엔드/서버` 개발자라면 반드시 `동시성` 문제를 만나게 된다. 특히, 요즘 `K8s` 환경을 자주 사용하는데 아무리 애플리케이션에서 `동시성` 발생 대비를 해도다른 `Pod`에서 같은 `DB`에 접근하면
hdbstn3055.tistory.com
우리는 이전 포스팅에서 Redis
를 활용한 Distributed Lock
을 적용하고 관련된 문제를 해결했다.
- 해결 방안1: 앞에 프록시 클래스를 두는 것
- 해결 방안2:
@TransactionalEventListener
를 사용하는 것
하지만, 해결 방안1
과 해결 방안2
는 Distributed Lock
획득과 반납으로 인한 BoilerPlate
코드가 존재하게 된다.
따라서, 이러한 로직을 어노테이션과 AOP를 활용하여 정리하는 것이 좋다.
Annotation
어노테이션에는 Distributed Lock
획들을 위한 key
, waitTime
, leaseTime
, timeUnit
를 정의한다.
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface RedLock {
String key();
long waitTime() default 10000L;
long leaseTime() default 3000L;
TimeUnit timeUnit() default TimeUnit.MILLISECONDS;
}
중요
이때, waitTime()를 적절하게 설정해야 한다.
너무 길게 설정하면 너무 오래걸려서 부하가 생기고 너무 적게 설정하면,
로직이 끝나기 전에 tryLock()
이 끝나버려 작업이 전부 씹혀버리게 된다.
해결 방안1에 관한 AOP 전환
Transaction
분리를 위해 Service
를 만들어 해당 메소드에 Propagation.REQUIRES_NEW
를 정의하고
해당 메소드를 호출해야 한다.
@Aspect
@Component
@RequiredArgsConstructor
public class RedLockAop {
private final RedissonClient redissonClient;
private final RequiresNewService requiresNewService;
private final ApplicationEventPublisher applicationEventPublisher;
@Around("@annotation(com.example.concurrencycontrol.annotation.RedLock)")
public Object lock(ProceedingJoinPoint proceedingJoinPoint) throws Throwable{
MethodSignature signature = (MethodSignature) proceedingJoinPoint.getSignature();
RedLock redLock = signature.getMethod().getAnnotation(RedLock.class);
RLock lock = redissonClient.getLock(getDynamicValue(signature.getParameterNames(), proceedingJoinPoint.getArgs(), redLock.key()).toString());
try{
if(lock.tryLock(redLock.waitTime(), redLock.leaseTime(), redLock.timeUnit())){
return requiresNewService.proceed(proceedingJoinPoint);
} else {
throw new RuntimeException();
}
} catch (Exception e){
throw e;
} finally {
if(lock.isLocked() && lock.isHeldByCurrentThread()){
lock.unlock();
}
}
}
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);
}
}
여기서 getDynamicValue()
메소드에 관한 간략한 설명을 하겠다.
왜 redLock.key()
를 그대로 사용하지 않고 위 메소드를 따로 생성하는 걸까?
동적 키를 사용하는 이유는 다음과 같다.
redLock.key()
는 고정된 문자열일 수 있다.- 하지만 많은 경우, 락을 걸 때 인자 값에 따라 동적으로 달라지는 키가 필요하다.
관련된 간단한 예시는 아래와 같다.
티켓 구메 시스템에서 티켓 ID에 따라 고유한 락을 걸어야 할때, 단순히 고정된redLock.key()
를 사용하면
모든 티켓에 대해 동일한 락이 걸리게 된다. 이는 동시성 문제를 해결하는 데 적합하지 않다.
@RedLock(key = "#ticketId")
public void purchase(Long ticketId, int amount) {
// 티켓 구매 로직
}
위와 같은 어노테이션에서 #ticketId
는 메서드 인자 ticketId
를 참조하는 SpEL 표현식이다.
동적으로 이 값을 평가해야만 티켓 ID별로 각각의 락을 거는 것이 가능하다.
즉, @RedLock(key = "#ticketId")
를 사용하면, 메서드 호출 시 넘겨받은 ticketId
에 맞는 고유한 락이 걸리게 된다.
이때 redLock.key()
만 사용하면 그냥 "#ticketId"
라는 문자열이 반환될 것이고,
이를 SpEL 표현식으로 평가해야만 실제 ticketId
값을 얻을 수 있다.
또한, SpEL
을 사용하면 단순한 인자 값뿐만 아니라, 복합적인 조건이나 계산식을 사용해 키를 생성할 수 있다.
예를 들어, 다음과 복잡한 조건의 예시가 가능하다.
@RedLock(key = "#ticketId + '-' + #amount")
public void purchase(Long ticketId, int amount) {
// 티켓 구매 로직
}
위 경우, ticketId
와 amount
를 결합한 형태의 동적 키가 생성된다.
이런 유연성을 주기 위해 SpEL
을 사용하는 것이고, getDynamicValue
메서드가 이 기능을 수행하는 것이다.
RequiresNewService
@Service
public class RequiresNewService {
@Transactional(propagation = Propagation.REQUIRES_NEW)
public Object proceed(ProceedingJoinPoint proceedingJoinPoint) throws Throwable{
return proceedingJoinPoint.proceed();
}
}

해결 방안2에 관한 AOP 전환
Aspect
의 @Around
에서 @RedLock
을 PointCut
으로 설정하여
해당 JoinPoint
가 실행하기 이전에 tryLock
를 통해 Lock
획득을 시도하는 로직이다.
@Aspect
@Order(1)
@Component
@RequiredArgsConstructor
public class RedLockAop {
private final RedissonClient redissonClient;
private final RequiresNewService requiresNewService;
private final ApplicationEventPublisher applicationEventPublisher;
@Around("@annotation(com.example.concurrencycontrol.annotation.RedLock)")
public Object lock(ProceedingJoinPoint proceedingJoinPoint) throws Throwable{
MethodSignature signature = (MethodSignature) proceedingJoinPoint.getSignature();
RedLock redLock = signature.getMethod().getAnnotation(RedLock.class);
RLock lock = redissonClient.getLock(getDynamicValue(signature.getParameterNames(), proceedingJoinPoint.getArgs(), redLock.key()).toString());
try{
if(lock.tryLock(redLock.waitTime(), redLock.leaseTime(), redLock.timeUnit())){
applicationEventPublisher.publishEvent(lock);
return proceedingJoinPoint.proceed();
} else {
throw new RuntimeException();
}
} catch (Exception e){
throw e;
} finally {
// if(lock.isLocked() && lock.isHeldByCurrentThread()){
// applicationEventPublisher.publishEvent(lock);
// }
}
}
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);
}
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMPLETION)
public void unLockEvent(RLock lock) {
if(lock.isLocked() && lock.isHeldByCurrentThread()){
lock.unlock();
}
}
}
TicketV7Service
@Transactional
@RedLock(key = "'TEST'")
public Ticket purchaseRLock2(Long ticketId, int amount) {
Ticket ticket = ticketRepository.findById(ticketId).get();
ticket.setStock(ticket.getStock() - amount);
log.info("Ticket Count: {}", ticket.getStock());
return ticket;
}
Hikari Connection Pool & Tomcat Max Worker Threads
해결 방안1
과 해결 방안2
를 테스트하다 요청 수를 증가시키면 아래와 같은 Time Out
이 발생하는 경우가 있다.
HikariPool-1 - Connection is not available, request timed out after 30008ms.
발생 이유는 Hikari Connection Pool Size
와 Tomcat Max Worker Thread
의 수에 따른Hikari DeadLock
이 발생한 경우이다.

위와 같은 순서에 의해서 Hikari DeadLock
상태가 발생하고 Distributed Lock
획득 대기를 하고 있는
Thread
의 Connection
들과 Distributed Lock
은 획득 했으나 새로운 Connection
획득을 대기하는
Connection
들에서 TimeOut
이 발생한다고 한다.
해결 방안
해당 문제의 해결 방안에 정답은 없다고 한다.
실제 업무에서는 모든 요청이 같은 요청인 경우보다 다른 요청도 동시에 발생할 것이기에
다른 로직에서 몇개의 Connection
를 사용할지는 모른다.
위와 같은 경우라면 Hikari Database Connection Pool
의 개수를 Worker Thread Pool
의 개수보다
여유롭게 설정한다면 해결될 것이다.
그런데 그렇다고 Hikari Connection
의 개수를 증가 시킨다면 해당 로직에서는 Connection
만 획득하고
Distributed Lock
획득 대기를 하는 Thread
만 증가하여 의미 없는 Connection
만 증가하고
DB
의 부하만 증가시킨다.
결구 해당 애플리케이션의 특성을 고려하여 적절히 Worker Thread Pool
의 개수와 Hikari Connection Pool
의 개수를 조절하여 사용해야 한다.

DataBase Lock
vs Distributed Lock
- 비관적 락(Pessimistic Lock)
- 장점:
- 데이터 일관성 보장
- 락을 걸기 때문에 다른 트랜잭션이 데이터에 접근할 수 없어 데이터 일관성이 유지된다.
- 충돌 방지
- 데이터를 동시 수정으로 인한 충돌을 예방할 수 있다. 이를 통해 복잡한 충돌 해결 로직을 피할 수 있다.
- 간단한 구현
- 데이터베이스의 기본 기능을 사용하여 구현할 수 있으므로, 별도의 외부 시스템을 사용하지 않아도 된다.
- 데이터 일관성 보장
- 단점:
- 성능저하
- 락으로 인한 대기 시간이 발생할 수 있어, 여러 트랜잭션이 동시에 진행될 때 Waiting으로 인한 DB 부하나
성능이 떨어질 수 있다.
- 락으로 인한 대기 시간이 발생할 수 있어, 여러 트랜잭션이 동시에 진행될 때 Waiting으로 인한 DB 부하나
- 자원소모
- 락을 유지하는 동안 해당 자원이 잠금 상태이므로, 시스템 자원을 소모할 수 있다.
- 성능저하
- 장점:
- 분산 락(Distributed Lock)
- 장점:
- 데이터 수정이 빈번하고 여러 인스턴스가 동시에 접근할 경우, 일관성을 보장한다.
- 락이 걸리면 다른 스레드는 대기하므로 데이터 충돌을 방지할 수 있다.
- 마이크로서비스 아키텍처와 같은 복잡한 시스템에서 사용된다.
- 단점:
- 분산 시스템에서 락을 관리하기 위한 오버헤드가 발생한다.
이로 인해 TPS가 낮아질 수 있다.
- 네트워크 지연이나 분산 시스템의 불안전성으로 인해 락 해제 실패 등의 이슈가 발생할 수 있다.
- 분산 시스템에서 락을 관리하기 위한 오버헤드가 발생한다.
- 장점:
정리하자면 수정이 많은 경우 비관락이 일반적으로 빠를 수 있다고 한다.
하지만, 충돌이 잦은 경우에는 분산락이 일관성을 보장하는데 유리하다고 한다.
'Spring > 동시성 & Lock' 카테고리의 다른 글
[캐시 & 동시성 & Lock] 선착순 티켓 시스템 고도화 - 2 (0) | 2024.11.21 |
---|---|
[캐시 & 동시성 & Lock] 선착순 티켓 시스템 고도화 - 1 (0) | 2024.11.19 |
[Spring Data] Redis Lock을 적용해보자 - 1 (0) | 2024.10.16 |
[Spring] synchronized 키워드를 활용한 동시성 문제 해결 및 한계 - 3 (0) | 2024.09.27 |
[Spring] synchronized 키워드를 활용한 동시성 문제 해결 및 한계 - 2 (0) | 2024.09.25 |