동시성으로 인한 문제 해결 경험

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

Intro

문제 상황

동시성 제어를 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은 트랜잭션이 제대로 관리되지 않거나 동시 처리가 제어되지 않는 시스템에서 발생합니다. 그렇기에 Lost Update Problem를 방지하기 위해 잠금, 트랜잭션 등의 동시성 제어 메커니즘을 사용할 필요가 있습니다.

트랜잭션 격리 수준 변경

  • 장점
    • 쉽게 관리가 가능합니다.
  • 단점
    • 데이터베이스마다 제공하는 격리수준 제공이 달라서 관리하기 어렵습니다.

처음에는 MySQL의 격리 레벨을 REPEATABLE_READ 높이면 Lost Update Problem 문제가 해결될 것이라 생각했지만 예상과 다르게 데드락이 발생했습니다.

왜 데드락이 발생했는지

문제 상황을 유추해보건데 하나의 로직이 커밋할 때, Dirty Checking으로 Update가 발생해 Lock을 걸고 있는 상황에서 다른 트랜잭션이 읽기 위해 Lock을 걸게되어 발생하는 상황이라 볼 수 있습니다.

아래 상황을 보면 데드락이 발생할 수 있는 조건까지 완벽하게 갖추고 있습니다.

  • 상호 배제 - MySQL은 인덱스를 활용하는 상황에서는 레코드에 Lock을 겁니다.
  • 순환 대기, 점유 대기 - 계좌 이체 로직은 두 개의 자원에 접근하고 있는 상황입니다.
  • 비선점 - 레코드 Lock이 걸린 경우 Lock이 해제될 때까지 대기합니다.

토이 프로젝트를 진행하던 도중이라 동시성 제어 기술의 장단점을 빠르게 분석빠르게 적용할 필요가 있어서 간단하게 정리했습니다.

Optimistic Lock and Pessimistic Lock

  • 장점
    • 구현 비용이 많이 들지 않습니다.
  • 단점
    • 연산이 진행되는 곳에서 사용한다는 보장을 할 수 없습니다.

Atomic Operation

코드

UPDATE account a SET a.balance = a.balance - :amount WHERE a.accountNumber = :accountNumber
  • 장점
    • 원자적 연산이기 때문에 Lost Update Problem 문제가 발생하지 않습니다.
  • 단점
    • ORM 프레임워크에서 이 방법을 사용하게 되면 unsafe read-modify-write cycles 문제가 빈번하게 발생합니다.

Distributed Lock

  • 장점
    • 시스템이 확장되는 상황에서도 동시성 제어를 보장합니다.
  • 단점
    • 구현이 복잡하거나 외부 인프라 비용이 발생합니다.

Synchronized

  • 장점
    • 구현 비용이 들지 않습니다.
  • 단점
    • 시스템이 확장되는 순간 동기화를 보장할 수 없습니다.

결론 - Synchronized

이유

동시성을 제어해야 할 작업과 트래픽이 많지 않아 어떤 동시성 제어 방법을 사용해도 무리가 발생하지 않는다고 생각했고, 현재 상황에서 쉽게 적용할 수 있었습니다.

단점을 보완하기 위해

트래픽이 많아지면 비용이 높아지는 문제가 있지만, 동시성 제어 기술을 쉽게 교체할 수 있도록 저수준 레벨 코드를 격리해 확장성을 높였습니다.

적용

동시성을 제어하기 위해 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);
}

AS-IS

@Override
public void execute(Runnable runnable) {
	runnable.run();
}

TO-BE

@Override
public void executeWithLock(String lockName, Runnable runnable) {
	log.debug("Start Concurrency Control : {}", lockName);
	synchronized (this) {
		runnable.run();
	}
	log.debug("End Concurrency Control : {}", lockName);
}

마지막으로

정리하자면

  • Lost Update Problem이 발생했고, 서비스 특징과 비용을 기준으로 동시성 제어 방법을 선정했습니다.
  • 트랜잭션의 격리 레벨을 높여 제어하려고 했지만, 위와같은 이유로 데드레깅 발생했습니다.
  • Synchronized를 이용해 비용을 줄여 구현을 했지만, 언제든지 동시성 제어 기술을 변경 가능해야 했습니다.
  • 디자인 패턴을 활용해 동시성 제어 기능을 분리해서 관리할 수 있었습니다.

참고 글

profile
익숙함을 경계하자

0개의 댓글