추후 구현할 예정인 쿠폰 서비스 등 트랜잭션 관련하여 동시성 문제가 발생할 것이라 생각되어 스터디를 진행해보았습니다.
트랜잭션 동시성 문제 - 비관적 잠금
트랜잭션 동시성 문제 중 손실되는 업데이트의 해결 방식 중 하나인 비관적 잠금에 대한 포스팅입니다.
동시성 테스트를 위해 간단한 계좌 입금 시스템을 만들어 테스트 하였습니다.
프로젝트 세팅
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()
}
클래스 설계
Account
@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;
}
}
AccountUpdateService
@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();
}
}
- 계좌 수정 관련한 서비스 클래스
- deposit 메소드를 통해서 accountId에 amount 만큼 입금을 해주는 메소드
AccountRepository
@Repository
public interface AccountRepository extends JpaRepository<Account, Long> {
}
AccountUpdateService
@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
- 데이터베이스가 제공하는 lock 기능을 이용해 엔티티를 영속 상태로 올릴 때부터 다른 세션에서 조회하지 못하도록 잠금 처리합니다.
- 활동성은 저하되지만 정확성과 세션의 성공은 보장됩니다.
테스트
@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원만이 입금 된 것이 확인된다.
입금 내역이 업데이트 되기 전 데이터를 읽어와 일어난 현상이다.
이제 이를 비관적 잠금으로 해결해보자.
AccountRepository 변경
@Repository
public interface AccountRepository extends JpaRepository<Account, Long> {
@Lock(LockModeType.PESSIMISTIC_WRITE)
Optional<Account> findById(Long id);
}
JPA에서는 @Lock 어노테이션을 제공하여 비관적 잠금을 사용할 수 있도록 지원해준다.
-
옵션 (LockModeType)
- LockModeType.PESSIMITIC_WRITE
- default 옵션
- 다른 트랜잭션에서 read, write 불가 (배타적 잠금)
- LockModeType.PESSIMISTIC_READ
- 반복 읽기만하고 수정하지 않은 용도로 사용
- 다른 트랜잭션에서 읽기는 가능 (공유 잠금)
- LockModeType.PESSIMISTIC_FORCE_INCREMENT
-
결과

테스트는 이상없이 통과하는 것이 확인된다.

그러나 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
레퍼런스
추후 구현할 예정인 쿠폰 서비스 등 트랜잭션 관련하여 동시성 문제가 발생할 것이라 생각되어 스터디를 진행해보았습니다.
트랜잭션 동시성 문제 - 비관적 잠금
트랜잭션 동시성 문제 중 손실되는 업데이트의 해결 방식 중 하나인 비관적 잠금에 대한 포스팅입니다.
동시성 테스트를 위해 간단한 계좌 입금 시스템을 만들어 테스트 하였습니다.
프로젝트 세팅
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() }클래스 설계
Account
AccountUpdateService
AccountRepository
AccountUpdateService
손실되는 업데이트
비관적 잠금
출처 : http://jaynewho.com/post/43
테스트
ExecutorService를 통해 총 100개의 스레드를 생성
총 100번의 스레드가 10원을 입금 하기 때문에 총 1000이 나올 것이라 예상하였다.
결과
하지만 예상과 달리 150원만이 입금 된 것이 확인된다.
입금 내역이 업데이트 되기 전 데이터를 읽어와 일어난 현상이다.
이제 이를 비관적 잠금으로 해결해보자.
AccountRepository 변경
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
레퍼런스