동시성 문제는 여러 실행요청이 하나의 자원에 동시에 접근할 때 발생하기 쉽습니다.
이러한 문제는 연산이 단독으로 실행됨을 보장해줌으로서 해결할 수 있습니다.
이러한 방법을 원자적 연산(atomic operation) 이라고 합니다.
원자적 연산을 위한 조건은 아래와 같습니다.
- 모든 조작이 완료할 때까지 어떤 프로세스도 변경을 알지 못하도록 비가시적이어야 한다.
- 조작중에 어느 하나라도 실패한다면 조작 전체도 실패하고 시스템의 상태를 조작 이전 상태로 복구해야 한다.
출처 : 위키백과
즉, 아래 표와 같이 한 작업이 이루어지고 있다면 그 다음 작업은 이전 작업이 완료된 후에 이루어져야 하고 도중에 실패했다면 이전 상태로 복구(rollback)이 이루어져야 합니다.
A 스레드 | iphone14 | B 스레드 |
---|---|---|
재고 read : 2 | 남은 재고 : 2 | |
재고 차감&상품 update : 1 | 남은 재고 : 1 | |
남은 재고 : 1 | 재고 read : 1 | |
남은 재고 : 0 | 재고 차감&상품 update : 0 |
그렇다면 원자적 연산을 위해서 어떻게 해야할까요?
먼저 언어 레벨에서 지원하는 키워드를 간단하게 알아보겠습니다.
바로 synchronized 키워드 입니다.
synchronized는 임계 구역(critical section)을 설정해 구역 내 로직의 연산을 atomic하게 보장해주는 자바의 예약어 입니다.
하지만 이 챕터에서 synchronized는 깊이 다루지 않겠습니다.
다른 방법에서 할 이야기도 많거니와 synchronized는 치명적인 단점이 있습니다.
바로 언어레벨에서 지원하기 때문에 서버가 2개 이상이 된다면 원자적 연산을 보장할 수 없기 때문입니다.
was 서버 한 대가 있다고 가정한다면 자바 언어로 만들어진 was 내에서는 원자적 연산이 가능합니다. 하지만 was1, was2로 늘어났다면? 두 서버 모두 synchronized를 사용 하더라도 연결된 DB는 한 대 이기 때문에 충돌이 발생합니다.
두 번째 방법은 트랜잭션 입니다.
트랜잭션을 간단히 설명하자면 데이터베이스에서 쪼갤 수 없는 하나의 작업 단위 입니다.
트랜잭션의 로직은 하나일 수 도 있고 여러 개 일 수도 있습니다. 여러 개일 때에도 다른 요청의 방해를 받지 않고 하나의 작업 단위로서 보장받을 수 있도록 해주는 것이죠.
실제 MySQL에서는 아래와 같이 진행 됩니다.
MySQL 트랜잭션
set autocommit false
상품 read
상품 update
commit or rollback
참고 : MySQL을 사용한다면 MySIAM 엔진은 트랜잭션을 지원하지 않습니다. InnoDB 엔진을 사용해야 트랜잭션을 사용할 수 있습니다.
스프링 기반 애플리케이션에서는 트랜잭션을 다룰 수 있도록 선언적 트랜잭션 처리를 지원합니다.
메서드 혹은 클래스에 @Transactional 을 통해 필요한 부분에 적용 가능합니다.
@Transactional
public void purchase(PurchaseRequest purchaseRequest) {
Member member = memberDao.findByMemberUniqueId(purchaseRequest.getMemberUniqueId());
Product product = productDao.findById(purchaseRequest.getProductId());
Purchase purchase = validatePurchaseRule(product, member, purchaseRequest);
Product updateProduct = product.addPurchasedStock(purchaseRequest.getAmount());
int updateProductCount = productDao.updatePurchasedStock(updateProduct);
if (updateProductCount != 1) {
throw new IllegalStateException("상품 정보 수정 오류");
}
Member updateMember = member.minusBalance(product.getPrice());
int updateMemberCount = memberDao.updateMemberBalance(updateMember);
if (updateMemberCount != 1) {
throw new IllegalStateException("회원 정보 수정 오류");
}
int insertCount = purchaseDao.save(purchase);
if (insertCount != 1) {
throw new IllegalStateException("구매 이력 등록 오류");
}
}