Spring

동시성제어(1) - Redis

jungmin.park 2023. 12. 12. 00:03

 

레이스 컨디션(race condition)

 

@Service
public class ApplyService {
	...
    
	public void apply(Long userId){
		long count = couponRepository.count();

		if(count > 100){
			return;
		}
		couponRepository.save(new Coupon(userId));
	}
}

 

TestCode

 

@Test
	public void 여러명응모() throws InterruptedException {
		int threadCount = 1000;
		ExecutorService executorService = Executors.newFixedThreadPool(32);
		//다른스레드에서 수행해주는 작업을 기다려주는 클래스
		CountDownLatch latch = new CountDownLatch(threadCount);

		for( int i = 0; i < threadCount; i++){
			long userId = i;
			executorService.submit(() -> {
				try{
					applyService.apply(userId);
				}finally {
					latch.countDown();
				}
			});
		}

		latch.await();
		long count = couponRepository.count();
		assertThat(count).isEqualTo(100);
	}

 

수행결과는 다음과 같다

 

 

두 개 이상의 쓰레드가 공유데이터에 액세스를 하고 동시에 작업을 하려고 할 때 발생하는 문제이다.

Thread-1 Coupon count  Thread-2
(1) select count(*)
from coupon
99  
  99 (2) select count(*)
from coupon
create coupon 100  
  101 create coupon

 

  • (1) Thread-1이 생성된 쿠폰의 갯수를 가져가고
  • (2) Thread-1이 쿠폰을 생성하기 전에 Thread-2가 생성된 쿠폰의 갯수를 가져가게된다.
    • 따라서 Thread-2가 생성된 쿠폰을 가져가는 값도 99가 되는것이다.
  • 결국 Thread-2도 쿠폰을 생성하게 된다.

이 문제를 해결하기 위해서 싱글스레드 개념을 도입한다.

싱글스레드로 작업하면 레이스 컨디션이 일어나지 않을 것이다.

하지만 주의해야 할 사항이 있다.

 

  • 쿠폰 발급 로직 전체를 싱글 스레드로 작업을 하게 된다면 성능이 좋지 않다.
    • 먼저 요청한 사람의 쿠폰이 발급된 이후에 다른 사람들의 쿠폰 발급이 가능해지기 때문이다.
    • 10시 1분에 1번 사용자 요청 10시 2분 발급완료 이 과정이라면 2번 사람은 2분이후에 요청이 가능해진다.
  • 자바의 Synchronized 사용
    • 서버가 여러대가 된다면 레이스 컨디션이 다시 발생하므로 적절하지 않다.
  • MySQL, Redis를 활용한 락을 구현해서 해결가능한가
    • 쿠폰 개수를 가져오는 것부터 쿠폰을 생성할 때까지 락을 걸어야 한다. 락을 거는 구간이 길어져서 성능에 좋지 않다.
  • 이 로직의 핵심은 쿠폰의 정합성이다.

Redis incr을 사용

incr은 싱글 스레드 방식으로 Key에 대한 Value를 1씩 증가시키는 명령어이며 성능도 빠른 명령어이다.

 

cli에서 실행해보기

docker ps 
docker run --name myredis -d -p 6379:6379 redis
incr coupon_count

 

 

 

CouponCountRepository

@Repository
public class CouponCountRepository {
	private final RedisTemplate<String, String> redisTemplate;

	public CouponCountRepository(RedisTemplate<String, String> redisTemplate) {
		this.redisTemplate = redisTemplate;
	}

	public Long increment(){
		return redisTemplate
			.opsForValue()
			.increment("coupon_count"); //키 값
	}
}

 

ApplyService

public void apply(Long userId){
		Long count = couponCountRepository.increment();

		if(count > 100){
			return;
		}
		couponRepository.save(new Coupon(userId));
	}

 

TestCode로 실행해보면 성공한다.

 

시간 Thread-1 Redis - count count Thread-2
10:00 start - 10 : 00


incr coupon_count
end - 10 : 02
99  
10:01 99 wait...
10:02 100 wait..
10:03 create coupon 101 start - 10: 02
incr coupon_count
end - 10 : 03
    101 failed create coupon
  • Redis는 싱글 스레드 기반으로 동작하기 때문에, 스레드 1에서 10시에 쿠폰 카운트를 증가시키는 명령어를 실행
  • 10시 2분에 완료가 된다고 했을때 Thread2에서 10시 1분에 쿠폰 카운트를 증가시키는 명령어를 실행한다
    • Thread 1의 작업이 모두 종료가 될 때까지 기다렸다가 10시 2분에 작업을 시작
  • 따라서 Thread에서는 언제나 최신값을 가져갈 수 있기 때문에 쿠폰이 100개보다 많이 생성되는 현상은 발생하지 않게 된다.

 


과연 동시성 제어가 제대로 잘되고 있는 것일까?

 

문제1. 다른 서비스에 영향이 갈 수 있다.

현재 로직은 쿠폰 발급 요청이 들어오면 레디스를 활요해서 쿠폰의 발급 개수를 가져온 후 발급이 가능하다면 rds에 저장하는 방식이다.

이 방식은 쿠폰의 개수가 많아질수록 RDB에 부하를 주게 되는데

 

예시로

MySQL이 1분에 100개 insert 작업만 가능하다고 가정한다.

이 상태에서 10시에 만개의 쿠폰 생성 요청이 들어오고 10시 1분에 주문 생성 요청, 10시 2분에 회원 가입 요청이 들어온다면?

1분에 100개씩 만개를 생성하려면 100분이 걸리게 된다.

 

주문생성과 회원가입은 100분 이후에 생성이 된다.

 

문제2. DB서버의 부하로 서비스 지연 및 오류로 이어질 수 있다. 

만약 다른 서비스에 타임아웃이 없다면 느리게라도 모든 요청이 처리가 되겠지만 대부분의 서비스에는 타임아웃 옵션이 설정되어있다.

그렇기 때문에 주문 회원가입 뿐만 아니라 일부분의 쿠폰도 생성이 되지 않는 오류가 발생가능하다.

 

어떻게 해결해야할지 다음편에 계속