[DB] 송금에서의 동시성 이슈 해결기 (feat. 락을 최소한으로 사용해보자!)

Loopy·2024년 2월 8일
1

삽질기록

목록 보기
28/28
post-thumbnail
post-custom-banner

만약 여러 사람이 한 계좌에 송금하거나, 한 계좌에 송금과 적금이 동시에 일어나는 경우 동시성 이슈를 어떻게 해결할 수 있을까에 대해 고민해보고 작성했다.

1. 여러 사람이 한 계좌에 송금

아래 코드는 송금 로직 예시로, 잔액이 부족하면 충전을 하고, 내 잔액을 차감시키고 친구의 잔액을 증가시키는 로직이다.

	@Transactional
	public TransferAccountDto.Res transfer(
		long accountId, String transferAccountNumber, long transferAmount
	) {
		Account account = accountRepository.findById(accountId)
			.orElseThrow(ErrorCode.INVALID_ACCOUNT::businessException);

		Account transferAccount = accountRepository.findByAccountNumbertransferAccountNumber)
			.orElseThrow(ErrorCode.INVALID_ACCOUNT::businessException);

		// 1. 잔액이 부족할 경우 10000원 단위로 자동 충전한다.
		if (account.isAmountLackToWithDraw(transferAmount)) {
			chargeService.autoChargeByUnit(accountId, transferAmount);
		}

		// 2. 잔액이 여유로워졌다면, 내 계좌의 잔액을 차감시키고 친구의 메인 계좌로 송금한다.
		account.withdraw(transferAmount);
		transferAccount.charge(transferAmount);

		long resultAmount = accountRepository.findAmount(accountId);
		return new TransferAccountDto.Res(resultAmount);
	}

테스트 시나리오는 다음과 같다.

