Search

동시성 이슈

카테고리
백엔드 🧬
유형
상품 재고 관리 도메인으로 알아보는 동시성 이슈와 해결
Date
Tags
1 more property
동시성 문제는 멀티 스레드나 분산 환경에서 가변 데이터로 접근하며 나타나는 문제로 세션이 데이터를 수정중 일 때 다른 세션이 수정중인 데이터에 접근해 데이터 정합성이 꺠지는 문제를 말한다.

동시성 문제를 해결할 수 있는 방법들

프로그래밍 단위의 동기화 블럭(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을 해제한다.