동시성 문제는 멀티 스레드나 분산 환경에서 가변 데이터로 접근하며 나타나는 문제로 세션이 데이터를 수정중 일 때 다른 세션이 수정중인 데이터에 접근해 데이터 정합성이 꺠지는 문제를 말한다.
동시성 문제를 해결할 수 있는 방법들
•
프로그래밍 단위의 동기화 블럭(Synchronized)
•
데이터베이스의 Lock
◦
MySQL8 이상, InnoDB 기준으로 설명한다.
Product 도메인
@Table(name = "product")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
public class Product {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "product_id")
private Long productId;
@Column(name = "product_quantity", nullable = false)
private Long quantity;
public Product(final Long productId, final Long quantity) {
this.productId = productId;
this.quantity = quantity;
}
public static Product registerProduct(final Long productId, final Long quantity) {
return new Product(productId, quantity);
}
// 해당 상품의 수량을 감소시킴
public void decrease(final Long quantity) {
if (this.quantity < quantity) {
throw new RuntimeException("재고 부족");
}
this.quantity = this.quantity - quantity;
}
}
Java
복사
ProductService
@Service
@RequiredArgsConstructor
@Transactional
public class ProductService {
private final ProductRepository productRepository;
public void decrease(final Long id, final Long quantity) {
Product product = productRepository.findByProductId(id).orElseThrow();
product.decrease(quantity);
productRepository.saveAndFlush(product);
}
public long getQuantitiy(long id) {
return productRepository.findByProductId(id)
.orElseThrow()
.getQuantity();
}
}
Java
복사
상품 도메인
•
상품 아이디 (productId)
•
상품 재고 (quantity)
상품 서비스
•
상품 재고 수량 감소
•
상품 재고 수량 조회
위 예시코드에서 상품의 재고가 줄어드는 로직을 예시로 동시성 이슈를 설명한다.
동시성 테스트 코드
@DisplayName("동시에 100개의 재고 감소 요청")
@Test
void 동시에_100개의_재고_감소_요청() throws InterruptedException {
// 실행할 수량 100개
int threadCount = 100;
// Thread Pool에 스레드 32개 대기
ExecutorService executorService = Executors.newFixedThreadPool(32);
// 다른 스레드를 기다려주는 클래스
CountDownLatch latch = new CountDownLatch(threadCount);
for (int i = 0; i < threadCount; i++) {
executorService.submit(() -> {
try {
// 상품 재고 감소
productService.decrease(1L, 1L);
// 스레드와 수량을 가져와서 출력해보자.
long threadId = Thread.currentThread().getId();
long quantity = productService.getQuantitiy(1L);
System.out.println(threadId + " : " + quantity);
} finally {
latch.countDown();
}
});
}
latch.await();
Product product = stockRepository.findById(1L).orElseThrow();
Assertions.assertEquals(0L, product.getQuantity());
}
Java
복사
•
1L 상품을 100개 등록한다.
•
스레드 100개가 동시에 1L 상품의 수량을 감소시킨다.
테스트 결과
Expected :0
Actual :85
org.opentest4j.AssertionFailedError: expected: <0> but was: <85>
Java
복사
스레드 100개가 decrease() 메서드를 호출한 결과 상품 수량은 85개 남은 겻을 볼 수 있다. 스레드 ID와 수량을 확인해보자.
# Thread Id : Quantity
41 : 98
35 : 97
31 : 97
48 : 97
27 : 97
54 : 97
32 : 97
56 : 96
37 : 96
35 : 96
40 : 96
43 : 96
47 : 95
34 : 96
Java
복사
서로 다른 스레드가 Quantity가 같은 Stock에 접근하여 decrease() 메서드를 호출하는 것을 볼 수 있다.
사진으로 표현하면 아래와 같다.
각 스레드는 Quantity에서 수량을 조회하고 1을 감소시킨 후 저장한다.
이게 반복되어 100개의 스레드가 겨우 15 수량만 감소시키는 결과가 생겼다.
Synchronized
첫 번째 해결 방법은 수량을 감소시키는 메서드에 동기화 블럭을 추가하는 것이다.
public synchronized void decrease(final Long id, final Long quantity) {
Product product = stockRepository.findByProductId(id).orElseThrow();
product.decrease(quantity);
stockRepository.saveAndFlush(product);
}
Java
복사
synchronized는 하나의 스레드가 점유하고 있는 공유 자원에 다른 스레드들이 접근할 수 없도록 막는 것이다.
이렇게 수정하면 해당 테스트는 성공한다. 공유 자원을 점유하고 있는 스레드의 작업이 끝나고 나서야 다른 스레드들이 접근하기 때문에 데이터의 동기화가 이루어진다.
출력한 로그도 찍어보면 각 스레드가 조회해온 상품의 재고 수가 서로 다른 것을 알 수 있다.
56 : 97
55 : 96
54 : 95
36 : 77
35 : 76
34 : 75
33 : 74
32 : 73
31 : 72
30 : 71
29 : 70
28 : 69
27 : 68
28 : 67
29 : 66
30 : 65
31 : 64
32 : 63
33 : 62
57 : 1
26 : 0
...
Java
복사
•
동기화 블럭에 진입한 스레드의 상태 값은 RUNNABLE이다.
•
진행 중인 스레드가 아닌 다른 스레드의 ID로 상태값을 조회하면 WAITING으로 나온다.
WAITING 중인 블럭은 동기화 블럭의 모니터 락을 얻어 자원을 점유하기 위해 기다리고 있는 것을 볼 수 있다.
동기화 블럭의 단점
•
또한 스레드들이 공유 자원을 위해 대기중이기 때문에 성능적인 측면에서 불리하다.
데이터베이스의 Lock을 사용한 동시성 해결
비관적 락(Pessimistic Lock)
•
비관적 락은 두 개 이상의 트랜잭션이 하나의 자원을 놓고 동시에 수정할 것이라고 가정하는 기법이다.
•
자원을 점유한 트랜잭션이 Lock을 걸어 다른 트랜잭션이 해당 자원에 접근하지 못하도록 막고 시작한다.
•
하지만 두 트랜잭션이 하나의 자원을 무한히 기다리는 데드락을 유발할 가능성이 있다.
◦
장점
▪
트랜잭션의 동시 접근을 확실하게 순차적으로 처리 가능하다.
◦
단점
▪
Lock을 가져야 자원에 접근할 수 있기 때문에 속도가 비교적 느려진다.
▪
데드락 발생의 위험이 있다.
낙관적 락(Optimistic Lock)
•
낙관적 락은 두 개 이상의 트랜잭션이 동시에 자원을 수정하지 않을 것이라 보고 자원에 대한 경쟁을 낙관적으로 바라보는 기법이다.
•
자원에 대해 버전을 추가하고 Lock을 걸지 않고 만약 동시성 문제가 일어나면 그 때 가서 처리하자는 기법이다.
•
JPA에서는 @Version을 사용해서 쉽게 적용할 수 있다.
◦
장점
▪
읽기 시점에 Lock을 사용하지 않아서 성능이 비교적 좋다.
▪
데이터가 동시에 수정되지 않는다면 비관적 락에 비해 성능이 좋다.
◦
단점
▪
여러 트랜잭션중 하나가 데이터를 변경하면 다른 요청들이 거부된다.
Named Lock
•
이름을 가진 Lock이다. 세션이 이름을 가진 Lock을 획득하면 Lock을 해제하거나 점유 시간이 종료될 때 까지 다른 세션은 접근할 수 없다.
•
Named Lock은 Pessimistic Lock과 유사하지만 다르다.
◦
비관적 락은 Row나 Table 단위로 Lock을 건다.
◦
NamedLock은 Metadata 단위로 Lock을 건다.
◦
비관적 락은 트랜잭션이 커밋될 때 Lock을 해제한다.
◦
NamedLock은 별도의 명령을 이용하거나 점유시간이 지나야 Lock을 해제한다.