동시성 제어를 Transcation
으로만 계좌 이체를 구현하는 중에 TOTAL
만큼 이체 요청을 보낼 때, N
개씩 동시에 요청을 하는 경우 TOTAL⁒N
개 만 처리된 문제가 발생했습니다.
문제 상황은 천 원 출금 명령을 200 번 했을 때 잔액에서 20 만원이 빠지는 것을 검증하기 위한 테스트에서 발생했습니다.
/**
* @Given : 사용자는 백만원을 입금하고
* @When : 상대방에게 동시에 천 원을 200번 요청하면
* @Then : 잔액에 80만원 남는다.
*/
@Test
void transfer_concurrency_200_times() throws Exception {
// given
long 입금할_돈 = 백만원;
long 출금할_돈 = 천원;
int 요청_횟수 = 200;
// 로직 생략...
// when
계좌_이체_여러번_요청(나의계좌, 상대방계좌, 출금할_돈, 요청_횟수);
// then
계좌_조회_요청(나의계좌).andExpect(
jsonPath(잔액).value(입금할_돈 - 출금할_돈 * 요청_횟수));
계좌_조회_요청(상대방계좌).andExpect(
jsonPath(잔액).value(출금할_돈 * 요청_횟수));
}
계좌_이체_여러번_요청
메서드에서 스레드 개수만큼 동시에 요청을 보내고 있는 상황입니다.
private void 계좌_이체_여러번_요청(...) throws InterruptedException {
int 스레드_개수 = 2;
CountDownLatch latch = new CountDownLatch(요청_횟수);
ExecutorService executorService = Executors.newFixedThreadPool(스레드_개수);
for (int i = 0; i < 요청_횟수; i++) {
executorService.execute(() -> {
try {
계좌_이체_요청(나의계좌, 상대방_계좌, 천원);
latch.countDown();
} catch (Exception e) {
throw new RuntimeException(e);
}
});
}
latch.await();
}
천 원을 출금하라는 명령을 200 번 했을 때, 두 개의 스레드로 요청하게 된다면 100 번만 성공하면서 딱 하나의 작업만 성공적으로 처리하고 그 외의 예외가 발생하지 않아 문제를 쉽게 파악할 수 없었습니다.
두 명 이상의 사용자가 동일한 데이터 항목에 동시에 액세스하고 업데이트하는 경우에 발생하는 데이터 무결성 문제입니다.
첫 번째 작업이 커밋되기 전 두 번째 작업이 요청하고 커밋하게 되면 첫 번째 작업이 손실이 발생하게 됩니다.
Lost Update Problem을 해결하기 위해서 요구 사항에 따라 적절한 동시성 제어 메커니즘을 신중하게 평가하고 선택해야 하기에 제가 고려한 사항은 두 가지입니다.
Lost Update Problem은 트랜잭션이 제대로 관리되지 않거나 동시 처리가 제어되지 않는 시스템에서 발생합니다. 그렇기에 Lost Update Problem를 방지하기 위해 잠금, 트랜잭션 등의 동시성 제어 메커니즘을 사용할 필요가 있습니다.
처음에는 MySQL의 격리 레벨을 REPEATABLE_READ 높이면 Lost Update Problem 문제가 해결될 것이라 생각했지만 예상과 다르게 데드락이 발생했습니다.
문제 상황을 유추해보건데 하나의 로직이 커밋할 때, Dirty Checking으로 Update가 발생해 Lock을 걸고 있는 상황에서 다른 트랜잭션이 읽기 위해 Lock을 걸게되어 발생하는 상황이라 볼 수 있습니다.
아래 상황을 보면 데드락이 발생할 수 있는 조건까지 완벽하게 갖추고 있습니다.
토이 프로젝트를 진행하던 도중이라 동시성 제어 기술의 장단점을 빠르게 분석해 빠르게 적용할 필요가 있어서 간단하게 정리했습니다.
코드
UPDATE account a SET a.balance = a.balance - :amount WHERE a.accountNumber = :accountNumber
동시성을 제어해야 할 작업과 트래픽이 많지 않아 어떤 동시성 제어 방법을 사용해도 무리가 발생하지 않는다고 생각했고, 현재 상황에서 쉽게 적용할 수 있었습니다.
트래픽이 많아지면 비용이 높아지는 문제가 있지만, 동시성 제어 기술을 쉽게 교체할 수 있도록 저수준 레벨 코드를 격리해 확장성을 높였습니다.
동시성을 제어하기 위해 Decorator 패턴을 적용했습니다. 하나의 클래스 내에서 여러 동시성 제어 방법을 추상화해 명시함으로 문제를 해결할 수 있었습니다.
@Service
@RequiredArgsConstructor
public abstract class ConcurrencyManager {
private final AccountService accountService;
public void transfer(AccountNumber accountNumber, AccountNumber toAccountNumber, Money amount) {
executeWithLock(accountNumber.getNumber(),
() -> accountService.transferMoney(accountNumber, toAccountNumber, amount)
);
}
protected abstract void executeWithLock(String lockName, Runnable runnable);
}
@Override
public void execute(Runnable runnable) {
runnable.run();
}
@Override
public void executeWithLock(String lockName, Runnable runnable) {
log.debug("Start Concurrency Control : {}", lockName);
synchronized (this) {
runnable.run();
}
log.debug("End Concurrency Control : {}", lockName);
}