JPA로 인한 이슈

this-is-spear·2023년 2월 23일
0

Intro

시작하면서

동시성을 위해 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

transferWithLocktransferMoney

내부에서 같은 주소를 가진 객체를 사용하고 있었습니다.

즉, 엔티티 매니저에 의해 캐싱된 객체를 반환하는 것을 파악했습니다. 쉽게 Lost Update Problem이 발생한다는 것을 인지할 수 있었지만, 두 번 동시 요청했을 때 예상한 문제가 발생하는 지 확인했습니다.

두 번 동시 접근할 때

첫 번째 접근 - transferMoney

첫 번째 접근 - Account의 balance

첫 번째 접근에서 Account의 주소가 Account@13551, 잔액이 997,000 원인 것을 확인할 수 있습니다.

두 번째 접근 - transferMoney

두 번째 접근 - Account의 balance

두 번째 접근에서 Account의 주소가 Account@13551, 잔액이 997,000 원인 것을 확인할 수 있습니다.

두 개의 요청 모두 Account 주소와 잔액이 같은 것으로 보아 이전 작업이 완료되기전 데이터를 먼저 읽어 하나의 작업만 성공하는 Lost Update Problem인 것을 확인할 수 있었습니다.

그래서 정리하자면

어떤게 문제지?

  • 엔티티 매니저는 동일한 식별자를 갖는 엔티티를 두 번 조회하게 되면 쿼리가 실행되지 않고 영속성 컨텍스트에서 값을 가져오게 됩니다.
  • 즉, 동시성 제어가 필요한 작업이 이전 요청이 완료되기전 데이터를 먼저 읽었기 때문에 발생하는 문제였습니다.

해결하기 위해 고민한 방법

  • 방법 1 - 추가하려는 로직을 동시성이 제어 영역에 추가
    • 장점
      • 수정이 간단함
    • 단점
      • 성능 저하의 원인이 됨
  • 방법 2 - DB에서 다시 조회할 수 있도록 설정
    • 장점
      • 수정이 간단함
      • 성능 저하가 발생하지 않음
    • 단점
      • 사이드 이펙트 가능성이 있음

결론

후자의 방법을 선택했는데, 새로운 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);
}

Outro

배운점

  • 엔티티 매니저는 같은 식별자로 두 번 조회할 때, 캐싱한 데이터를 반환합니다.
  • JPA를 사용하며 동시성을 제어할 때 식별자가 같은 엔티티를 조회하는 상황을 주의해야 합니다.

회고

  • Integration Test 코드 덕분에 문제 상황을 인지할 수 있었습니다.
  • 작성한 코드 외적인 상황에서 문제 원인 파악은 정말 어렵습니다.
profile
익숙함을 경계하자

0개의 댓글