일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | 3 | ||||
4 | 5 | 6 | 7 | 8 | 9 | 10 |
11 | 12 | 13 | 14 | 15 | 16 | 17 |
18 | 19 | 20 | 21 | 22 | 23 | 24 |
25 | 26 | 27 | 28 | 29 | 30 | 31 |
- BindingAdapter
- 투 포인터
- 그래프
- DP
- springdoc
- 위상 정렬
- 알고리즘
- 페르마 소정리
- DFS
- tarjan
- concurreny
- kapt
- MySQL
- 위상정렬
- SCC
- 구현
- Linux
- miller-rabin
- disjoint set
- spring boot
- 이분 탐색
- Java
- 백트래킹
- Meet in the middle
- MST
- 분리 집합
- 누적 합
- BFS
- union-find
- kruskal
- Today
- Total
기맹기 개발 블로그
동시성 이슈를 해결하는 방법들 본문
이 글은 인프런 강의 재고시스템으로 알아보는 동시성이슈 해결방법 을 수강하고 정리한 내용입니다.
동시성 이슈
하나의 공유 자원에 동시에 여러 스레드가 쓰기 작업을 하는 상황에서는 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();
}
}
참조
'Spring' 카테고리의 다른 글
[Swagger] Error code enum을 Swagger에 연동시키기 (0) | 2023.02.14 |
---|---|
[MyBatis] Cause: java.lang.IndexOutOfBoundsException 해결법 (0) | 2022.11.18 |
jar에서의 Json 의존성 오류 문제 (0) | 2022.11.10 |