해당 글은 재고시스템으로 알아보는 동시성 이슈 해결 방법 강의를 정리한 글입니다.
정리 코드
: 깃허브 링크
개발 환경
- Java 17
- Spring boot 3.2.3
- redis
- docker
- mysql
- jpa
- lombok
✅ 동시성 이슈란?
- 동시에 여러 개의 스레드, 프로세스 또는 작업이 공유된 자원에 접근하고 수정하는 과정에서 발생할 수 있는 문제들
- 여러 작업이 동시에 실행되고 동시에 동일한 자원에 접근하거나 수정하려고 할 때 발생되는 이슈
📌 동서싱 이슈로 발생되는 문제
경쟁상태(Race Condition)
: 여러 개의 스레드나 프로세스가 동시에 공유된 자원에 접근하고 수정할 때, 실행 순서나 타이밍 등에 따라 예기치 않은 결과가 발생교착상태(DeadLock)
: 두 개 이상의 작업이 서로 상대방이 가진 자원을 기다리면서 무한히 대기하는 상태일관성 문제(Consistency Issue)
: 동시에 여러개의 작업이 데이터를 수정하거나 읽고 쓰는 경우, 데이터 일관성이 깨질 수 있음병목 현상(Bottleneck)
: 특정 자원에 대한 동시 접근이 많아져 병목현상 발생
✅ 재고시스템 기본 코드
📌 Stock
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
| @Getter
@NoArgsConstructor
@Entity
public class Stock {
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
private Long id;
private Long productId;
private Long quantity;
@Version
private Long version;
public Stock (long productId, long quantity) {
this.productId = productId;
this.quantity = quantity;
}
public void decrease(Long quantity) {
if (this.quantity - quantity < 0) {
throw new RuntimeException("재고는 0개미만이 될 수 없습니다.");
}
this.quantity -= quantity;
}
}
|
📌 StockRepository
1
2
| public interface StockRepository extends JpaRepository<Stock, Long> {
}
|
📌 StockService
1
2
3
4
5
6
7
8
9
10
11
12
13
14
| @RequiredArgsConstructor
@Service
public class StockService {
private final StockRepository stockRepository;
@Transactional
public void decrease(Long id, Long quantity) {
Stock stock = stockRepository.findById(id).orElseThrow();
stock.decrease(quantity);
stockRepository.saveAndFlush(stock);
}
}
|
📌 StockServiceTest
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
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
| @SpringBootTest
class StockServiceTest {
@Autowired
private StockService stockService;
@Autowired
private StockRepository stockRepository;
@BeforeEach
public void dataInsert() {
stockRepository.saveAndFlush(new Stock(1L, 100L));
}
@AfterEach
public void dataDelete() {
stockRepository.deleteAll();
}
@Test
void 재고감소() throws Exception {
stockService.decrease(1L, 1L);
Stock stock = stockRepository.findById(1L).orElseThrow();
assertThat(99L).isEqualTo(stock.getQuantity());
}
@Test
void 동시에_100개요청_재고감소() throws Exception {
//given
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();
assertThat(stock.getQuantity()).isZero();
}
}
|
위 테스트 코드 중 동시에_100개요청_재고감소
테스트 실행 결과입니다.
테스트 실패 ..!
아래 여러 예제를 통해 동시성 이슈를 해결해 보겠습니다.
✅ 동서싱 이슈 해결
📌 Java - synchronized
1
2
3
4
5
6
| public synchronized void decrease(Long id, Long quantity) {
Stock stock = stockRepository.findById(id).orElseThrow();
stock.decrease(quantity);
stockRepository.saveAndFlush(stock);
}
|
- 메서드에 고유락을 걸어 하나의 스레드만 접근 가능하게 해준다.
멀티스레드 환경에서 스레드간 데이터 동기화
를 시켜주기 위해 자바에서 제공하는 키워드
문제점
synchronized
는 하나의 프로세스 안에서만 보장이 된다- 서버가 1대일 때는 정상 동작하지만 서버가 2대 이상일 경우 동기화가 불가능하다.
- @Transactional 과 동시에 사용할 수 없습니다. -> 실제
commit
나가는 시점보다 빠르게 다음 스레드가 메서드 실행하기 때문에 데이티 불일치 문제 발생
📌 Database(Mysql) - Pessimistic Lock(비관적 락)
1
2
3
4
| @Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select s from Stock s where s.id = :id")
Stock findByIdWithPessimisticLock(Long id);
}
|
- 실제 쿼리에 Lock을 걸어 정합성을 맞추는 방법
exclusive lock(베타적 잠금)
을 걸게되면 다른 트랜잭션에서는 lock이 해제되기 전에 데이터를 가져갈 수 없음- lock의 범위를 최소한 해야한다.
문제점
- 데드락이 걸릴 수 있음
- 동시 처리 성능 저하 발생
📌 Database(Mysql) - Optimistic Lock(낙관적 락)
1
2
3
4
5
6
7
8
9
10
11
|
// Stock 클래스에 추가
@Version
private Long version;
// Repository
@Lock(LockModeType.OPTIMISTIC)
@Query("select s from Stock s where s.id = :id")
Stock findByIdWithOptimisticLock(Long id);
}
|
- Entity에 version 컬럼 추가 및 충돌 발생시 처리 로직을 직접 구현해주는 방식
- 실제로 Lock을 걸지않고 version 값을 이용하여 정합성을 맞추는 방법
- 충돌이 나지 않는다는 가정하에, 별도의 락을 잡지 않으므로 비관적 락보단 성능이 좋음
과정
- Server1이 version1 임을 조건절에 명시하면서 업데이트 쿼리를 날림
- version1 값이 업데이트 되어 version2가 됨
- server2가 version1로 업데이트를 시도하면 버전이 맞지 않아 실패함
문제점
- 업데이트가 실패했을 때, 재시도 로직을 개발자가 구현해 줘야함
- 충돌이 빈번하게 일어나면 오히려 성능이 안좋음
📌 Database(Mysql) - Named Lock
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
| // Repository
@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);
// service
@Transactional
public void decrease(Long id, Long quantity) {
try {
lockRepository.getLock(id.toString());
stockService.decrease(id, quantity);
} finally {
lockRepository.releaseLock(id.toString());
}
}
|
- 이름을 가진
metadata Lock
이다. - 락을 획득 후, 해지될 때 까지 다른 세션은 이 락을 획득할 수 없음
- 트랜잭션이 종료될 때 락이 자동으로 해지되기 않기 때문에, 별도로 해주해주거나 선점시간이 끝나야 해지됨
- Mysql에서는
getLock()
락 획득, releaseLock()
락 해제 명령어 입니다. Pessimistic Lock
은 time out을 구현하기 힘들지만, Named Lock
은 손쉽게 명시할 수 있음
문제점
- 실제 서비스에서는 커넥션풀이 부족해질 수 있기때문에 DataSource 분리 해야한다.
📌 Redis - Lettuce
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
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
| // 의존성 추가
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
// Repository 추가
@RequiredArgsConstructor
@Component
public class RedisRepository {
private final RedisTemplate<String, String> redisTemplate;
public Boolean lock(Long key) {
return redisTemplate
.opsForValue()
.setIfAbsent(generateKey(key), "lock", Duration.ofMillis(3000));
}
public Boolean unLock(Long key) {
return redisTemplate.delete(generateKey(key));
}
private String generateKey(Long key) {
return key.toString();
}
}
// Lock 획득 코드 추가
@RequiredArgsConstructor
@Component
public class LettuceLockStockFacade {
private final RedisRepository redisRepository;
private final StockService stockService;
public void decrease(Long id, Long quantity) throws InterruptedException {
while (!redisRepository.lock(id)) {
Thread.sleep(100);
}
try {
stockService.decrease(id, quantity);
} finally {
redisRepository.unLock(id);
}
}
}
|
- Setnx 명령어를 활용하여 분산락을 구현 (Set if not Exist - key:value) -> 값이 없을때만 Set 명령어 수행
- Setnx는
Spin Lock
방식이므로 retry 로직을 직접 작성해 줘야함 - 구현이 간단하다.
Spin Lock 이란?
- Lock을 획득하려는 스레드가 Lock을 획득할 수 있는지 확인하며 반복적으로 시도하는 방법
- 동시에 많은 스레드가 lock 획득 대기 상태라면 redis에 부하가 갈 수 있음.
📌 Redis - Redisson
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
| // 의존성 추가
implementation 'org.redisson:redisson-spring-boot-starter:3.23.2'
// 락 획득 코드 추가
@RequiredArgsConstructor
@Component
public class RedissonLockStockFacade {
private final RedissonClient redissonClient;
private final StockService stockService;
public void decrease(Long id, Long quantity) {
RLock lock = redissonClient.getLock(id.toString());
try {
// 락 획득 시도 시간, 락 점유 시간
boolean available = lock.tryLock(10, 1, TimeUnit.SECONDS);
if (!available) {
System.out.println("lock 획득 실패");
return;
}
stockService.decrease(id ,quantity);
} catch (InterruptedException e) {
throw new RuntimeException(e);
} finally {
lock.unlock();
}
}
}
|
- Pub/sub 방식으로 lock을 획득
- 락 획득 재시도를 기본으로 제공해준다.
- lettuce와 비교했을 때 redis에 부하가 덜 간다.
마치며
되게 저렴한 강의였는데 생각보다 알찼던 것 같다.
결국 동시성 이슈 발생시 해결을 위해 어느 곳에서 락을 걸지 결정해야 하는데,
여러가지 시나리오 생각 후 결정하는 것이 best choice 인 것 같다.
This post is written by PRO.