프로젝트를 진행하면서 동시성 제어가 필요한 기능을 만났다
'숙소'와 '쿠폰'은 재고를 가지고
숙소 예약을 생성할 때 숙소 재고 감소 & 쿠폰 재고 감소 가 발생하게 된다.
비관적 락은 충돌이 발생할 것이라고 가정하고, 트랜잭션 시작 시점에 락을 설정한다.
비관적 락은 데이터베이스에서 제공하는 락 기능을 사용하여 구현된다.
일반적으로 X 락을 사용하여 레코드에 대한 독점적인 접근을 보장하며 X 락이 설정된 레코드는 다른 트랜잭션에서 읽기나 쓰기 작업을 수행할 수 없다.
장점
단점
낙관적 락은 충돌이 발생하지 않을 것이라고 가정하고, 트랜잭션 종료 시점에 충돌 여부를 확인한다.
충돌이 발생한 경우 트랜잭션을 롤백한다.
낙관적 락은 버전 관리를 사용하여 구현된다.
레코드마다 버전 번호를 저장하고, 트랜잭션이 시작할 때 해당 레코드의 버전 번호를 가져온다.
트랜잭션이 종료될 때 해당 레코드의 버전 번호를 다시 확인하고, 만약 버전 번호가 변경된 경우 충돌이 발생한 것으로 판단하고 트랜잭션을 롤백한다.
장점
단점
분산 락은 데이터베이스에 직접 락을 거는 것이 아니라, 락을 관리하는 별도의 시스템을 사용하여 락을 관리한다. 주로 여러 대의 서버에서 동일한 데이터를 공유하는 경우에 사용된다.
분산락은 중앙 집중식 방식과 분산 방식으로 구현될 수 있다.
중앙 집중식 분산락은 하나의 서버에서 모든 락을 관리하는 방식이다. 중앙 서버는 각 서버의 상태를 모니터링하고, 락을 요청하는 트랜잭션을 승인 또는 거부한다.
분산 분산락은 각 서버에서 락을 관리하는 방식이다. 각 서버는 자체적으로 락을 관리하고, 다른 서버와의 락 충돌을 해결하기 위해 협력한다.
장점
단점
락 종류 | 충돌 발생 가정 | 락 설정 시점 | 락 설정 대상 | 장점 | 단점 |
---|---|---|---|---|---|
비관적 락 | 발생 | 트랜잭션 시작 | 레코드 | 충돌 확실히 방지, 데이터 정합성 보장 | 성능 저하, 비용 낭비 |
낙관적 락 | 발생하지 않음 | 트랜잭션 종료 | 레코드 | 성능 우수, 비용 절감 | 충돌 발생 시 롤백, 데이터 정합성 보장 어려움 |
분산 락 | 발생 | 트랜잭션 시작 | 레코드 | 분산 환경에서 데이터 정합성 보장 | 구현 복잡, 관리 어려움 |
DB 자체에 Lock을 거는 것은 성능이 저하되고 확장성을 고려하여 분산 락으로 결정했다.
분산 락은 보통 Redis 기반으로 구현하고 Redis와 통신하는 Java 라이브러리에는
Lettuce와 Redisson이 있는데 대부분은 Redisson을 사용한다. 이유가 뭘까?
Redis의 setnx() 명령어를 사용하여 락을 구현한다.
Spin Lock (만약 다른 스레드가 Lock 을 소유하고 있다면 그 lock이 반환될 때까지 계속 확인 하며 기다리는 것) 방식으로 Retry 로직을 개발자가 작성해야 한다.
장점
단점
Redis의 pub/sub() 메커니즘을 사용하여 락을 구현한다.
pub/sub() 메커니즘은 발행자(publisher)가 메시지를 발행하면 구독자(subscriber)가 해당 메시지를 수신하는 방식이다.
락이 해제되면 락을 subscribe 하는 클라이언트는 락이 해제되었다는 신호를 받고 락 획득을 시도하게 된다.
장점
단점
기능 | Lettuce | Redisson |
---|---|---|
락 획득 방식 | `setnx()` 명령어 | `pub/sub()` 메커니즘 |
장점 | 락을 획득하기 위해 경쟁이 발생하지 않고, 락을 획득하는 데 필요한 시간이 짧다. | 락을 획득하기 위해 경쟁이 발생하지 않고, 락이 해제되지 않은 상태로 장시간 유지되지 않는다. |
단점 | 락을 획득하지 못하면 무한 루프에 빠질 수 있고, 락이 해제되지 않은 상태로 장시간 유지될 수 있다. | `setnx()` 명령어를 사용하는 것보다 성능이 저하될 수 있다. |
성능 | 우수 | 보통 |
사용 편의성 | 어려움 | 용이 |
지원되는 기능 | Redis의 모든 기능 지원 | 일부 기능은 Lettuce만큼 완전하게 지원되지 않을 수 있다. |
일반 컴포넌트가 아닌 AOP로 관리되도록 구현한 이유에는 크게 3가지가 있다.
분산 락 처리 로직을 비지니스 로직과 분리
예약 비지니스 로직 자체가 복잡하기 때문에 코드 복잡성 및 가독성 측면에서
분산 락을 획득하고 해제하는 로직을 분리하고 싶었다.
재사용성 고려
숙소 재고 감소만 수행한다면 단일 메서드로 구현할 수도 있겠지만,
숙소 재고 감소/증가, 쿠폰 재고 감소/증가 총 4 곳에서 분산 락 로직이 필요하다.
확장성을 고려하면 더 여러 곳에서 분산 락이 필요해진다.
lockname, waitTime, leaseTime을 커스텀 하게 지정
상황에 따라 변할 수 있는 값들을 지정 가능하게 구현하여 변경 또는 확장에 유연하게 한다.
implementation 'org.redisson:redisson-spring-boot-starter:3.17.4'
@Configuration
public class RedissonConfig {
@Value("${spring.redis.host}") // yaml에 지정
private String redisHost;
@Value("${spring.redis.port}")
private int redisPort;
private static final String REDISSON_HOST_PREFIX = "redis://";
@Bean
public RedissonClient redissonClient() {
Config config = new Config();
config.useSingleServer()
.setAddress(REDISSON_HOST_PREFIX + redisHost + ":" + redisPort);
return Redisson.create(config);
}
}
Advice를 적용시키고 싶은 메서드에 해당 어노테이션을 달아주면 된다.
lockName은 필수로 지정하도록 했고, waitTime, leaseTime,timeUnit 는 default값일 지정해줬다.
leaseTime은 보통 락 획득 후 수행할 작업 소요시간보다 약간 많게 설정하고
waitTime은 leaseTime은 보다 길게 설정한다.
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Description("Concurrency control annotation to acquire a lock on the target resource.")
public @interface ConcurrencyControl {
/**
* The name of the target resource to acquire a lock on.
*/
String lockName();
/**
* The maximum time to wait for the lock to be available, in the specified time unit.
*/
long waitTime() default 5L;
/**
* The duration to hold the lock for, in the specified time unit.
*/
long leaseTime() default 3L;
/**
* The time unit for the waitTime and leaseTime values.
*/
TimeUnit timeUnit() default TimeUnit.SECONDS;
}
Aspect 모듈이 실행해야 하는 일과 JoinPoint 등을 작성한다.
@Around("@annotation(com.backoffice.upjuyanolja.global.concurrency.annotation.ConcurrencyControl)&&args(targetId)")
동작 시점은 Around로 메서드 호출 전, 후 모든 시점에서 동작하도록 지정한다
포인트컷에 위에 정의한 ConcurrencyControl 어노테이션을 지정하고, 메서드의 첫 번째 매개변수를 가져오도록 한다.
annotation과 매개변수를 이용하여 lockName을 설정하고 락 획득 시도를 한다.
락 획득에 성공하면 original method의 작업을 시작하고 종료 후 락을 해제한다.
@ConcurrencyControl 이 선언된 메서드는 Propagation.REQUIRES_NEW 옵션을 지정해
부모 트랜잭션의 유무에 관계없이 별도의 트랜잭션으로 동작하고, 트랜젝션 커밋 이후 락이 해제되도록 설정해야한다.
분산 락 트랜젝션이 부모와 동일한 트랜젝션에서 수행되면 부모 트랜젝션 종료 시점에 커밋 시점이 된다.
그러면 락은 해제되어도 커밋은 되지 않았기 때문에, 다른 스레드에서는 락을 획득할 수 있지만 변경 전의 데이터를 보게 된다.
즉, 동시성 환경에서 데이터 정합성이 보장되지 않는다.
따라서 데이터 정합성 보장을 위해서는 락의 해제가 트랜잭션 커밋보다 뒤에 이뤄져야 한다.
이를 위해 joinPoint를 넘겨 받아 새 트랜젝션에서 수행 시키는 TransactionAspect 클래스를 정의한다.
@Component
public class TransactionAspect {
// leaseTime 보다 트랜잭션 타임아웃을 작게 설정
// leaseTimeOut 발생 전에 rollback 시키기 위함
@Transactional(propagation = Propagation.REQUIRES_NEW, timeout = 2)
public Object proceed(final ProceedingJoinPoint joinPoint) throws Throwable {
return joinPoint.proceed();
}
}
@Slf4j
@Aspect
@Component
@RequiredArgsConstructor
public class ConcurrencyAspect {
private final RedissonClient redissonClient;
private final TransactionAspect transactionAspect;
@Around("@annotation(com.backoffice.upjuyanolja.global.concurrency.annotation.ConcurrencyControl)&&args(targetId)")
public Object handleConcurrency(ProceedingJoinPoint joinPoint, Long targetId) throws Throwable {
Object result;
// Get annotation
ConcurrencyControl annotation = getAnnotation(joinPoint);
// Get lock name and acquire lock
String lockName = getLockName(targetId, annotation);
RLock lock = redissonClient.getLock(lockName);
try {
boolean available = lock.tryLock(annotation.waitTime(), annotation.leaseTime(),
annotation.timeUnit());
if (!available) {
log.warn("Redisson GetLock Timeout {}", lockName);
throw new IllegalArgumentException();
}
log.info("Redisson GetLock {}", lockName);
// Proceed with the original method execution
return transactionAspect.proceed(joinPoint);
} finally {
try {
lock.unlock();
} catch (IllegalMonitorStateException e) {
log.warn("Redisson Lock Already UnLock {}", lockName);
}
}
}
private ConcurrencyControl getAnnotation(ProceedingJoinPoint joinPoint) {
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
Method method = signature.getMethod();
return method.getAnnotation(ConcurrencyControl.class);
}
private String getLockName(Long targetId, ConcurrencyControl annotation) {
String lockNameFormat = "lock:%s:%s";
String relevantParameter = targetId.toString();
return String.format(lockNameFormat, annotation.lockName(), relevantParameter);
}
}
@Service
public class StockService {
@ConcurrencyControl(lockName = "roomStock")
public void decreaseRoomStock(Long id) {
}
@ConcurrencyControl(lockName = "couponStock")
public void decreaseCouponStock(Long id) {
}
}
이렇게 동시성 제어를 위한 AOP 구현이 완료되었다.
추가로 이 AOP를 적용하면서 발생했던 이슈는 다음 글에서 다뤄보려고 한다!
발생했던 이슈 보러가기!