동시성 이슈에 대응하기
동시성 이슈에 대응하기
여러가지 동시성 문제 상황
개발을 진행하다보면 많은 동시성 문제를 마주하게 된다.
예약 기능을 구현할 때, 조회수 기능을 구현할 때 등등…
실제로 졸업 프로젝트로 진행했던 오늘만 사장에서 예약 기능을 구현하는 도중 동시성 문제를 마주하였다.
해당 기능을 구현하면서 들었던 강의와 찾아본 내용을 간단하게 정리해본다.
- 여러 실무 환경에서 동시성 문제 발생
- 아래와 같은 재고 감소 코드에서
1 2 3 4 5 6 7
@Transactional public void decrease(Long id, Long quantity) { Stock stock = stockRepository.findById(id).orElseThrow(); stock.decrease(quantity); stockRepository.saveAndFlush(stock); }
- 아래와 같이 동시에 100개의 요청을 보낼 때에 문제 발생!
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
@Test public void 동시에_100개의_요청() throws InterruptedException { int threadCount = 100; ExecutorService executorService = Executors.newFixedThreadPool(32); CountDownLatch latch = new CountDownLatch(threadCount); for (int i = 0; i < threadCount; i++) { executorService.submit(() -> { try { stockService.decrease(1L, 1L); } finally { latch.countDown(); } }); } latch.await(); Stock stock = stockRepository.findById(1L).orElseThrow(); assertEquals(0, stock.getQuantity()); // 남은 재고가 0이 아님 }
- 문제 상황
- 여러 스레드가 공유 데이터에 접근해서 갱신 시도 → race condition 발생
- 하나의 스레드가 갱신 후에 다음 스레드가 접근하도록 해야!!
- 문제 해결 방법
- Application 레벨에서 해결
- Database Lock 활용
- Redis Distributed Lock 활용
Application Level
synchronized
- synchronized 사용
1
2
3
4
5
6
7
@Transactional
public synchronized void decrease(Long id, Long quantity) {
Stock stock = stockRepository.findById(id).orElseThrow();
stock.decrease(quantity);
stockRepository.saveAndFlush(stock);
}
- 여전히 문제 발생 → transactional 어노테이션의 동작 방식 때문!
- 트랜잭션이 종료되면서 재고가 갱신되기 전에 다음 스레드가 재고에 접근 가능 → 문제 발생!!
- transactional 어노테이션을 주석처리하면?
1
2
3
4
5
6
7
//@Transactional
public synchronized void decrease(Long id, Long quantity) {
Stock stock = stockRepository.findById(id).orElseThrow();
stock.decrease(quantity);
stockRepository.saveAndFlush(stock);
}
- 원하는 방식으로 동작
문제점
- 자바의 synchronized는 하나의 프로세스 안에서만 보장
- 결국 서버의 개수가 늘어나면 여러 스레드에서 데이터에 접근 가능 → race condition 발생
Database Lock
- MySQL이 제공해주는 Lock 사용
Pessimistic Lock
- 실제로 데이터에 Lock을 걸어서 정합성을 맞추는 방법
- 데이터에 Exclusive Lock을 걸게되면 다른 트랜잭션에서는 Lock이 해제되기 전에 데이터를 가져갈 수 없음
- 데드락 발생 가능
예시 코드
- Repository에 추가
1
2
3
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select s from Stock s where s.id = :id")
Stock findByIdWithPessimisticLock(Long id);
특징
- 충돌이 빈번하게 발생한다면 Optimistic Lock보다 성능이 좋음
- 별도의 Lock때문에 성능 감소 가능
Optimistic Lock
- 실제로 Lock을 이용하지 않고 버전을 이용함으로써 정합성을 맞추는 방법
- 먼저 데이터를 읽은 후에 Update를 수행할 때 현재 내가 읽은 버전이 맞는지 확인하며 Update
- 내가 읽은 버전에서 수정사항이 생겼을 때는 Application에서 다시 읽은 후에 작업을 수행
예시 코드
- Entity에 추가
1
2
@Version
private Long version;
- Repository에 추가
1
2
3
@Lock(LockModeType.OPTIMISTIC)
@Query("select s from Stock s where s.id = :id")
Stock findByIdWithOptimisticLock(Long id);
- Facade 추가 (실패했을 때 재시도 구현해야 함)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@Component
public class OptimisticLockStockFacade {
private final OptimisticLockStockService optimisticLockStockService;
public OptimisticLockStockFacade(OptimisticLockStockService optimisticLockStockService) {
this.optimisticLockStockService = optimisticLockStockService;
}
public void decrease(Long id, Long quantity) throws InterruptedException {
while(true) {
try {
optimisticLockStockService.decrease(id, quantity);
break;
} catch (Exception e) {
Thread.sleep(50); // 50ms후에 재시도
}
}
}
}
특징
- 별도의 Lock을 잡지 않으므로 Pessimistic Lock보다 성능에 이점
- Update 실패시에 재시도 로직을 직접 구현해줘야 함
- 충돌이 빈번하게 일어나지 않는다면 추천
Named Lock
- 이름을 가진 Metadata Locking
- 이름을 가진 Lock을 획득한 후 해제할 때까지 다른 세션은 이 Lock을 획득할 수 없음
- 트랜잭션이 종료될 때 Lock이 자동으로 해제되지 않으므로 별도로 해제해 주어야 함
- Pessimistic Lock과 유사하지만 Metadata에 Lock을 한다는 점이 다름
예시 코드
- 편의를 위해 동일한 데이터 소스 사용 → connection pool이 부족해질 수 있음
- 실제로 사용할 때는 데이터 소스를 분리해야!
- Repository 추가
1
2
3
4
5
6
7
8
public interface LockRepository extends JpaRepository<Stock, Long> {
@Query(value = "select get_lock(:key, 3000)", nativeQuery = true)
void getLock(String key);
@Query(value = "select release_lock(:key)", nativeQuery = true)
void releaseLock(String key);
}
- Facade 추가
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@Component
public class NamedLockStockFacade {
private final LockRepository lockRepository;
private final StockService stockService;
public NamedLockStockFacade(LockRepository lockRepository, StockService stockService) {
this.lockRepository = lockRepository;
this.stockService = stockService;
}
@Transactional
public void decrease(Long id, Long quantity) {
try {
lockRepository.getLock(id.toString());
stockService.decrease(id, quantity);
} finally {
lockRepository.releaseLock(id.toString());
}
}
}
- Service 변경
1
2
3
4
5
6
7
@Transactional(propagation = Propagation.REQUIRES_NEW) //기존 트랜잭션은 반영되지 않고 일시적으로 중단되며, 새로운 트랜잭션이 시작
public void decrease(Long id, Long quantity) {
Stock stock = stockRepository.findById(id).orElseThrow();
stock.decrease(quantity);
stockRepository.saveAndFlush(stock);
}
- connection pool 사이즈 변경
1
2
3
4
spring:
jpa:
hikari:
maximum-pool-size: 40
특징
- 주로 분산락을 구현할 때 사용
- 타임아웃을 손쉽게 구현 가능
- 트랜잭션 종료시에 Lock 해제, 세션 관리를 잘 해주어야!
- 실제 구현시에 복잡해질 수도 있음
Redis Distributed Lock
- 아래 두가지 라이브러리를 통해 분산락 구현
- 일반적으로 MySQL보다 성능이 좋음
- 실무에서 비용적 여유가 있다면 Redis를 고려 그렇지 않고 트래픽이 적다면 MySQL을 고려
Lettuce
- setnx 명령어를 활용하여 분산락 구현
- Key와 Value를 set할 때 기존의 값이 없을 때에만 set
- Spin Lock 방식
- 반복적으로 확인하면서 Lock 획득
- retry 로직 직접 구현해줘야!
예시코드
- Repository 추가
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@Component
public class RedisLockRepository {
private RedisTemplate<String, String> redisTemplate;
public RedisLockRepository(RedisTemplate<String, String> redisTemplate) {
this.redisTemplate = redisTemplate;
}
public Boolean lock(Long key) {
return redisTemplate
.opsForValue()
.setIfAbsent(generateKey(key), "lock", Duration.ofMillis(3_000));
}
public Boolean unlock(Long key) {
return redisTemplate.delete(generateKey(key));
}
private String generateKey(Long key) {
return key.toString();
}
}
- Facade 추가
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@Component
public class LettuceLockStockFacade {
private final RedisLockRepository redisLockRepository;
private final StockService stockService;
public LettuceLockStockFacade(RedisLockRepository redisLockRepository, StockService stockService) {
this.redisLockRepository = redisLockRepository;
this.stockService = stockService;
}
public void decrease(Long id, Long quantity) throws InterruptedException {
while(!redisLockRepository.lock(id)) {
Thread.sleep(100);
}
try {
stockService.decrease(id, quantity);
} finally {
redisLockRepository.unlock(id);
}
}
}
특징
- 구현이 간단
- Spin Lock 방식이므로 Redis에 부하가 갈수도! → Lock 획득 재시도 간에 텀을 주어야!!
Redisson
- pub - sub 기반으로 Lock 구현 제공
- Lock이 해제되면 Lock 해제를 기다리던 스레드에게 알려줌
- retry 로직 작성 필요 X
예시코드
- Redisson 라이브러리 추가
1
implementation group: 'org.redisson', name: 'redisson-spring-boot-starter', version: '3.34.0'
- Facade 추가
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
@Component
public class RedissonLockStockFacade {
private RedissonClient redissonClient;
private StockService stockService;
public RedissonLockStockFacade(RedissonClient redissonClient, StockService stockService) {
this.redissonClient = redissonClient;
this.stockService = stockService;
}
public void decrease(Long id, Long quantity) {
RLock lock = redissonClient.getLock(id.toString());
try {
boolean available = lock.tryLock(10, 1, TimeUnit.SECONDS); // 문제 발생시에 Lock을 기다리는 시간을 조금 늘려줄 것
if (!available) {
System.out.println("lock 획득 실패");
return;
}
stockService.decrease(id, quantity);
} catch (InterruptedException e) {
throw new RuntimeException(e);
} finally {
lock.unlock();
}
}
}
특징
- pub - sub 기반으 Redis의 부하를 줄여줌
- 구현이 조금 어렵고 별도의 라이브러리를 사용해야!
This post is licensed under CC BY 4.0 by the author.