기맹기 개발 블로그

동시성 이슈를 해결하는 방법들 본문

Spring

동시성 이슈를 해결하는 방법들

기맹기 2023. 3. 4. 05:12

이 글은 인프런 강의 재고시스템으로 알아보는 동시성이슈 해결방법 을 수강하고 정리한 내용입니다.

 

동시성 이슈

하나의 공유 자원에 동시에 여러 스레드가 쓰기 작업을 하는 상황에서는 Race condition이 발생할 수 있다.

간단한 재고 관리 시스템을 예시로 웹 애플리케이션에서의 동시성 이슈와 해결 방법들을 알아보자.

@Service
public class StockService {
	...
	
	@Transactional
	public void decrease(Long id, Long quantity) {
		Stock stock = stockRepository.findById(id).orElseThrow();
		
		stock.decrease(quantity);
		
		stockRepository.saveAndFlush(stock);
	}
}

위와 같은 서비스 메소드를 싱글 스레드 환경에서 실행할 때는 문제없이 작동할 것이다.

@Test
public void 동시에_100개의_스레드로_요청() throws InterruptedException {
	// given
	final int threadCount = 100;
	ExecutorService executorService = Excutors.newFixedThreadPool(NUM_OF_THREADS);
	CountDownLatch latch = new CountDownLatch(threadCount);
	
	// when
	for (int i = 0; i < threadCount; i++) {
		executorService.submit(() -> {
			try {
				stockService.decrease(stockId, 1L);
			} finally {
				latch.countDown();
			}
		});
	}
	
	latch.await();
	
	// then
	final Long quantity = stockRepository.findById(stockId).orElseThrow().getQuantity();

	assertThat(quantity).isZero();
}

하지만 멀티스레드 환경에서는 race condition이 발생하여 기댓값이 나오지 않을 것이다.

자바에서 제공하는 동기화 기법을 비롯한 다양한 방법으로 접근하여 이 문제를 해결해보자.

 

1. synchronized

자바에서 제공하는 synchronized 키워드를 사용하여 임계영역을 설정하면 한 개의 스레드만 접근할 수 있도록 강제할 수 있다.

1-1. Transactional과 synchronized

@Transactional
public synchronized void decrease(Long id, Long quantity) {
	Stock stock = stockRepository.findById(id).orElseThrow();
	
	stock.decrease(quantity);
	
	stockRepository.saveAndFlush(stock);
}

@Transactional 을 사용하면 Spring AOP가 적용되어 프록시 객체가 생성된다.

프록시 객체에서는 동기화 메소드를 진입하기 전에 TransactionStatus를 저장하기 때문에 위 상황에서는 여전히 race condition이 발생한다.

 

1-2. Only synchronized

public synchronized void decrease(Long id, Long quantity) {
	Stock stock = stockRepository.findById(id).orElseThrow();
	
	stock.decrease(quantity);
	
	stockRepository.saveAndFlush(stock);
}

@Transactional 을 제거하면 의도대로 임계영역은 지정된다. 하지만 트랜잭션 처리는 할 수 없다.

 

1-3. Transactional과 synchronized 분리

@Service
public class StockService {

	@Autowired
	private StockTransactionService transactionService;
	
	public synchronized void decrease(Long id, Lnog quantity) {
		transactionService.decrease(id, quantity);
	}
}

@Service
public class StockTransactionService {
	
	@Transactional
	public void decrease(Long id, Long quantity) {
		...
	}
}

위처럼 두 클래스를 분리하면 임계영역도 보장되면서, 트랜잭션 메소드를 항상 프록시 객체 외부에서 호출하게 된다.

 

1-4. 문제점

1-3을 통해서 프로세스 단위의 동시성 이슈는 해결할 수 있다.

하지만 여러 대의 서버를 사용하는 환경에서는 결국 synchronized만을 이용해서 동시성을 해결할 수 없다.

 

2. DBMS Lock

2-1. Pessimistic Lock (비관적 락)

DBMS의 실제 데이터에 락을 걸어서 정합성을 맞춘다.

데드락의 위험성이 있지만 Optimistic Lock에 비해 성능이 좋다.

  • Shared Lock (= Read Lock)
    • Read끼리는 동시에 접근할 수 있다.
    • Write와는 함께 접근할 수 없다.
    • @Lock(LockModeType.PESSIMISTIC_READ)
  • Exclusive Lock (= Write Lock)
    • 락이 해제될 때까지 Read/Write 모두 접근할 수 없다.
    • @Lock(LockModeType.PESSIMISTIC_WRITE)

Readers-Writers problem과 같다.

레포지토리의 메소드에 각 락에 맞는 @Lock 을 이용한다.

 

2-2. Optimistic Lock (낙관적 락)

실제 락을 이용하지 않고 버전을 이용해 정합성을 맞춘다.

데이터를 읽을 때 버전과 쓸 때 버전을 비교하여 같다면 쓰기 작업을 수행하고, 다르면 트랜잭션을 다시 수행한다.

도메인 엔터티에 버전 프로퍼티를 추가한다.

@Version 어노테이션으로 버전 프로퍼티를 지정할 수 있다.

@Entity
public class Stock {
	...
	
	@Version
	private Long version;

	...
}