이 상태에서 그대로 100명의 사람이 한 계좌(초기값 0원)에 접근하는 상황을 테스트해보자. 테스트가 성공했다면 5000 원씩 송금했으므로 해당 계좌의 잔액은 5000 * 100 = 500000 원이 되어야 한다.

	@Test
	void 동시에_같은_계좌에_송금이_발생한다() throws InterruptedException {		
        // given
		// 1. 회원 가입 -> 메인 계좌 자동 생성
		var userA = memberService.register("email1", "password1");
		var userB = memberService.register("email2", "password2");

		var userAAccountId = userA.accountId();
		var userBAccountId = userB.accountId();   // userB 에게 동시에 전송

		var transferAmount = 5000;
		var chargeAmount = 1000000;

		// 2. B 계좌로 보낼 수 있도록 잔액을 여유롭게 충전한다.
		var userBAccountNumber = accountRepository.findById(userBAccountId).orElseThrow().getAccountNumber();
		chargeService.charge(userAAccountId, chargeAmount);

		var concurrentUser = 100;
		List<CompletableFuture<Void>> futures = new ArrayList<>();
		var executorService = Executors.newFixedThreadPool(1000);
		var countDownLatch = new CountDownLatch(concurrentUser);

		// when
		for (int i = 0; i < concurrentUser; i++) {
			futures.add(CompletableFuture.runAsync(() -> {
				accountService.transfer(userAAccountId, userBAccountNumber, transferAmount);
				countDownLatch.countDown();
			}, executorService));
		}

		CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();
		countDownLatch.await();
		executorService.shutdown();

		// then
		var accountAEntity = accountRepository.findById(userAAccountId).orElseThrow();
		var accountBEntity = accountRepository.findById(userBAccountId).orElseThrow();
		assertThat(accountAEntity.getAmount()).isEqualTo(chargeAmount - transferAmount * concurrentUser);
		assertThat(accountBEntity.getAmount()).isEqualTo(transferAmount * concurrentUser);  // 동시성 이슈 확인 필요

하지만 아래와 같이 계좌에는 40000원 밖에 들어오지 않게 되는데, 동시성 이슈가 발생해 테스트가 깨진 것이다.

송금과 적금이 동시간대에 발생하는 경우

테스트 시나리오는 다음과 같다.

계좌에 10000원이 있는 상황에서, 적금으로 자동 이체가 10000원 빠져나가고 동시에 해당 계좌의 주인이 5000원을 친구에게 송금하려고 한다. 당연히 동시성 이슈가 발생하지 않는다면, 자동 이체가 잔액 부족으로 실패해야 한다.

	@Test
	void 송금_도중_발생한_적금_자동_이체는_실패한다() {
		// given
		// 1. 회원 가입 및 메인 계좌 생성
		var userA = memberService.register("email1", "password1");
		var userB = memberService.register("email2", "password2");

		var userAId = userA.memberId();
		var userAAccountId = userA.accountId();
		var userBAccountNumber = accountRepository.findById(userB.accountId()).orElseThrow().getAccountNumber();

		// 2. 적금 계좌 생성
		var withdrawAmount = 10000;
		var savingsAccountId = savingsAccountService.createSavingsAccount(userAId, "name", withdrawAmount, SavingsType.REGULAR).id();

		// 3. 잔액 충전
		var chargeAmount = 10000;
		chargeService.charge(userAAccountId, chargeAmount);

		// when + then
		var transferAmount = 5000;

		var future1 = CompletableFuture.runAsync(() ->
		{
			try {
				savingsAccountService.transferForRegularSavings(userAId);
			} catch (BusinessException ex) {
				assertThat(ex.getErrorMessage()).isEqualTo(ErrorCode.ACCOUNT_LACK_OF_AMOUNT.getMessage());
			}
		});

		var future2 = CompletableFuture.runAsync(() ->
			accountService.transfer(userAAccountId, userBAccountNumber, transferAmount)
		);

		CompletableFuture.allOf(future1, future2).join();  // wait

		var transferResultAmount = accountRepository.findAmount(userAAccountId);
		var savingsResultAmount = savingsAccountRepository.findById(savingsAccountId).orElseThrow().getAmount();
		assertThat(transferResultAmount).isEqualTo(chargeAmount - transferAmount);  // 송금 성공
		assertThat(savingsResultAmount).isEqualTo(0);   // 적금 실패
	}

하지만 테스트를 해보면, 적금 계좌에 그대로 10000원이 들어갔으며 송금도 성공한 것을 볼 수 있다.

☁️ CompletableFuture
Java에서 비동기 프로그래밍을 지원하는 클래스중 하나이다.

그렇다면 문제를 해결하기 위해서는 어떻게 하는게 좋을까?

🙃 쓰기 락(비관적 락)을 사용해보자!

첫째로, 락을 걸어서 다른 트랜잭션에서 아예 접근할 수 없도록 해보자. 비관적 락의 종류에는 읽기 락과, 쓰기 락 두가지가 존재한다.

읽기 락과 쓰기 락

  • 읽기 락
    • 다른 트랜잭션에서 잠긴 데이터를 읽을 수 있고 다른 공유 락을 획득할 수 있지만, 쓰기는 허용하지 않는다.
    • 참고로 InnoDB는 기본 격리 레벨이 Repeatable Read 이므로 읽기 잠금을 걸지 않아도, 언두 로그를 통해 일관성 있는 데이터를 읽어옴을 보장할 수 있다
  • 쓰기 락
    • 다른 트랜잭션에서 같은 레코드에 읽기 / 쓰기 잠금 모두 걸 수 없다.

물론 읽기 락을 사용하는게 성능적인 측면에서 낫지만, 읽기 락을 사용한다면 동시성 이슈를 해결할 수 없다.

  1. 특정 A 사용자가 읽기 락을 얻어서 송금 트랜잭션에 진입한다. (잔액 : 10000원)
  2. 사용자가 5000원을 인출하기 위해 5000원으로 업데이트 하는 쿼리를 날린다.
  3. 업데이트 쿼리(자동으로 쓰기 락이 걸림)가 날라가기 바로 직전에, 적금 트랜잭션에서 해당 계좌의 잔액을 조회하고 (10000원), 5000원을 차감한 금액으로 업데이트 하는 쿼리를 날린다.
  4. 결론적으로 계좌의 잔액은 0원이 아닌, 5000원이 된다.

따라서 조회 자체를 막기 위해 쓰기 락을 사용해보자. 아래와 같은 방식으로 송금할 계좌를 DB에서 가져올 때, 비관적 락을 걸어주면 동시성 이슈를 보장할 수 있다.

public interface AccountRepository extends JpaRepository<Account, Long> {

	@Lock(LockModeType.PESSIMISTIC_WRITE)
	@Query("select ac from Account ac where ac.id = :id")
	Optional<Account> findByIdWithWriteLock(@Param("id") long id);
    
    @Lock(LockModeType.PESSIMISTIC_WRITE)
	@Query("select ac from Account ac where ac.accountNumber = :accountNumber")
	Optional<Account> findByAccountNumberWithWriteLock(String accountNumber);

🤔 쓰기락을 사용하지 않고 해결할 수 있을까?

하지만 위와 같은 방식은 동시에 일어날 확률이 낮은 상황에서 성능을 희생시키는 방식이므로 좋지 않다. 특정 계좌의 잔액을 조회하려고만 해도, 송금 트랜잭션이 끝나 락을 반환할 때까지 기다려야 하기 때문이다. 😰

트랜잭션 레벨 조정하기

트랜잭션 레벨만 조정해서 이슈를 해결할 수 있을까?

@Transactional(isolation=Isolation.SEARLIZEABLE)  // 추가
public TransferAccountDto.Res transfer(
		long accountId, String transferAccountNumber, long transferAmount
) {
    ...
}

트랜잭션 레벨 4인 Serializable 은 해당 트랜잭션 안에서 조회되는 모든 row(SELECT)에 대해서 읽기락을 획득하고 읽어오며, 해당 row를 수정할 땐 쓰기락을 획득하고 수정한다.

오, 그러면 직접 락을 걸어주지 않아도 레벨을 4로 설정해두면 동시성 이슈가 해결되지 않을까? 라고 생각하면 오산이다. 다음과 같은 상황을 생각해보자.

  1. A 트랜잭션에서 K row를 읽었다. (읽기 락 획득)
  2. 이후 B 트랜잭션에서 K row K를 읽었다. (읽기 락 획득)
  3. 이후 A 트랜잭션에서 업데이트를 위해 쓰기락을 획득하려고 하지만, B 트랜잭션이 잡은 읽기 락이 반환될 때 까지 대기하게 된다.
  4. 마찬가지로 B 트랜잭션에서 업데이트를 위해 쓰기락을 획득하려고 하지만, B 트랜잭션이 잡은 읽기 락이 반환될 때 까지 대기하게 된다.

왜 이런 현상이 발생할까?

읽기 락은 여러 트랜잭션에서 동시에 획득할 수 있지만, 다른 트랜잭션이 읽기 락을 획득해 놓은 상태에서는 쓰기 락을 획득하지 못하고 대기해야 하기 때문이다.

따라서 두 트랜잭션이 동시에 한 row를 읽어오고 수정하려 한다면 무한히 자원을 기다리게 되는 데드락 현상이 발생한다. 직접 테스트를 해봐도, 데드락이 발생하고 만다.. 🥹

결론적으로 트랜잭션 격리 수준은 트랜잭션 동안의 일관된 데이터 읽기를 고려하기 위해 적용하는거지, 동시성 이슈 해결과는 연관이 없다.

락을 쓰되, 락의 범위를 줄여서 해결하기

근데 생각해보면, 꼭 처음 트랜잭션에 들어갈 때 락을 잡지 않고 해결할 수 있는 방법이 있어보인다.

UPDATE 또는 DELETE 문의 경우 InnoDB는 업데이트하거나 삭제하는 행에 대해서만 잠금을 유지합니다. 일치하지 않는 행에 대한 레코드 잠금은 MySQL이 WHERE 조건을 평가한 후에 해제됩니다. 이는 데드락이 발생할 확률을 크게 낮춥니다. (공식 문서 발췌)

어짜피 가장 중요한 업데이트를 수행할 때 자동으로 쓰기 락을 획득하니, 업데이트 하는 시점에만 현재 대상 잔액이 정확한지 체크해도 되지 않을까?

즉 읽는 것은 자유롭게 놔두고, 업데이트 할 때 내가 5000원을 송금하려고 했으면 내 계좌의 잔액이 5000원 이상 남아있는지 업데이트문에 조건을 추가해주는 것이다.

MySQL에서는 업데이트의 결과로 성공한 row 수를 반환해주니,이 값이 0이라면 그 사이에 다른 트랜잭션으로 인해 잔액 데이터가 변경되었음을 뜻한다. 따라서 그때 잔액이 부족하다고 예외를 반환해주거나, 재시도를 해주면 된다.

public interface AccountRepository extends JpaRepository<Account, Long> {

    ...
	@Modifying
	@Query("update Account ac "
		+ "set ac.amount = ac.amount - :amount "
		+ "where ac.id = :id and ac.amount >= :amount")
	int withdraw(@Param("id") long id, @Param("amount") long amount);
    ...
}

JPA의 변경감지를 활용하지 않고, 직접 @Query를 통해 조건을 추가해 작성해주었다. 서비스 로직은 다음과 같다.

	@Transactional
	public TransferAccountDto.Res transfer(
		long accountId, String transferAccountNumber, long transferAmount
	) {
		Account account = accountRepository.findById(accountId)
			.orElseThrow(ErrorCode.INVALID_ACCOUNT::businessException);

		// 1. 잔액이 부족할 경우 10000원 단위로 자동 충전한다.
		if (account.isAmountLackToWithDraw(transferAmount)) {
			chargeService.autoChargeByUnit(accountId, transferAmount);
		}

		// 2. 잔액이 여유로워졌다면, 내 계좌의 잔액을 차감시키고 친구의 메인 계좌로 송금한다.
		minusMyAccount(accountId, transferAmount);
		plusTargetAccount(transferAccountNumber, transferAmount);

		long resultAmount = accountRepository.findAmount(accountId);
		return new TransferAccountDto.Res(resultAmount);
	}

	private void minusMyAccount(long accountId, long transferAmount) {
		int effectedRowCnt = accountRepository.withdraw(accountId, transferAmount);
		if (effectedRowCnt == 0) {  // 실패했다면 우선 예외를 던지도록 처리
			throw ErrorCode.ACCOUNT_LACK_OF_AMOUNT.businessException();
		}
	}

	private void plusTargetAccount(String accountNumber, long transferAmount) {
		/**
		 * 다른 계좌에 금액을 업데이트 할 때는 어쩔 수 없이 비관적 락이 필요하다. 
           따라서 다른 트랜잭션으로 분리하여 락을 잡고 있는 범위를 줄일 수 있을 듯 하다.
		 */
		Account transferAccount = accountRepository.findByAccountNumberWithWriteLock(accountNumber)
			.orElseThrow(ErrorCode.INVALID_ACCOUNT::businessException);

		accountRepository.deposit(transferAccount.getId(), transferAmount);
	}

테스트 결과

(번외) 트랜잭션 레벨에 따른 locking 전략

사실 InnoDB는 각 트랜잭션 레벨마다, 서로 다른 locking 전략을 사용한다.

SELECT 구문

InnoDB 언두 로그 를 이용한 consistent read 수행하므로, 트랜잭션 레벨 4단계가 아니라면 락을 걸지 않는다.

locking reads (SELECT with FOR UPDATE or FOR SHARE), UPDATE, DELETE 구문

1️⃣ SELECT with FOR UPDATE or FOR SHARE

LEVEL 3 (Repeatable Read)

유니크 인덱스, 세컨더리 인덱스 여부에 따라 사용하는 락이 달라진다.

  • 유니크 인덱스를 사용하거나, 유니크한 조건으로 검색하는 경우 -> 레코드 락을 사용
  • 그 외 세컨더리 인덱스를 사용하거나, 범위 검색하는 경우 -> 넥스트 키 / 갭락을 사용

즉, 팬텀 READ 문제를 해결하기 위해 넥스트 키 락(갭 락 범위 앞 뒤), 갭 락(범위)을 사용하므로 성능이 좋지 않다.

LEVEL 2 (Read Commited)

레벨 3와 다르게 검색 및 인덱스 스캔 에 대해 갭 락이 비활성화되며, 대신 레코드 락이 걸린다. 갭락은 외래 키 제약 조건 확인 및 중복 키 확인에만 사용되므로, 새로운 레코드가 중간에 추가되는 phantom READ 현상이 발생할 수 있다.

2️⃣ UPDATE / DELETE FROM WHERE

LEVEL 3 (Repeatable Read)

범위 검색 시 마주치는 모든 레코드에 베타적 넥스트 키 락을 건다.

LEVEL 2 (Read Commited)

범위 검색 시 업데이트하거나 삭제하는 행에 대해서만 잠금을 유지하고, 일치하지 않는 행에 대한 레코드 잠금은 WHERE 조건을 평가한 후에 바로 해제되어 데드락 발생을 낮춘다.

// LEVEL 3
x-lock(1,2); retain x-lock
x-lock(2,3); update(2,3) to (2,5); retain x-lock
x-lock(3,2); retain x-lock
x-lock(4,3); update(4,3) to (4,5); retain x-lock
x-lock(5,2); retain x-lock
 // LEVEL 2
x-lock(1,2); unlock(1,2)
x-lock(2,3); update(2,3) to (2,5); retain x-lock
x-lock(3,2); unlock(3,2)
x-lock(4,3); update(4,3) to (4,5); retain x-lock
x-lock(5,2); unlock(5,2)

요런 특성을 사용해서, phantom read 를 감수하고 성능적인 측면을 올릴 수도 있을 것 같다.

참고 자료
https://dev.mysql.com/doc/refman/8.0/en/innodb-transaction-isolation-levels.html
https://dev.mysql.com/doc/refman/8.0/en/innodb-locks-set.html
https://hudi.blog/jpa-concurrency-control-optimistic-lock-and-pessimistic-lock/
https://www.linkedin.com/pulse/read-committed-pessimistic-locking-distributed-sql-databases-pachot/

profile
개인용으로 공부하는 공간입니다. 잘못된 부분은 피드백 부탁드립니다!
post-custom-banner

0개의 댓글