동시성을 위해 synchronized block으로 내부 코드를 제어했지만, 일부 로직을 수정해서 문제가 발생했다. 문제의 원인은 JPA의 엔티티의 생명주기를 생각하지 못한 착오였습니다.
문제는 Account
거래에서 발생했습니다. 접근한 사용자가 계좌 주인이 맞는지 판단하기 위해 Account
를 DB에서 읽어 validateMember
에서 유효성을 식별하는 로직을 추가했습니다.
스레드 4 개를 이용해 동시 접근을 진행한 결과는 아래와 같이 동시성 문제가 발생하고 있었습니다.
JSON path "$.balance.amount" expected:<800000> but was:<941000>
필요:800000
실제 :941000
/**
* 백만원 있는 사용자가 상대방에게 동시에 천원을 200번 요청하면 잔액에 80만원 남는다.
*/
@Test
void transfer_concurrency_200_times() throws Exception {
...
// when
계좌_이체_여러번_요청(나의계좌, 상대방계좌, 출금할_돈, 요청_횟수, 이메일, 비밀번호);
// then
계좌_조회_요청(나의계좌, 이메일, 비밀번호).andExpect(
jsonPath(AMOUNT).value(입금할_돈 - 출금할_돈 * 요청_횟수));
}
동시성을 제어하는 로직 외부에서 조회한 엔티티를 재활용하는 문제처럼 보였고, 실제 같은 객체를 이용하고 있는지 직접 확인할 필요가 있었습니다.
하나만 접근해 내부 동작을 파악했습니다.
transfer
transferWithLock
→ transferMoney
내부에서 같은 주소를 가진 객체를 사용하고 있었습니다.
즉, 엔티티 매니저에 의해 캐싱된 객체를 반환하는 것을 파악했습니다. 쉽게 Lost Update Problem이 발생한다는 것을 인지할 수 있었지만, 두 번 동시 요청했을 때 예상한 문제가 발생하는 지 확인했습니다.
첫 번째 접근 - transferMoney
첫 번째 접근 - Account의 balance
첫 번째 접근에서 Account의 주소가 Account@13551, 잔액이 997,000 원인 것을 확인할 수 있습니다.
두 번째 접근 - transferMoney
두 번째 접근 - Account의 balance
두 번째 접근에서 Account의 주소가 Account@13551, 잔액이 997,000 원인 것을 확인할 수 있습니다.
두 개의 요청 모두 Account 주소와 잔액이 같은 것으로 보아 이전 작업이 완료되기전 데이터를 먼저 읽어 하나의 작업만 성공하는 Lost Update Problem인 것을 확인할 수 있었습니다.
후자의 방법을 선택했는데, 새로운 Transaction 영역을 활용해 영속성 컨텍스트의 영역을 달리했습니다. 그로 인해 외부에서 선언한 엔티티를 재활용하지 않았고, 쉽게 동시성 제어가 가능했습니다.
코드는 동시성을 제어하는 로직 내부만 발췌했습니다.
AS-IS
@Transactional
public void transferMoney(AccountNumber fromAccountNumber, AccountNumber toAccountNumber,
Money money) {
Account fromAccount = getAccountByAccountNumber(fromAccountNumber);
Account toAccount = getAccountByAccountNumber(toAccountNumber);
fromAccount.withdraw(money);
toAccount.deposit(money);
}
TO-BE
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void transferMoney(String principal, AccountNumber fromAccountNumber, AccountNumber toAccountNumber,
Money money) {
Account fromAccount = getAccountByAccountNumber(fromAccountNumber);
Account toAccount = getAccountByAccountNumber(toAccountNumber);
fromAccount.withdraw(money);
toAccount.deposit(money);
}