🚀 Double Spend Issue - 이중 이체 문제
두 개의 트랜잭션이 동시에 동일한 계좌에서 동일한 금액을 이체하려고 할 때, 충분한 잔액이 없는 상황에서 각각의 트랜잭션이 잔액을 충분하다고 판단 함으로 인해 계좌 잔액이 부족한데도 이체가 완료되는 상황
👿 세부 상황
A계좌에서 B계좌로 만원, C계좌로 만원을 이체하려고 한다. 순차적으로 A계좌에서 B계좌로, B계좌에서 C계좌로 이체하는 것이 아닌 동시에 두 이체가 이루어지는 상황
📄 @Test 코드
@Test
@DisplayName("A계좌 이중이체 발생 문제")
void A계좌_이중이체_발생_문제() throws Exception{
TransferRequest requestToAccountB = new TransferRequest("1234567890", "2345678901", BigDecimal.valueOf(10000), "1234");
TransferRequest requestToAccountC = new TransferRequest("1234567890", "3456789012", BigDecimal.valueOf(10000),"1234");
//given
ExecutorService executorService = Executors.newFixedThreadPool(2);
CountDownLatch latch = new CountDownLatch(2);
//when
transferTo(executorService, latch, requestToAccountB);
transferTo(executorService, latch, requestToAccountC);
latch.await();
//then
Account accountB = accountRepository.getByAccountNumber("2345678901");
Account accountC = accountRepository.getByAccountNumber("3456789012");
long count = transferHistoryRepository.count();
assertThat(accountB.getBalance().toBigInteger()).isEqualTo(BigInteger.valueOf(10000));
assertThat(accountC.getBalance().toBigInteger()).isEqualTo(BigInteger.valueOf(10000));
assertThat(count).isEqualTo(2);
}
transferTo 메서드
private void transferTo(ExecutorService executorService, CountDownLatch latch, TransferRequest request) {
executorService.submit(() -> {
try {
transferService.transfer(request);
} finally {
latch.countDown();
}
});
}
executorService.submit을 해줄때, 필요한 인수가 다른 두 메서드를 실행하고자 하는 코드가 중복되는 부분을 없애고자 새로운 transferTo 메서드를 선언해서 사용해주었다.
@BeforeEach
@BeforeEach
public void init() {
Member member = Member.builder()
.email("abc@abc.com")
.nickName("abc")
.password("abc123")
.build();
memberRepository.save(member);
Account accountA = Account.builder()
.accountNumber("1234567890")
.balance(BigDecimal.valueOf(10000))
.password("1234")
.member(member)
.build();
Account accountB = Account.builder()
.accountNumber("2345678901")
.balance(BigDecimal.valueOf(0))
.password("1235")
.member(member)
.build();
Account accountC = Account.builder()
.accountNumber("3456789012")
.balance(BigDecimal.valueOf(0))
.password("1236")
.member(member)
.build();
accountRepository.save(accountA);
accountRepository.save(accountB);
accountRepository.save(accountC);
}
각 테스트가 실행될 때마다, 3개의 계좌를 생성해주었다. 계좌A는 만원, 계좌B는 0원, 계좌C는 0원이 들어있다.
Q. 이 테스트 코드의 결과는 어떻게 되었을까?
계좌 A에는 돈이 만원만 들어있기 때문에, B계좌에 만원이 이체되거나 C계좌에 만원이 이체되어야 한다.
assertThat(accountB.getBalance().toBigInteger()).isEqualTo(BigInteger.valueOf(10000));
assertThat(accountC.getBalance().toBigInteger()).isEqualTo(BigInteger.valueOf(10000));
두 계좌 모두 만원이 이체되었다.
Q. 왜 이러한 문제가 발생할까?
파라미터 바인딩을 확인해보면, A계좌에서 B계좌로 이체하는 것과 B계좌에서 C계좌로 이체하는 것중 더 빠른 것을 확인해본 결과 "3456789012"라는 계좌번호를 가진 C계좌에 이체를 하는 트랜잭션이 먼저 시작됨을 알 수 있다. 단, 매번 테스트마다 실행되는 순서는 랜덤하게 바뀐다.
A계좌에서 C계좌로 이체하는 트랜잭션을 A트랜잭션이라고 가정하고, B계좌에서 C계좌로 이체하는 트랜잭션을 B트랜잭션이라고 가정하자.
A트랜잭션이 진행중인 도중에 B트랜잭션이 시작되었으므로, A트랜잭션에서 A계좌에 만원이 빠져나간 부분을 커밋하 전에, A계좌의 잔액을 기반으로 B트랜잭션을 진행하게 된다. 다시말해, A트랜잭션에서 이체된 A계좌의 상태가 update되기 전에 이체되지 않은 A계좌를 B트랜잭션에서 사용하기 때문에 발생하는 문제다.
해결방법 후보
1. synchronized
@Transactional
public synchronized void transfer(final TransferRequest request) {
이체하는데 사용하는 메서드에 synchronized를 붙여주었다.
Q. synchronized는 무엇이고, 왜 해결책이 될 수 있는가?
synchronized는 특정 자원에 대한 동시 접근을 제어하여 Race Condition을 방지해준다.
Q. Race Condition이란?
경쟁 조건(Race Condition)은 멀티스레드 환경에서 두 개 이상의 스레드가 공유 자원에 동시에 접근하려 할 때 발생하는 문제이다.
Q. synchronized는 동시 접근을 어떻게 제어해주는가?
메서드에 synchronized 키워드를 붙이면, 메서드 전체를 동기화해서 한 번에 하나의 스레드만 해당 메서드를 실행 시켜 준다.
synchronized 실험
https://sangyunpark99.tistory.com/18
synchronized 키워드를 붙여주고 재실행한 Test 코드 결과
여전히, 이중 이체 문제가 발생하는 사실을 확인할 수 있다.
//@Transactional
public synchronized void transfer(final TransferRequest request) {
@Transactional 어노테이션을 제거한 후, 테스트를 돌리는 경우엔 이중이체가 발생되지 않았다.
Q. @Transactional과 synchronized를 같이 사용할 시에 왜 동시성 문제가 해결되지 않을까?
Transactional의 자세한 구조는 다음과 같다.
JPA에서 Transaction 흐름
EntityManager entityManager = emf.createEntityManager();
EntityTransaction transaction = entityManager.getTransaction();
transaction.begin(); // Transaction 시작
bussinessLogic()
transaction.commit(); // Transaction 커밋
AOP로 인하여 @Transactional 어노테이션 하나로 위 로직을 생략하면서 사용하는 것이다.
Spring은 @Transactional이 붙은 메서드를 호출하는 경우, Proxy 객체를 생성한다.
Proxy 객체는 원본의 객체를 감싸고, 메서드 호출 전에는 transaction.begin을 메서드를 호출 한 후에는 transaction.commit을 수행한다. 코드로 보면 다음과 같다.
EntityManager entityManager = emf.createEntityManager();
EntityTransaction transaction = entityManager.getTransaction();
transaction.begin(); // Transaction 시작
// 호출한 synchronized 메서드
transaction.commit(); // Transaction 커밋
😎 transaction begin과 commit은 synchronized의 영향을 받지 않는다.
한 쓰레드에서 transaction이 커밋(DB에 반영)되기 전에 다른 쓰레드가 synchronized가 붙은 메서드에 접근을 하여 커밋이 되지 않은 데이터를 사용하게 된다.
transaction단에서 동시성 문제를 해결해주지 않는다면, 문제는 해결되지 않는다.
2. Locking
기존 synchronized 키워드를 사용함에도 transaction으로 인해 발생되는 문제를 해결해주기 위해선, 더 근본적인 원인을 제공하는 데이터에 대해 동시성 처리를 해주면 된다.
Q. Locking 은 무엇이고, 왜 해결책이 될 수 있는가?
특정 자원(데이터)에 접근을 제한하여, 다른 트랜잭션이나 여러 스레드가 동시에 해당 자원에 접근하지 못하도록 하는 것이다.
Locking의 전략에는 2가지가 존재한다.
- Optimistic Lock(낙관적 Lock)
- Pessimistic Lock(비관적 Lock)
🔒 비관적 잠금(Pessimistic Lock) 적용
동시성 제어가 필요한 데이터는 계좌의 잔액이다. 계좌의 잔액은 계좌에 포함되어 있는 필드 변수이다.
이체 로직이 실행될때, Locking을 해주어야 하는 부분은 계좌번호를 통해 계좌의 객체를 불러오는 부분이다.
아래 코드와 같이 계좌번호를 통해 계좌의 객체를 불러오는 메소드를 @Query 어노테이션을 통해 직접 작성해주었다.
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select a from Account a where a.accountNumber = :accountNumber")
Optional<Account> findByAccountNumberForUpdate(@Param("accountNumber") String accountNumber);
default Account getByAccountNumberForUpdate(final String accountNumber) {
return findByAccountNumberForUpdate(accountNumber).orElseThrow(NotExistAccountException::new);
}
Q. PESSIMISTIC_WRITE란?
배타 잠금(Exclusive Lock)을 통해 다른 트랜잭션이 해당 데이터를 읽거나 수정하지 못하도록 한다.
@Test
long count = transferHistoryRepository.count();
if(accountB.getBalance().compareTo(BigDecimal.ZERO) > 0) {
assertThat(accountB.getBalance().toBigInteger()).isEqualTo(BigInteger.valueOf(10000));
assertThat(accountC.getBalance().toBigInteger()).isEqualTo(BigInteger.valueOf(0));
}else {
assertThat(accountB.getBalance().toBigInteger()).isEqualTo(BigInteger.valueOf(0));
assertThat(accountC.getBalance().toBigInteger()).isEqualTo(BigInteger.valueOf(10000));
}
assertThat(count).isEqualTo(1);
Q. 왜 조건문을 사용해서 분기처리를 해주었는가?
테스트 코드 또한 변경해주었다. A계좌에서 B계좌로 이체하는 Transaction과 A계좌에서 C계좌로 이체하는 Transaction 중 먼저 시작되는 Transaction은 예측이 어려우므로, 발생할 수 있는 모든 경우를 전부 고려해주었다. 발생할 수 있는 경우는 다음과 같다.
(1) A계좌에서 B계좌에 이체하는 Transaction이 먼저 실행되는 경우, B계좌에는 만원이 존재하고, C계좌에는 0원이 존재한다.
(2) A계좌에서 C계좌에 이체하는 Transaction이 먼저 실행되는 경우, C계좌에는 만원이 존재하고, B계좌에는 0원이 존재한다.
Q. 왜 transferHistoryRepository.count()를 사용했는가?
이체하는 transaction이 한번 완료될때마다, 이체 내역을 저장해준다. count 함수는 저장소(Repository) 내에서 특정 조건을 만족하는 엔티티가 몇 개 존재하는지를 알려준다. 이체 내역이 잘 저장되었는지 확인하고자 사용한다.
✅ Test 결과(time: 1s 626ms)
🔒 낙관적 잠금(Optimistic Lock) 적용
낙관적 잠금은 @Version 어노테이션을 사용해준다.
데이터에 버전관리를 추가해주어 수정 시점을 추적해주는 방식이다. 수정 시점이 올바르지 않을 경우 충돌이 발생한 것으로 간주하고, 해당 트랜잭션을 다시 시작하거나 적절히 처리하는 로직이 필요하다.
Account
package com.example.payment.account.entity;
import com.example.payment.global.entity.BaseEntity;
import com.example.payment.member.entity.Member;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import jakarta.persistence.Version;
import java.math.BigDecimal;
import lombok.AccessLevel;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
@Getter
@Entity
@Table(name = "accounts")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Account extends BaseEntity {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "account_id")
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "member_id")
private Member member;
@Column(name = "account_number", nullable = false, updatable = false, length = 10)
private String accountNumber;
@Column(nullable = false)
private BigDecimal balance;
@Column(nullable = false)
private String password;
@Version
private Integer version;
@Builder
public Account(final Member member, final String accountNumber, final BigDecimal balance, final String password) {
this.member = member;
this.accountNumber = accountNumber;
this.balance = balance;
this.password = password;
}
public void updateBalance(final BigDecimal balance) {
this.balance = balance;
}
}
@Version 어노테이션을 사용해서 version을 엔티티에 추가해준다.
// Optimistic Lock
@Lock(LockModeType.OPTIMISTIC)
@Query("select a from Account a where a.accountNumber = :accountNumber")
Optional<Account> findByAccountNumberForUpdate(@Param("accountNumber") String accountNumber);
수정 시점이 올바르지 않을 경우, 즉 버전 충돌이 일어날 경우를 고려해서, 트랜잭션을 재시도 해주는 로직을 구현해주어야 한다.
AOP를 사용하여 재시도 로직을 구현
CustomAnnotation Retry
package com.example.payment.transfer;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface Retry {
}
@Retry에 대한 Aop
package com.example.payment.transfer;
import jakarta.persistence.OptimisticLockException;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Pointcut;
import org.hibernate.StaleObjectStateException;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.orm.ObjectOptimisticLockingFailureException;
import org.springframework.stereotype.Component;
@Order(Ordered.LOWEST_PRECEDENCE - 1)
@Aspect
@Component
public class OptimisticLockRetryAspect {
private static final int RETRY_NUMBER = 100;
private static final int RETRY_DELAY = 100;
@Pointcut("@annotation(Retry)")
public void retry() {
}
@Around("retry()")
public Object retryLock(ProceedingJoinPoint joinPoint) throws Throwable {
Exception exceptionHolder = null;
for (int attempt = 0; attempt < RETRY_NUMBER; attempt++) {
try {
return joinPoint.proceed();
} catch (OptimisticLockException | ObjectOptimisticLockingFailureException | StaleObjectStateException e) {
exceptionHolder = e;
Thread.sleep(RETRY_DELAY);
}
}
throw exceptionHolder;
}
}
@Retry
@Transactional
public void transfer(final TransferRequest request) {
✅ Test 결과(time: 2s 20ms)
Q. 비관적 락과 낙관적 락 두 방법 중 어떤 방법을 선택해서 동시성 문제를 해결할것인가?
결론을 먼저 이야기하자면, 비관적 락을 사용하는 것이 더 좋은 선택이라고 생각한다.
낙관적 락은 데이터 수정 시 버전 번호를 확인하여 충돌을 감지한다. 이중 이체가 발생하는 상황에서는 충돌이 무조건 발생한다. 다시 말해, 두 개의 트랜잭션이 동일한 계좌에서 동일한 금액을 동시에 이체하려고 하면 항상 버전 충돌이 발생하고, 그로 인해 하나의 트랜잭션은 롤백이 되고, 롤백이 된 후에 재시작이 되어야 한다. 롤백이 되는 시간과 재시작 되는 시간이 더해져 시간적으로 비효율적인 결과를 불러 일으킨다.
실제로 테스트 시간을 비교했을때,
🔒 비관적 락은 (1s 626ms)가 걸리게 되었고, 🔒 낙관적 락은 (2s 20ms)가 걸렸다.
계좌 이체는 금전적인 거래이므로 신속하고 정확한 처리가 필요하다.그러므로, 최대한 동시성 문제가 발생할 상황을 완전히 배제 시키는 것이 맞다고 생각한다. 비관적 락을 사용하여 배타 잠금을 통해 격리성을 높이는 것이 더 좋다고 생각한다.
단, 무조건 비관적 락을 사용하는 것이 정답은 아니다..!
비관적 락은 분산 DB 환경에서는 굉장히 좋지 않은 방법이다.
단일 DB 환경에서는 하나의 중앙 관리자가 모든 잠금을 관리하기 때문에, 비관적 락을 통해 트랜잭션 간의 충돌을 잘 제어할 수 있다. 하나의 트랜잭션이 잠금을 걸면, 다른 트랜잭션은 그 잠금이 해제될 때까지 대기하게 된다.
분산 DB 환경에서는 여러 노드 간의 동기화 문제로 인해 동시성 제어의 효율성이 떨어진다.
상당한 요청이 동시에 들어오게 되는 경우, 비관적 잠금이 비동기적으로 처리할 수 있는 트랜잭션들을 동기적으로 처리하게 만들며, 이로 인해 성능 저하와 동시성 제어의 어려움이 발생한다.
'트러블슈팅' 카테고리의 다른 글
좋아요에서 발생하는 동시성 이슈 (2) | 2024.10.14 |
---|---|
Redis사용시, 발생하는 데이터 정합성 문제 (0) | 2024.10.01 |