예약 구매 시스템 프로젝트를 진행하면서 재고 처리를 구현하던 중 동시성 문제를 해결하기 위해 학습하고 시도한 내용입니다.
동시성 문제
동시성 문제란 동시에 여러 쓰레드가 하나의 자원에 접근하면서, 서로의 작업을 간섭하게는 문제이다. 이로 인해, 예상한 값과 다른 결과가 나올 수 있다.
테스트 시나리오
- 예약 구매 상품 A가 있으며, 초기 재고 수량은 10개이다.
- 해당 상품을 구매하려고 10000명의 사용자가 접근한다.
위 상황에서 결제 화면에 진입하면 재고 수량을 감소시키고, 결제 프로세스 진행 중 어떠한 이유로 이탈한다면 재고 수량을 증가 시켜야 한다. 이때, 여러 쓰레드가 동시에 접근하면서 결제 완료된 재고 수량이 10개를 초과하는 문제가 발생하였다.
해결하기
데이터베이스 락
데이터베이스에 직접 Lock을 적용하는 방법으로 각 Lock 별로 적용되는 범위가 다르다.
Table Lock
- 테이블 단위로 Lock을 생성한다.
- READ 옵션을 사용하면 데이터를 읽을 수 있지만, CUD(Create, Update, Delete) 작업은 할 수 없다.
- WRITE 옵션을 사용하면 데이터에 대한 CRUD 작업이 불가능하다.
Row Level Lock
- 테이블의 데이터 row 단위로 Lock을 생성한다.
- 상태 값으로 확인할 수 없으며, 락이 걸릴 경우 수정이 안되면 로우 레벨 락이 수행되었다고 판단한다.
- S LOCK 옵션은 공유 락을 설정하고, X LOCK 옵션은 배타적 락을 설정한다.
Record Lock
- 인덱스가 존재할 경우 인덱스 테이블에 LOCK이 생성된다.
Gap Lock
- 조건에 해당하는 범위 내에서 비어있는 영역을 락하는 경우 생성된다.
Next-Key Lock
- 레코드 락 + 갭 락으로, 범위에 존재하는 데이터에는 '레코드 락'이 발생하고 존재하지 않는 범위에는 '갭 락'이 생성된다.
위의 방법들로는 서비스 가용성을 확보하면서 동시성 제어를 직접 구현하기 힘들다는 단점이 있다.
직접 제어할 수 있는 방법은 크게 쿼리를 작성하여 직접 LOCK을 제어하는 방법과, 애플리케이션 안에서 제어하는 방법이 있다.
쿼리를 통한 동시성 제어
Shared Lock (S Lock)
- 한 트랜잭션이 특정 자원에 공유 락을 설정하면, 다른 트랜잭션도 동일한 자원에 대한 읽기 작업을 허용한다.
- 즉, 여러 트랜잭션이 동시에 해당 자원을 읽을 수 있지만, 쓰기 작업은 허용하지 않는다.
Exclusive Lock (X Lock)
- 특정 트랜잭션이 배타적 락을 설정하면, 다른 트랜잭션은 동시에 해당 자원을 읽거나 쓰지 못한다.
Intent Lock
- 인텐트 락은 락의 의도를 나타내는 표시 역할로 사용된다.
애플리케이션을 통한 동시성 제어
낙관적 락
- JPA를 사용한다면, LockMode.OPTIMISTIC 으로 낙관적 락을 구현할 수 있으며, Entity 객체에 @Version 필드가 있어야 한다.
- But, 동시성 제어를 낙관적 락으로 하기에는 한계가 있다.
- 충돌시 예외 처리를 직접 구현해야 한다.
- 재시도를 해야한다면 DB I/O가 발생하여 부하가 증가한다.
- FK가 존재하는 테이블을 수정할 경우 데드락이 발생할 수 있다.
비관적 락
- JPA를 사용한다면 LockMode.PESSIMSTIC으로 구현할 수 있으며, 다른 트랜잭션은 BLOCKING 된다.
- 그래서 timeout을 설정하지 않으면 DB에 설정된 시간동안 다음 트랜잭션이 대기하게 된다.
- 동시성을 확실히 보장할 수 있다. But, 요청이 Blocking 되어 성능이 저하될 수 있고, 여러 테이블에 Blocking이 된다면 데드락이 발생할 수 있다.
- e.g. A가 테이블 a의 lock 획득, B가 테이블 b의 lock 획득, A가 테이블 b의 lock 획득 시도 (실패), B가 테이블 a의 lock 획득 시도 (실패)
분산락
- 경쟁 상황(Race Condition)이 발생할 때, 데이터의 정합성을 지키기 위해 사용한다.
- Java에서는 'synchronized'라는 키워드를 통해 하나의 스레드만 접근할 수 있도록 동기화 기능을 제공하지만, 다중 서버에서는 synchronized 만으로 동시성 이슈를 해결할 수 없기 때문에 분산락을 사용한다.
- Redis, MySQL, Zookeeper 등을 이용해 구현할 수 있다.
Redis의 Redisson 라이브러리를 사용한 이유
Named Lock을 사용하기 위해서는 별도의 커넥션 풀을 관리해야 하고, 락에 관련된 부하를 RDBMS에서 받아야 하기 때문에 Redis가 더 효율적이라고 생각했다.
Redisson vs Lettuce
- Redisson은 별도의 Lock Interface를 지원하기 때문에, 락을 설정할 수 있는 메서드를 사용하여 timeout 등을 쉽게 설정할 수 있다.
- Lettuce는 setnx, setex 명령어를 이용해 지속적으로 락이 해제되었는지 확인해야하는 스핀락 방식으로 동작하지만, Redisson은 Pub/Sub 방식을 이용하기 때문에 요청이 많아져도 Redis가 받는 부하가 커지지 않는다.
Redisson을 사용하기로 결정하고, 필요한 메서드 마다 lock을 적용하기 쉽도록 어노테이션 기반으로 AOP를 이용해 분산락 컴포넌트를 만들었다.
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface StockLock {
}
@Aspect
@Component
@RequiredArgsConstructor
public class StockLockAspect {
private final RedissonClient redissonClient;
@Around("@annotation(com.nayoon.stock_service.common.lock.StockLock)")
public Object applyStockLock(ProceedingJoinPoint joinPoint) throws Throwable {
Long productId = (Long) joinPoint.getArgs()[0];
RLock lock = redissonClient.getLock(String.format("stock:productId:%d", productId));
try {
boolean available = lock.tryLock(100, 10, TimeUnit.SECONDS);
if (!available) {
throw new RuntimeException("Failed to acquire lock for product: " + productId);
}
return joinPoint.proceed();
} finally {
lock.unlock();
}
}
}
그리고 사용할 메서드 위에 @StockLock 어노테이션을 사용하면 해당 메서드에 Redisson이 적용된다.