트랜잭션 동시성 문제 중 손실되는 업데이트의 해결 방식 중 하나인 비관적 잠금에 대한 포스팅입니다.
동시성 테스트를 위해 간단한 계좌 입금 시스템을 만들어 테스트 하였습니다.
plugins {
id 'org.springframework.boot' version '2.6.7'
id 'io.spring.dependency-management' version '1.0.11.RELEASE'
id 'java'
}
group = 'com.account'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '11'
configurations {
compileOnly {
extendsFrom annotationProcessor
}
}
repositories {
mavenCentral()
}
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
compileOnly 'org.projectlombok:lombok'
runtimeOnly 'com.h2database:h2'
annotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
}
tasks.named('test') {
useJUnitPlatform()
}
@Getter
@NoArgsConstructor(access = PROTECTED)
@Entity
public class Account {
@Id
@GeneratedValue
private Long id;
private String name;
private long balance = 0;
public static Account of(String name) {
Account account = new Account();
account.name = name;
return account;
}
public void addBalance(long amount) {
if (amount <= 0) throw new IllegalArgumentException("0원 이하는 입금 불가능합니다.");
this.balance += amount;
}
}
@Service
@RequiredArgsConstructor
public class AccountUpdateService {
private final AccountRepository accountRepository;
@Transactional
public long deposit(long accountId, long amount) {
Account account = accountRepository.findById(accountId)
.orElseThrow(() -> new IllegalArgumentException(accountId + "는 존재하지 않는 계좌입니다."));
account.addBalance(amount); // 계좌 입금 처리
return account.getBalance();
}
}
@Repository
public interface AccountRepository extends JpaRepository<Account, Long> {
}
@SpringBootTest
@AutoConfigureMockMvc
class AccountUpdateServiceTest {
private static final ExecutorService service = Executors.newFixedThreadPool(100);
@Autowired AccountUpdateService accountUpdateService;
@Autowired AccountRepository accountRepository;
private long accountId;
@BeforeEach
void beforeAll() {
Account account = Account.of("우리");
accountRepository.save(account);
accountId = account.getId();
}
@Test
@DisplayName("입금 테스트")
void depositSimpleTest() {
// given
int amount = 10;
// when
accountUpdateService.deposit(accountId, amount);
Account findAccount = accountRepository.findById(accountId)
.orElseThrow(IllegalArgumentException::new);
// then
assertThat(findAccount.getBalance()).isEqualTo(amount);
}
@Test
@DisplayName("입금 멀티 스레드 race condition 테스트")
void depositMultiThreadRaceConditionTest() throws InterruptedException {
StopWatch stopWatch = new StopWatch();
stopWatch.start();
// given
int count = 100;
// then
CountDownLatch latch = new CountDownLatch(count);
for (int i = 0; i < count; i++) {
service.execute(() -> {
accountUpdateService.deposit(accountId, 10);
latch.countDown();
});
}
latch.await();
// when
Account findAccount = accountRepository.findById(accountId)
.orElseThrow(IllegalArgumentException::new);
stopWatch.stop();
System.out.println(stopWatch.prettyPrint());
assertThat(findAccount.getBalance()).isEqualTo(10 * 100);
}
}
이미지 출처: http://jaynewho.com/post/43
@SpringBootTest
@AutoConfigureMockMvc
class AccountUpdateServiceTest {
private static final ExecutorService service = Executors.newFixedThreadPool(100);
@Autowired AccountUpdateService accountUpdateService;
@Autowired AccountRepository accountRepository;
private long accountId;
@BeforeEach
void beforeAll() {
Account account = Account.of("우리");
accountRepository.save(account);
accountId = account.getId();
}
@Test
@DisplayName("입금 멀티 스레드 race condition 테스트")
void depositMultiThreadRaceConditionTest() throws InterruptedException {
StopWatch stopWatch = new StopWatch();
stopWatch.start();
// given
int count = 100;
// then
CountDownLatch latch = new CountDownLatch(count);
for (int i = 0; i < count; i++) {
service.execute(() -> {
accountUpdateService.deposit(accountId, 10);
latch.countDown();
});
}
latch.await();
// when
Account findAccount = accountRepository.findById(accountId)
.orElseThrow(IllegalArgumentException::new);
stopWatch.stop();
System.out.println(stopWatch.prettyPrint());
assertThat(findAccount.getBalance()).isEqualTo(10 * 100);
}
}
ExecutorService를 통해 총 100개의 스레드를 생성
총 100번의 스레드가 10원을 입금 하기 때문에 총 1000이 나올 것이라 예상하였다.
하지만 예상과 달리 150원만이 입금 된 것이 확인된다.
입금 내역이 업데이트 되기 전 데이터를 읽어와 일어난 현상이다.
이제 이를 비관적 잠금으로 해결해보자.
@Repository
public interface AccountRepository extends JpaRepository<Account, Long> {
@Lock(LockModeType.PESSIMISTIC_WRITE)
Optional<Account> findById(Long id);
}
JPA에서는 @Lock
어노테이션을 제공하여 비관적 잠금을 사용할 수 있도록 지원해준다.
옵션 (LockModeType)
결과
테스트는 이상없이 통과하는 것이 확인된다.
그러나 Lock을 사용하기 전보다 실행시간이 늘어난 것을 확인할 수 있다.
콘솔창
그러면 무엇이 변경되었길래 멀티 스레드 환경에서 정합성을 맞출 수 있었던 걸까
그것은 쿼리 콘솔을 보면 쉽게 해결 가능하다.
Hibernate:
select
account0_.id as id1_0_0_,
account0_.balance as balance2_0_0_,
account0_.name as name3_0_0_
from
account account0_
where
account0_.id=? for update
Hibernate:
update
account
set
balance=?,
name=?
where
id=?
select ~ for update
이전과 달리 for update 구문이 추가된 것이 보인다.
이는 데이터 베이스에서 제공하는 구문으로써 특정 row에 배타적 LOCK을 거는 행위이다.
https://github.com/bum12ark/transaction-account