반응형
비관적 락이란?
개요
- 비관적 락(Pessimistic Lock)은 DB 단계에서 관리하는 락으로, 이 방식을 사용하면 select 된 데이터를 잠가 다른 트랜젝션이 Update할 수 없도록 제한합니다.
- 락에는 Write Lock과 Read Lock이 있으며, Write Lock을 걸었을 경우 다른 트랜젝션이 Read와 Write가 모두 불가하고, Read Lock의 경우 읽기는 가능하나 쓰기가 불가능합니다. Write Lock의 경우 SELECT 쿼리 뒤에 FOR UPDATE 가, Read Lock의 경우 FOR SHARE 가 붙게됩니다.
- JPA에서는 @Lock(LockModeType.PESSIMISTIC_WRITE) 와 @Lock(LockModeType.PESSIMISTIC_READ) 로 구현이 가능합니다.
비관적 락의 범위
- 비관적 락은 크게 Row-Level Locking, Gap Lock으로 구분됩니다.
- Row Level Lock
- Row Level Lock은 테이블의 Row마다 걸리는 Lock으로, Write Lock과 Read Lock이 존재합니다.
- 컬럼을 몇개를 가져오든 행 레벨의 잠금은 행단위로 이루어 지게 됩니다.
- Gap Lock
- Index의 gap에 걸리는 락으로, Gap은 index 중 DB에 실제 record가 없는 부분입니다.
- 예를 들어 id에 대한 index가 존재하고 id가 3과 7인 행이 있다고 하면, id ≤ 2, 4 ≤ id ≤ 6, id ≥ 8인 부분에는 gap이 생기게 됩니다. 이러한 gap에 대해 접근을 하는 것을 막는 기능을 합니다.
- MySQL에서 Row Level Lock은 인덱스 단위로 걸리게 됩니다. 따라서 where절에 Index를 사용하지 않는 컬럼에 대해 락을 걸게 된다면, Optimizer가 인덱스 스캔을 하지 못할것이고 row-level lock을 사용할 수 없게 됩니다. 따라서 이 경우 테이블 전체에 락을 걸게 됩니다.
낙관적 락이란?
- 낙관적 락은 Application Level에서 관리하는 락으로, Commit하는 시점에 SELECT 시점의 Version과 Commit하는 시점의 VERSION을 비교해 VERSION이 같다면 정상 커밋, 실패한다면 Exception을 발생시키는 방식으로 동작합니다.
- JPA에서 @Lock(value = LockModeType.*OPTIMISTIC*) 으로 구현이 가능합니다.
- 낙관적 락을 사용하다 버젼 충돌로 인해 Exception이 발생하게 된 경우, 아래의 AOP를 적용해 재시도가 가능합니다.
- 직접 구현하기
- 먼저 낙관적 락 Retry 시도는 핵심 비즈니스 로직이 아닌 낙관적 락 메서드에 공통 적용되는 횡단 관심사이므로 AOP를 사용해 구현합니다.
- 아래와 같이 어노테이션과 Aspect를 정의합니다.
- 직접 구현하기
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface Retry {
int maxRetries() default 1000;
int retryDelay() default 100;
}
@Order(Ordered.LOWEST_PRECEDENCE - 1)
@Aspect
@Component
public class OptimisticLockRetryAspect {
@Around("@annotation(retryAnnotation)")
public Object retryOptimisticLock(ProceedingJoinPoint joinPoint, Retry retry) throws Throwable {
Exception exceptionHolder = null;
for (int attempt = 0; attempt < retry.maxRetries(); attempt++) {
try {
return joinPoint.proceed();
} catch (OptimisticLockException | StaleObjectStateException e) {
exceptionHolder = e;
Thread.sleep(retry.retryDelay());
}
}
throw exceptionHolder;
}
}
- Aspect 코드를 살펴보자면, Aspect가 적용된 코드에 대해 maxRetries 만큼 시도를 하며, OptimisticLockException이 발생한 경우 retryDelay만큼 대기했다가 다시 시도하는 것을 볼 수 있습니다.
- 라이브러리 적용하기
- 위 기능을 구현한 라이브러리가 존재합니다.
implementation 'org.springframework.retry:spring-retry'
implementation 'org.springframework:spring-aspects'
그 후 Main 클래스에 아래와 같이 추가해줍니다.
@EnableRetry
@SpringBootApplication
public class LockApplication {
public static void main(String[] args) {
SpringApplication.run(LockApplication.class, args);
}
}
그러면 아래와 같이 처리가 가능합니다.
@Transactional
@Retryable(
retryFor = {ObjectOptimisticLockingFailureException.class},
maxAttempts = 1000,
backoff = @Backoff(100),
recover = "onFail"
)
public void ticketing(long ticketId) {
Ticket ticket = ticketRepository.findById(ticketId)
.orElseThrow(() -> new IllegalArgumentException("Ticket Not Found."));
ticket.increaseReservedAmount();
int sequence = ticket.getReservedAmount();
reservationRepository.save(new Reservation(ticket, sequence));
}
@Recover
public void onFail(){
// ...
}
여기서 retryFor은 메서드에서 특정 Exception을 Catch해서 retry하겠음을 의미하고, maxAttempts는 최대 시도 횟수, backOff는 대기 시간을 의미합니다.
recover은 maxAttempts 만큼 시도했는데도 실패한 경우에 실행되는 메서드를 정의하는 부분입니다. 이때 recover에 사용할 메서드는 반드시 recover이 있어야 합니다.
Redis를 사용한 Lock
- Redis를 사용해서 Lock의 구현이 가능합니다.
- 가장 간단한 방법은 아래와 같이 Redis에 특정 키를 확인하고 키가 없으면 키를 세팅, 있으면 대기하는 방식으로 구현이 가능합니다.
- 직접 구현하기
@Component
public class Lock {
private final StringRedisTemplate redisTemplate;
public Lock(StringRedisTemplate redisTemplate) {
this.redisTemplate = redisTemplate;
}
public boolean lock(String key, long ttl) {
String value = redisTemplate.opsForValue().get(key);
if (value != null) {
return false;
}
redisTemplate.opsForValue().set(key, "locked", ttl, TimeUnit.MILLISECONDS);
return true;
}
public void unlock(String key) {
redisTemplate.delete(key);
}
}
- 하지만 이러한 코드는 redisTemplate.opsForValue().get(key)와 redisTemplate.opsForValue().set(key, "locked", ttl)이 원자적으로 동작하지 못하기 때문에 race condition이 발생할 수 있습니다.
- 따라서 Redis에서 제공하는 SETNX 연산을 활용해 Atomic하게 get과 set을 구현해야 합니다.
- SETNX는 Set if Not Exist의 줄임말로, 키가 존재하지 않은 경우 값을 지정하는 방식으로 동작합니다.
@Component
public class Lock {
private final StringRedisTemplate redisTemplate;
public Lock(StringRedisTemplate redisTemplate) {
this.redisTemplate = redisTemplate;
}
public boolean lock(String key, long ttl) {
Boolean success = redisTemplate.opsForValue().setIfAbsent(key, "locked", ttl, TimeUnit.MILLISECONDS);
return success != null && success;
}
public void unlock(String key) {
redisTemplate.delete(key);
}
}
Redisson에서 제공하는 Lock Interface 사용하기
- 위에서 구현한 방식은 Redis에게 지속적으로 락을 획득하기 위한 요청을 계속 보내는 스핀락 형식으로 동작을 하게 되는데, 이 방식은 요청이 많을 수록 Redis가 받는 부하가 커집니다.
- 이에 대비해 Redisson은 Pub/Sub 방식을 사용하기에 락이 해제되면 락을 subscribe하는 클라이언트에게 신호를 전달해 줄수 있게 됩니다.
- 아래는 이를 활용한 코드입니다.
@Service
@RequiredArgsConstructor
@Slf4j
public class DistributedLockService {
private static final String REDISSON_LOCK_PREFIX = "LOCK:";
private final RedissonClient redissonClient;
public boolean lock(String dynamicKey, long waitTime, long leaseTime, TimeUnit timeUnit) throws Throwable {
String key = REDISSON_LOCK_PREFIX + dynamicKey;
RLock rLock = redissonClient.getLock(key);
try {
boolean available = rLock.tryLock(waitTime, leaseTime, timeUnit);
return available;
} catch (InterruptedException e) {
throw new InterruptedException();
}
}
public void unlock(String dynamicKey){
String key = REDISSON_LOCK_PREFIX + dynamicKey;
RLock rLock = redissonClient.getLock(key);
try {
rLock.unlock();
} catch (IllegalMonitorStateException e) {
log.info("Redisson Lock Already UnLock {} {}",
kv("serviceName", "executeWithLock"),
kv("key", key)
);
}
}
}
이 부분에서 rLock.tryLock은 Pub/Sub 방식으로 동작하며, Redis에서 이벤트를 발생시킬때 까지 Blocking 상태로 대기하게 됩니다.
AOP로 Lock 적용하기
- 락을 얻는 로직은 락을 사용하는 메서드의 횡단 관심사이므로 AOP로 분리가 가능합니다.
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface DistributedLock {
/**
* 락의 이름
*/
String key();
/**
* 락의 시간 단위
*/
TimeUnit timeUnit() default TimeUnit.SECONDS;
/**
* 락을 기다리는 시간 (default - 5s)
* 락 획득을 위해 waitTime 만큼 대기한다
*/
long waitTime() default 5L;
/**
* 락 임대 시간 (default - 3s)
* 락을 획득한 이후 leaseTime 이 지나면 락을 해제한다
*/
long leaseTime() default 3L;
}
@Aspect
@Component
@RequiredArgsConstructor
@Sl4j
public class DistributedLockAop {
private static final String REDISSON_LOCK_PREFIX = "LOCK:";
private final RedissonClient redissonClient;
private final AopForTransaction aopForTransaction;
@Around("@annotation(com.kurly.rms.aop.DistributedLock)")
public Object lock(final ProceedingJoinPoint joinPoint) throws Throwable {
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
Method method = signature.getMethod();
DistributedLock distributedLock = method.getAnnotation(DistributedLock.class);
String key = REDISSON_LOCK_PREFIX + CustomSpringELParser.getDynamicValue(signature.getParameterNames(), joinPoint.getArgs(), distributedLock.key());
RLock rLock = redissonClient.getLock(key);
try {
boolean available = rLock.tryLock(distributedLock.waitTime(), distributedLock.leaseTime(), distributedLock.timeUnit());
if (!available) {
return false;
}
return aopForTransaction.proceed(joinPoint);
} catch (InterruptedException e) {
throw new InterruptedException();
} finally {
try {
rLock.unlock();
} catch (IllegalMonitorStateException e) {
log.info("Redisson Lock Already UnLock {} {}",
kv("serviceName", method.getName()),
kv("key", key)
);
}
}
}
}
- 여기서 CustomSpringParser은 key 값으로 유연한 인자의 전달을 위해 Spring Expression Language를 지원하도록 사용합니다.
- AopForTransaction은 락을 얻고 해제하는 과정이 트랜잭션을 포함하도록 해줍니다.
참고 자료
반응형
'Spring' 카테고리의 다른 글
스프링의 핵심, IoC/DI, AOP, PSA - IoC/DI 편 (0) | 2024.11.26 |
---|---|
Spring + ELK로 로그 시스템 구축하기 (1) | 2024.11.16 |
락을 활용한 동시성 처리 (0) | 2024.11.15 |
War와 Jar (0) | 2024.11.15 |
Spring과 Spring Boot의 차이점 (0) | 2024.11.15 |