레포지토리의 메소드에는 @Lock(LockModeType.OPTIMISTIC) 을 적용한다.

public interface StockRepository extends JpaRepository<Stock, Long> {

	@Lock(LockModeType.OPTIMISTIC)
	@Query("SELECT s FROM Stock s WHERE s.id = :id")
	Optional<Stock> findByIdWithOptimisticLock(@Param("id") Long id);

}

버전 불일치가 발생했을 때 재시도할 수 있도록 퍼사드 패턴을 이용할 수 있다.

@Component
@RequiredArgsConstructor
public class OptimisticLockStockFacade {
	
	private final OptimisticLockStockService optimisticLockStockService;

	public void decrease(final Long id, final Long quantity) {
		while (true) {
			try {
				optimisticLockStockService.decrease(id, quantity);
				break;
			} catch (Exception e) {
				Thread.sleep(50);
			}
		}
	}

}

 

2-3. Named Lock

이름을 가진 metadata locking이다.

트랜잭션이 종료될 때 락이 자동으로 해제되지 않으며, 별도의 명령을 하거나 선점시간이 끝나야 해제된다.

위처럼 데이터와 락이 분리되어 있다.

예시에서는 JPA의 native query를 이용하고 동일한 datasource를 사용한다. 하지만 커넥션 풀이 부족해질 수 있기 때문에 실무에서는 datasource를 분리하는 것이 좋다.

MySQL의 GET_LOCK(lockName, timeout) 과 RELEASE_LOCK(lockName) 을 이용해서 분산 락을 구현할 수 있다.

@Query(value = "SELECT GET_LOCK(:key, 3000)", nativeQuery = true)
void getLock(String key);

@Query(value = "SELECT RELEASE_LECK(:key)", nativeQuery = true)
void releaseLock(String key);
  • 주의사항
    • MySQL의 named lock은 커넥션이 종료되면 자동으로 잠금이 해제되기 때문에 주의해야 한다.
    • JDBC 템플릿은 트랜잭션이 종료되면 풀에서 얻어온 커넥션을 반환하기 때문에 락을 릴리즈하지 못할 수 있으므로 주의해야 한다. 우아한형제들 기술블로그

퍼사드 패턴을 이용해 락을 획득하고 서비스를 호출하도록 구현할 수 있다.

@Component
@RequiredArgsConstructor
public class NamedLockStockFacade {
	private final LockRepository lockRepository;
	private final StockService stockService;

	@Transactional
	public void decrease(Long id, Long quantity) {
		try {
			lockRepository.getLock(id.toString());
			stockService.decrease(id, quantity);
		} finally {
			lockRepository.releaseLock(id.toString());
		}
	}
}

기존 서비스의 트랜잭션은 부모 트랜잭션과 별도로 실행되기 위해서 propagation을 Propagation.REQUIRES_NEW 로 변경한다.

 

3. Redis를 이용

3-1. spin lock 방식

Redis의 setnx 명령어는 동일한 key가 없을 경우에만 저장된다.

이를 이용해 스핀락을 구현할 수 있다.

id를 key로 하는 lock/unlock 메소드를 가진 레포지토리를 생성한다.

@Component
@RequiredArgsConstructor
public class RedisLockRepository {

	private final RedisTemplate<String, String> redisTemplate;
	
	public Boolean lock(Long key) {
		return redisTemplate
							.opsForValue()
							.setIfAbsent(key.toString(), "lock", Dulation.ofMillis(3_000));
	}

	public Boolean unlock(Long key) {
		return redisTemplate.delete(key.toString());
	}
}

 

이제 lock 메소드는 동일한 key가 있을 경우 false를 반환하고, 없을 경우 true를 반환할 것이다.

unlock 메소드는 해당 key를 삭제할 것이다.

레포지토리를 이용해 스핀락을 동작하는 퍼사드 클래스를 구현할 수 있다.

@Component
@RequiredArgsConstructor
public class LettuceLockStockFacade {
	...
	
public void decrease(Long key, Long quantity) throws InterruptedException {
	while (!redisLockRepository.lock(key)) {
		Thread.sleep(100);
	}
	
	try {
		stockService.decrease(key, quantity);
	} finally {
		redisLockRepository.unlock(key);
	}
}

 

3-2. pub/sub 방식

Redis에서는 subscribe "chanel" 과 publish "chanel" "message" 로 사용할 수 있다.

이러한 pub/sub를 이용해서 락에 대기하고 있는 스레드에게 락이 풀렸음을 알릴 수도 있다.

  • 주의사항
    • Redis pub/sub는 전송 보장 없이 메시지를 전송한다.

redisson 라이브러리를 이용해서 스프링에서 pub/sub 기반 락을 구현해보자.

redisson은 RLock 을 제공하여 간편하게 코드로 구현할 수 있다.

@Component
@RequiredArgsConstructor
@Slf4j
public class RedissonLockStockFacade {
	...
	
	public void decrease(Long key, Long quantity) throws InterruptedException {
		RLock lock = redissonClient.getLock(key.toString());
		
		try {
			final boolean available = lock.tryLock(5, 1, TimeUnit.SECONDS);
			
			if (!available) {
				log.info("redisson lock timeout");
				return;
			}
			stockService.decrease(key, quantity);		

	} finally {
		lock.unlock();
	}
}

참조