📍 오늘의 학습 키워드
JMeter
동시성 제어
쿠폰 발급 기능의 문제점
- 선착순 쿠폰에 있어서 동시에 유저가 접근하는 경우도 당연히 고려해야했다.
- 또한, 쿠폰 발급에 접근한 선착순대로 쿠폰이 발급되어야 한다.
- Jmeter를 활용하여 동시에 많은 유저가 접근하는 경우를 테스트 했다.
- 선착순 쿠폰이 500개의 재고가 있다고 가정할 때, 505명의 유저가 동시에 접근했다고 가정하고 테스트 했다.
시나리오
- 505명의 유저는 모두 회원가입 된 상황
1. 로그인: API 호출하여 Access Token 받기
2. 쿠폰 발행 : 쿠폰 발행 API 호출하면서 1에서 받은 Access Token을 헤더로 전달
예상 결과
- 500개 수량의 쿠폰에 505명의 유저가 접근했으니 50개의 발행 쿠폰이 만들어지고 쿠폰의 수량은 0이 된다. 500번째 까지는 순차적으로 쿠폰 발행이 성공하고 501-505번째까지는 쿠폰 발행이 실패한다.
📖 JMeter
로그인 API
- 로그인을 위해 505명의 유저 정보를 텍스트 파일로 준비한다.
- 테스트를 위해 Test Plan에 User Defined Variables를 추가하고 username, password를 변수로 선언한다.
- CSV Data Set Config 추가
- HTTP Request Defaults 추가
- HTTP Header Manager 추가 (토큰이 필요한 경우 추가한다.)
- Thread Group 추가
- 505명의 유저를 테스트 하기 때문에 505개의 threads를 입력한다.
- HTTP Request 추가 (HTTP Request - 로그인)
- Regular Expression Extractor 추가 (토큰 추출)
- 로그인할 때 토큰을 추출하기 위해서 추가한다.
- 결과를 보기 위해 Summary Report, View Results Tree, Graph Results를 추가한다.
쿠폰 발행 API
- HTTP Request 추가 (HTTP Request - 쿠폰 발행)
테스트 결과
- 그러나 예상 결과와는 달랐다.
- Jmeter를 사용해서 500개의 쿠폰에 505명의 유저가 접근했을 때 dedlock이 발생한 것을 알 수있다.
- 또한, 500개의 쿠폰에 505개의 유저가 접근했으면 0개가 남아야 제대로 작동했다는 것인데 11개가 남은 것을 확인할 수 있다.
- 한번 더 테스트를 실행했더니 이번에는 24개가 남은 것을 확인할 수 있다.
Deadlock(데드락)
- Deadlock에 대해서는 우선, 아래만큼 알아보고 다음에 더 자세히 알아보려고 한다.
Deadlock 이란?
- 다수의 프로세스나 스레드가 서로가 점유한 자원을 대기하면서 진행이 멈춰버리는 상태를 가리킨다.
- 다시 말해, 서로의 자원을 대기하면서 상호간의 락(lock)을 걸고 있어 더 이상 진행할 수 없게 되는 상황을 말한다.
Deadlock 발생 원인
- 데드락은 아래 네 가지 조건이 동시에 충족될 때 발생한다.
- 상호 배제(Mutual Exclusion): 자원이 한 번에 하나의 스레드만 사용할 수 있음을 보장한다.
- 점유 대기(Hold and Wait): 스레드가 이미 보유한 자원을 가진 채 다른 자원을 기다린다.
- 비선점(No Preemption): 스레드가 다른 스레드가 보유한 자원을 강제로 해제할 수 없다.
- 순환 대기(Circular Wait): 두 개 이상의 스레드가 자원을 보유하고, 각 스레드가 다음 스레드가 보유한 자원을 기다린다.
Deadlock 과 동시성 문제
- Deadlock은 동시성 문제의 한 종류이기 때문에 Deadlock이 발생했으면 동시성 제어가 필요하다.
📖 동시성 제어
동시성 제어를 해야하는 이유
- Race Condition
- 두 개 이상의 스레드가 동시에 같은 데이터를 접근하여 값을 변경하고자 할 때, 데이터의 예상치 못한 변경이 발생할 수 있다.
- Dead Lock
- 두 개 이상의 스레드가 서로의 작업이 완료될 때까지 기다리면서 결국 아무도 완료되지 않는 문제가 발생할 수 있다.
- Data Corruption
- 두 개 이상의 스레드가 동시에 같은 데이터에 접근하여 값을 변경할 때, 예상치 못한 데이터의 변형이 발생할 수 있다.
동시성 제어 - Lock
Lock
- 한 번에 하나의 스레드만이 자원에 접근할 수 있도록 하기 위해 lock(락)을 건다.
- 여기서 lock은 상호 배제 메커니즘
Lock을 활용한 동시성 제어 기법
비관적 락 (Pessimistic Lock)
- 주로 데이터베이스에서 사용된다.
- 비관적 락은 데이터를 읽고 변경하기 위해 락을 획득하는 방식이다. 즉, 동시에 여러 스레드가 데이터를 변경할 수 없다.
- 데이터를 읽을 때나 변경할 때 락을 획득하고, 작업이 완료될 때까지 락을 유지한다.
- 데이터를 읽을 때 다른 스레드가 데이터를 변경할 수 없도록 방지하며, 데이터를 변경할 때 다른 스레드의 접근을 차단한다.
- 주로 동시에 데이터를 변경할 가능성이 높은 상황에서 사용된다.
- 시간은 없는데 빠르게 락을 구현하고 싶을 때 사용한다.
낙관적 락 (Optimistic Lock)
- 낙관적 락은 동시에 여러 스레드가 데이터를 읽고 변경할 수 있도록 허용하며, 변경 시에 충돌을 방지하기 위해 버전 관리를 사용한다.
- 데이터를 읽을 때 락을 획득하지 않고, 데이터를 변경하기 전에 충돌을 검사합니다.
- 주로 두 단계로 구성된다.
- 데이터를 읽을 때 버전 정보를 가져옵니다.
- 데이터를 변경할 때 이전에 읽은 버전과 현재 버전을 비교하여 충돌을 검사합니다.
- 충돌이 발생하지 않으면 변경된 내용을 적용하고, 충돌이 발생하면 롤백하거나 다시 시도한다.
- 주로 데이터의 읽기가 많은 상황이나 충돌이 적은 상황에서 사용된다.
- 트랜잭션을 사용하지 않기 때문에 비관적 락에 비해 성능이 좋으나, 충돌이 날 가능성이 있고 이때, 복구가 힘들다.
분산 락 (Distributed Lock)
- 분산 시스템에서 여러 노드 간에 공유 자원에 대한 접근을 조정하기 위해 사용되는 동시성 제어 메커니즘이다.
- 여러 프로세스나 스레드가 동일한 자원에 동시에 접근하는 상황에서 데이터 일관성을 유지하고, 경쟁 조건을 방지하기 위해 사용된다.
- 분산 락은 네트워크를 통해 통신하며 여러 노드 간에 락을 동기화 한다. 일반적으로 중앙화된 락 관리 서비스를 사용하여 분산 락을 구현한다.
- 특징:
- Atomicity(원자성): 분산 락은 락을 획득하거나 해제하는 동작이 원자적으로 수행되어야 한다. 즉, 락을 획득하거나 해제하는 동안 다른 클라이언트가 동시에 락을 획득하거나 해제할 수 없어야 한다.
- Consistency(일관성): 분산 락은 여러 노드 간에 일관성 있는 락의 상태를 유지해야 한다. 모든 클라이언트는 락을 동일하게 인식하고, 동일한 락에 대한 접근 권한을 가지게 된다.
- Availability(가용성): 분산 락은 시스템의 가용성을 보장해야 한다. 즉, 락 서비스가 항상 동작 가능해야 하며, 장애가 발생한 경우에도 시스템의 정상적인 동작을 지원해야 한다.
- Partition Tolerance(분할 내구성): 분산 시스템에서는 네트워크 파티션과 같은 분할 상황이 발생할 수 있다. 분산 락은 이러한 분할 상황에서도 정상적으로 동작하고 데이터 일관성을 유지할 수 있어야 한다.
- 주로 Redis를 활용하기에 분산 락을 사용하는 것은 Redis를 사용하는 것이라고 생각해도 무방하다.
- Redis를 사용하여 분산 락이 가능한 이유는 크게 두 가지이다.
- Memory DB 이다.
- 디스크를 사용하는 RDBMS 등의 DB 보다 최소 수만배, 혹은 수십만배 이상 빠르다.
- 싱글 스레드이다.
동시성 제어 - 분산 락 구현(Redis)
EventServiceImpl.java
private final RedissonClient redissonClient;
private static final String LOCK_KEY = "couponLock";
public void issueCoupon(Long eventId, Long couponId, User user, LocalDateTime now) {
RLock lock = redissonClient.getFairLock(LOCK_KEY);
try {
boolean isLocked = lock.tryLock(10, 60, TimeUnit.SECONDS);
if(isLocked) {
try {
if(issuedCouponRepository.existsByCouponIdAndUserId(couponId, user.getId())) {
throw new InvalidCouponException(INVALID_COUPON);
}
Event event = findEvent(eventId);
if(now.isBefore(event.getOpenAt())) {
throw new InvalidCouponException(INVALID_COUPON);
}
Coupon coupon = findCoupon(couponId);
if (coupon.getQuantity() <= 0) {
throw new InvalidCouponException(INVALID_COUPON);
}
coupon.decrease();
couponRepository.save(coupon);
IssuedCoupon issuedCoupon = new IssuedCoupon(user, coupon);
issuedCouponRepository.save(issuedCoupon);
} finally {
lock.unlock();
}
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
- 동시성 제어를 구현하려면 @Transactional 을 적용 해제해야한다.
- @Transactional을 유지한채로 동시성 제어를 해보니 데드락이 여전히 발생했기 때문이다.
결과
- 순차대로 쿠폰 발행이 이루어진 것을 확인할 수 있고 쿠폰 수량이 0개 남은 것을 확인할 수 있다.
- 이로써, 동시성 제어가 되어 예상 결과와 똑같은 결과를 얻은 것을 확인할 수 있다.
💡 느낀점
- 최종 프로젝트에서 분산 락을 이용하여 동시성 제어를 하면서 아래 3가지에 대해서 더 알아보면 좋을 것 같다.
- Deadlock
- 비관적 락, 낙관적 락을 이용한 동시성 제어
- 트랜잭션