프로젝트 소개
최근 좋은 기회를 얻어서 작은 스터디를 시작하게 됐다. 스터디 운영하시는 분께서 너무 감사하게도 평소 프로젝트를 할 땐 신경 쓰지 않았던 부분들을 고민할 수 있는 주제로 프로젝트 아이디어를 제공해 주셨다.(동시성 문제, 성능 문제 등)
최근 CS의 중요성을 깨닫고 이걸 프로젝트에 어떻게 적용할지 고민이 많았는데 제공해준 프로젝트를 진행하면서 이런 고민을 해결할 수 있었다.
4개의 주제 중 페이 서비스를 만드는 것이 마음에 들었다. 평소 CRUD만 했던 DB를 Isolation Level과 락을 어떻게 걸지 고민하며 적용해 보는 것이 좋은 경험이 될 것이라고 생각했다. 또한 트랜잭션을 학습할 때 항상 이체를 예시로 들었는데 공부할 땐 예시만 보고 그렇구나~ 했던 것을 어떻게 해결할 것인지 고민해보고 싶었다. 특히 여러 사용자들이 동시에 이체하는 실제 환경을 배경으로 고민하고 싶었다.
페이 서브시에 대해 간단히 요약하자면 네이버 페이, 카카오 페이 같은 서비스를 간단하게 구현해 보는 것이다. 여기선 단순히 A→B로 이체뿐만 아니라 페이 서비스니까 충전 한도, 간단한 적금 기능이 있다. 여러 step으로 나뉘어 있고 각 step마다 요구사항과 작은 힌트들이 있다. 힌트들을 보면 여태까지 무지성으로 개발한 것을 멈추고 실제 환경이라면 어떻게 성능을 효율적으로 가져갈 수 있을지에 대한 힌트처럼 보였다. 사실 이것도 내가 최근에 CS의 중요성을 깨달아서 이쪽으로 치우친 생각일 수도 있다. 그래도 개인적으로 생각하는 효율적인 방향으로 설계해 보려 노력했다.
설계
설계에 굉장히 오랜 시간이 걸렸다. 단순히 DB에 모든 데이터를 저장하고 사용하는 것 보다는, 어떻게 해야 효율적으로 보다 나은 성능을 낼지 고민했기 때문이다.
여러 step이 있지만 당장 앞만 보고 하나의 step만 설계하고 진행하면 금방 할 수 있지만 여러 step들까지 함께 고민하려니 어려웠다. 아마 앞으로 각 step마다의 이슈나 과정을 기록하겠지만 개인적으로 step2가 가장 고민을 많이 했다. 그럼에도 적절한 방법이 생각나지 않았다. 이체 과정에서의 트랜잭션인데 이체하는 두 사람의 레코드가 락이 걸릴 것이기 때문에 이 범위를 최소화하는 것이 목표이다. 당장은 큐 자료구조에 이체 가능한지 먼저 확인하고(돈이 모자랄 수 있으니까) 해당 요청을 큐에 넣고 락을 해제한 후 남은 과정을 진행하면 조금 더 효율적이라고 생각한다.
가령 A->B로 이체를 한다면, A가 이체 가능한지 확인하고(select로 락이 걸림) 이체 가능하다면 기존 금액, 이체할 금액 등을 기록한 데이터를 큐에 넣고 락을 해제한다. 이후 이 큐에서 요청을 빼서 진행하는 다른 스레드가 B의 금액을 바로 update를 해주고, A의 금액을 차감하는 update 쿼리를 날려주는 것이다. 이러면 요청이 몰려와도 B의 update 중에는 A에는 락이 걸리지 않아 다른 작업들을 할 수 있을 것이다. 만약 큐가 없었다면 락을 가져가는 시간이 길어지니 효율이 떨어질 것이라 생각했다.
여하튼 이런 고민들이 각 step마다 필요했고 덕분에 다른 스터디원들보다 조금 늦게 시작하게 되었다 ㅠ
다시 본론으로 돌아와서, step1에서는 회원 가입을 하면 메인 계좌가 생성되고, 적금 계좌를 원하는 대로 만들 수 있어야 한다. 또한 메인 계좌에 충전을 할 수 있어야 하고, 충전 한도도 지켜야 하며, 메인 계좌에서 적금 계좌로 송금을 할 수 있어야 한다. 물론 메인 계좌에 돈이 없으면 적금 계좌로 송금이 안되어야 한다. 힌트로는 Isolation Level과 한도 관리에 대해 고민하라는 것이었다.
당장 배웠던 지식으로는 그냥 Repeatable Read를 사용하고 한도 관리도 메인 계좌 테이블에 한 컬럼으로 작성하면 된다고 생각했다. 하지만 Repeatable Read에서는 여러 요청들이 동시에 들어오면 정합성에 문제가 발생할 수 있었다.
이 문제를 해결하기 위해, 레코드에 Lock을 걸면 좋다고 생각했다. 하지만 Lock을 사용하면 성능상 문제가 있을 것이라 생각해서 조금 더 찾아보았다. 공식 문서와 여러 블로그를 찾아본 결과 Repeatable Read에서 Locking Read를 하면 테이블을 스캔하는 모든 레코드에 락이 걸리는 문제를 발견할 수 있었다. Locking Read가 for update구문을 사용한 것인데 조건절을 진행하며 만나는 모든 레코드에 Lock이 걸리는 것이다. 이건 쿼리문 순서에 대해 생각해 보면 되는데 from을 통해 테이블을 가져오고 where절로 레코드를 걸러낸다. 그럼 from이 먼저 실행되니 테이블 풀 스캔을 할 것이고 이때 읽는 레코드마다 Lock이 걸리는 것이다. 그래서 잘못 쓰면 테이블 전체에 Lock이 걸리지만 사용하는 레코드는 매우 적은 문제가 발생할 수 있다.
다행히 위의 문제도 해결할 수 있었는데 Isolation Level을 Read Committed로 변경하는 것이다. 이 Isolation Level에서 Locking Read도 레코드에 Lock이 걸리지만, 조건 절에 해당하지 않으면 Lock을 해제한다. 그럼 다른 트랜잭션이 Lock이 풀린 레코드는 읽을 수 있게 된다.
나는 Read Committed + Locking Read를 사용하면 데이터 정합성과 최소한의 Lock으로 성능까지 가져갈 수 있다고 생각했고, 이 방법을 사용하기로 했다.
다음은 인당 한도인데 이후에 step을 보면 인당 한도는 매일 00시에 초기화되어야 한다는 부분이 있었다. 매번 초기화를 하면 많은 사용자가 있을 때 update쿼리를 사용해야 하고, 충전 한 번에 해당 레코드에 update가 발생할 것이다. 그리고 페이 시스템 특성상 큰돈을 충전하기 보다 소액으로 충전하는 경우가 많다고 생각하여 우선 DB에는 저장하면 안 되겠다고 생각했다. 특히 MySQL은 쓰기 성능을 버리고 읽기 성능을 높였기 때문에 다른 방법 고민이 필수였다. CS공부를 하지 않았다면 무작정 인메모리 데이터베이스인 Redis를 사용했겠지만, 단일 서버 환경에서 진행하기에 네트워크 비용을 들이기보다는 그냥 메모리에 저장하는 것이 좋다고 생각했다.
하지만 동시성 문제를 해결해야 했는데 여기서 synchronized를 사용하면 DB보다 빠르게 하려고 메모리 사용하는데 여기서도 Lock을 걸어버리면 비효율적이라고 생각했다. 보다 효율적인 방법에 대해 고민했고 CAS와 각 엔트리마다 Lock을 사용하는 ConcurrentHashMap을 사용하기로 했다. HashMap을 그대로 사용했다면 자료구조 전체에 락이 걸리니 이 방법이 오히려 Redis보다 느렸을 것이다. 물론 concurrentHashMap도 문제가 있다. get()메소드에는 Lock이 걸리지 않는다. 즉, 읽기에는 Lock이 걸리지 않는다. 사실 Lock이 걸려도 문제인 게 충전 한도라는 데이터는 읽고, 충전을 했으니 그만큼 한도를 차감하고, 그걸 다시 기록해야 한다. 이 과정 전체에 Lock을 걸어야 정합성이 완전히 보장될 것이다. 하지만 내가 생각하기에는 충전은 결국 사용자 본인이 할 것이고, 시스템 특성상 사용자가 1초에 수십 번씩 요청을 보낼 수는 없을 것이다. 그래서 해당 문제는 고려하지 않기로 해서 ConcurrentHashMap을 사용해도 좋다고 생각했다.
구현
회원 가입은 간단하게 세션을 사용했고 메인 계좌, 적금 계좌 생성도 간단하게 구현했다. 어떤 정보가 들어가야 할 지는 잘 모르겠어서 우선 필수적인 돈이나, 적금은 어떤 적금인지, 이율은 얼마인지 최소한의 정보로 구성했다.
앞서 설계할 땐 생각못했던 부분인데, 적금 상품을 어떻게 관리해야 하는지에 대한 고민이다. 우선 요구사항에는 적금 상품은 2가지로 정기 적금과 자유 적금이 있다. 그래서 그냥 Map 자료구조에 <적금 이름, 이율> 형태로 관리하기로 했다. 어차피 수정하는 기능은 없고 단순히 읽기 기능만 할 것이니까 문제가 없을 것이라 생각했다. 아마 실제 서비스처럼 구현하려면 이것도 DB에서 관리하는 게 좋을 것 같다.
사실 설계할 때 고민을 오래 했기에 각 기능들은 금방 구현했지만, 요구사항에 테스트 코드 작성이 있어서 여기서 시간이 오래 걸렸다. 테스트 코드 작성이 처음이라 학습과 병행하다 보니 시간이 오래 걸리게 되었다.(그리고 약간의 문제도 있었다)
어쨌든 기능을 전부 구현하고 마지막으로 동시성 테스트를 성공하면 끝이었다. 여기서도 고민이었던 것이 다른 테스트는 단위 테스트로 Mock 객체를 활용했는데, DB의 Isolation Level과 Lock을 사용하니 통합 테스트를 해야 한다고 생각했다. 사실 다른 방법으로 가능할까 싶어서 @DataJpaTest를 사용하려 했지만 계속 문제가 발생했는데 얘도 내부는 Mocking되어 있어서 @SpringBootTest를 사용해야 내가 원하는 테스트를 할 수 있다는 것을 알게 되었다.
아래는 내가 동시성 테스트를 할 때 사용한 코드들이다. 충전도 결국 이체와 같다고 생각하여 [내 메인 계좌 -> 적금 계좌]가 동시에 발생한다면 전체 금액은 변동이 없어야 한다. 또한 이때 만약 내 메인 계좌에 충전을 하더라도 그 충전 금액까지 포함해서 전체 금액에 변동이 없어야 한다. 사실 이러는 경우도 많이 없겠지만 만약 적금 계좌로의 이체가 자동 이체라면? 충분히 가능성이 있을 것이다.
MainAccountRepository
package org.c4marathon.assignment.bankaccount.repository;
import java.util.Optional;
import org.c4marathon.assignment.bankaccount.entity.MainAccount;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Lock;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import jakarta.persistence.LockModeType;
public interface MainAccountRepository extends JpaRepository<MainAccount, Long> {
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select ma from MainAccount ma where ma.accountPk = :accountPk")
Optional<MainAccount> findByIdForUpdate(@Param("accountPk") long accountPk);
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select ma from MainAccount ma where ma.accountPk = :accountPk")
Optional<MainAccount> findByPkForUpdate(@Param("accountPk") long accountPk);
}
엇 이거 이제 봤는데 같은 기능을 다른 메소드로 사용하고 있었다. 이따 수정해야겠다.. 이게 아마 처음엔 사용자와 1:1 매핑되어 있다가 이후에 바꾸다 보니 이렇게 됐나 보다.
SavingAccountRepository
package org.c4marathon.assignment.bankaccount.repository;
import java.util.List;
import java.util.Optional;
import org.c4marathon.assignment.bankaccount.entity.SavingAccount;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Lock;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import jakarta.persistence.LockModeType;
public interface SavingAccountRepository extends JpaRepository<SavingAccount, Long> {
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select sa from SavingAccount sa where sa.accountPk = :accountPk")
Optional<SavingAccount> findByPkForUpdate(@Param("accountPk") long accountPk);
@Query("select sa from SavingAccount sa where sa.member.memberPk = :memberPk")
List<SavingAccount> findSavingAccount(long memberPk);
}
각각 메인 계좌, 적금 계좌에 대한 리포지토리로 @Lock을 통해 Locking Read를 사용했다. 이체에 관련해선 000ForUpdate가 붙은 메소드를 사용했다.
MainAccountServiceImpl
package org.c4marathon.assignment.bankaccount.service;
import org.c4marathon.assignment.bankaccount.dto.response.MainAccountResponseDto;
import org.c4marathon.assignment.bankaccount.entity.MainAccount;
import org.c4marathon.assignment.bankaccount.entity.SavingAccount;
import org.c4marathon.assignment.bankaccount.exception.AccountErrorCode;
import org.c4marathon.assignment.bankaccount.limit.ChargeLimitManager;
import org.c4marathon.assignment.bankaccount.repository.MainAccountRepository;
import org.c4marathon.assignment.bankaccount.repository.SavingAccountRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Isolation;
import org.springframework.transaction.annotation.Transactional;
import lombok.RequiredArgsConstructor;
@Service
@RequiredArgsConstructor
public class MainAccountServiceImpl implements MainAccountService {
private final MainAccountRepository mainAccountRepository;
private final ChargeLimitManager chargeLimitManager;
private final SavingAccountRepository savingAccountRepository;
/**
*
* @param mainAccountPk 메인 계좌 pk
* @param money 충전할 금액
* @return 충전 후 계좌 잔고
*
* ChargeLimitManager를 통해 충전이 가능한지 확인하고 money만큼 충전 후 계좌 잔고를 리턴합니다.
*/
@Override
@Transactional(isolation = Isolation.READ_COMMITTED)
public int chargeMoney(long mainAccountPk, int money) {
System.out.println("charge start");
if (!chargeLimitManager.charge(mainAccountPk, money)) {
throw AccountErrorCode.CHARGE_LIMIT_EXCESS.accountException(
"MainAccountServiceImpl에서 메인 계좌 충전 중 일일 충전 한도 초과 예외 발생");
}
MainAccount mainAccount = mainAccountRepository.findByIdForUpdate(mainAccountPk)
.orElseThrow(() -> AccountErrorCode.ACCOUNT_NOT_FOUND.accountException(
"MainAccountServiceImpl에서 메인 계좌 충전 중 ACCOUNT_NOT_FOUND 예외 발생"));
mainAccount.chargeMoney(money);
mainAccountRepository.save(mainAccount);
return mainAccount.getMoney();
}
@Override
@Transactional(isolation = Isolation.READ_COMMITTED)
public void sendToSavingAccount(long mainAccountPk, long savingAccountPk, int money) {
SavingAccount savingAccount = savingAccountRepository.findByPkForUpdate(savingAccountPk)
.orElseThrow(() -> AccountErrorCode.ACCOUNT_NOT_FOUND.accountException(
"MainAccountServiceImpl에서 sendToSavingAccount 메소드 실행 중 [Main Account NOT_FOUND] 예외 발생."));
savingAccount.addMoney(money);
savingAccountRepository.save(savingAccount);
MainAccount mainAccount = mainAccountRepository.findByPkForUpdate(mainAccountPk)
.orElseThrow(() -> AccountErrorCode.ACCOUNT_NOT_FOUND.accountException(
"MainAccountServiceImpl에서 sendToSavingAccount 메소드 실행 중 [Main Account NOT_FOUND] 예외 발생."));
if (!isSendValid(mainAccount.getMoney(), money)) {
throw AccountErrorCode.INVALID_MONEY_SEND.accountException(
"MainAccountServiceImpl에서 sendToSavingAccount 메소드 실행 중 잔고 부족 예외 발생.");
}
mainAccount.minusMoney(money);
mainAccountRepository.save(mainAccount);
}
...
public boolean isSendValid(int myMoney, int sendMoney) {
if (myMoney < sendMoney) {
return false;
}
return true;
}
}
동시성 테스트에 사용할 메소드들이다. 현재 서로 다른 메인계좌 간 이체 기능은 없기에 이를 chargeMoney 메소드로 대신하고 sendToSavingAccount는 메인계좌에서 적금계좌로 이체하는 기능이다.
이걸 동시에 호출했을 때 전체 이동한 돈의 액수가 변함이 없어야 한다. 가령 1000원씩 chargeMoney를 10번 하고, sendToSavingAccount로 1000원씩 10번 이체한다면, 나의 메인 계좌 잔고는 변함이 없어야 할 것이다.
MoneySendConcurrencyTest
@ActiveProfiles("test")
@SpringBootTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
public class MoneySendConcurrencyTest {
@Autowired
MemberRepository memberRepository;
@Autowired
MainAccountRepository mainAccountRepository;
@Autowired
SavingAccountRepository savingAccountRepository;
@Autowired
ChargeLimitManager chargeLimitManager;
@Autowired
MainAccountService mainAccountService;
private Member member;
private MainAccount mainAccount;
private SavingAccount savingAccount;
private long mainAccountPk;
private long savingAccountPk;
@Nested
@DisplayName("메인 계좌에서 적금 계좌 송금시 동시성 테스트")
class SendToSavingAccount {
@BeforeEach
void accountInit() {
createAccount();
}
@Test
@DisplayName("메인 계좌에서 적금 계좌로 송금하는 작업과 내 계좌로 입금되는 작업이 동시에 일어나도 총 돈의 액수는 변함없어야 한다.")
void concurrency_send_to_saving_account_and_my_account() throws InterruptedException {
// Given
MainAccount findMainAccount = mainAccountRepository.findById(mainAccountPk).get();
int startMoney = findMainAccount.getMoney();
int mainPlusMoney = 1000;
int savingPlusMoney = 1000;
final int threadCount = 50;
final ExecutorService executorService = Executors.newFixedThreadPool(25);
final CountDownLatch countDownLatch = new CountDownLatch(threadCount);
AtomicInteger successCount = new AtomicInteger();
AtomicInteger failCount = new AtomicInteger();
// When
for (int i = 0; i < threadCount; i++) {
executorService.submit(() -> {
try {
mainAccountService.sendToSavingAccount(mainAccountPk, savingAccountPk, savingPlusMoney);
mainAccountService.chargeMoney(mainAccountPk, mainPlusMoney);
successCount.getAndIncrement();
} catch (Exception exception) {
failCount.getAndIncrement();
} finally {
countDownLatch.countDown();
}
});
}
countDownLatch.await();
executorService.shutdown();
// then
MainAccount resultMainAccount = mainAccountRepository.findById(mainAccountPk).get();
SavingAccount resultSavingAccount = savingAccountRepository.findById(savingAccountPk).get();
assertEquals(startMoney, resultMainAccount.getMoney()); // 충전과 송금 금액이 같으니 메인 계좌는 처음 조회 했을 때 값과 일치해야 한다.
assertEquals(savingPlusMoney * threadCount,
resultSavingAccount.getSavingMoney()); // 적금 계좌는 5000*10만큼 있어야 한다.
assertEquals(threadCount, successCount.get());
assertEquals(0, failCount.get());
}
}
void createAccount() {
int money = 100000;
mainAccount = MainAccount.builder()
.chargeLimit(LimitConst.CHARGE_LIMIT)
.money(money)
.build();
mainAccountRepository.save(mainAccount);
member = Member.builder()
.memberId("testId")
.password("testPass")
.memberName("testName")
.phoneNumber("testPhone")
.mainAccountPk(mainAccount.getAccountPk())
.build();
memberRepository.save(member);
savingAccount = new SavingAccount();
savingAccount.init("free", 500);
savingAccount.addMember(member);
savingAccountRepository.save(savingAccount);
mainAccountPk = mainAccount.getAccountPk();
savingAccountPk = savingAccount.getAccountPk();
chargeLimitManager.init(mainAccountPk);
}
}
참고로 테스트 DB는 testcontainers를 사용해서 분리하였다.
createAccount 메소드는 처음 계좌 생성에 대한 내용이다. 간단하게 사용자 하나, 그에 따른 메인 계좌 하나, 적금 계좌 하나이고 현재 메인 계좌 잔고는 10만 원인 상태이다.
@Test
@DisplayName("메인 계좌에서 적금 계좌로 송금하는 작업과 내 계좌로 입금되는 작업이 동시에 일어나도 총 돈의 액수는 변함없어야 한다.")
void concurrency_send_to_saving_account_and_my_account() throws InterruptedException {
// Given
MainAccount findMainAccount = mainAccountRepository.findById(mainAccountPk).get();
int startMoney = findMainAccount.getMoney();
int mainPlusMoney = 1000;
int savingPlusMoney = 1000;
final int threadCount = 50;
final ExecutorService executorService = Executors.newFixedThreadPool(25);
final CountDownLatch countDownLatch = new CountDownLatch(threadCount);
AtomicInteger successCount = new AtomicInteger();
AtomicInteger failCount = new AtomicInteger();
// When
for (int i = 0; i < threadCount; i++) {
executorService.submit(() -> {
try {
mainAccountService.sendToSavingAccount(mainAccountPk, savingAccountPk, savingPlusMoney);
mainAccountService.chargeMoney(mainAccountPk, mainPlusMoney);
successCount.getAndIncrement();
} catch (Exception exception) {
failCount.getAndIncrement();
} finally {
countDownLatch.countDown();
}
});
}
countDownLatch.await();
executorService.shutdown();
// then
MainAccount resultMainAccount = mainAccountRepository.findById(mainAccountPk).get();
SavingAccount resultSavingAccount = savingAccountRepository.findById(savingAccountPk).get();
assertEquals(startMoney, resultMainAccount.getMoney()); // 충전과 송금 금액이 같으니 메인 계좌는 처음 조회 했을 때 값과 일치해야 한다.
assertEquals(savingPlusMoney * threadCount,
resultSavingAccount.getSavingMoney()); // 적금 계좌는 5000*10만큼 있어야 한다.
assertEquals(threadCount, successCount.get());
assertEquals(0, failCount.get());
}
ExecutorService는 비동기 모드에서 작업 실행을 단순화하는 JDK API이다. 작업 할당을 위한 스레드 풀과 API를 제공한다. 나는 스레드 수의 절반만큼 스레드 풀을 만들었다(newFixedThreadPool(25))
CountDownLatch는 어떤 스레드가 다른 스레드에서 작업이 완료될 때까지 기다릴 수 있게 해주는 클래스이다. 가령 병렬로 작업을 처리할 때 메인 스레드는 이걸 기다리지 않고 다음 코드를 실행할 텐데 CountDownLatch는 이 스레드들이 완료될 때까지 다음 작업을 하지 않고 기다릴 수 있게 해 준다. 이를 통해 모든 작업이 끝난 후 assertEquals로 정합성 테스트를 하면 된다
CountDownLatch를 생성할 때 스레드 수를 인자로 전달한다. 그리고 countDown()을 호출하면 이 수가 하나씩 감소하는데 await()은 이 값이 0이 될 때까지 기다리는 메소드라서 모든 스레드가 종료되면 값이 0이 되고 다음 코드를 실행하게 될 것이다.
테스트가 성공했다.
그럼 반대로 Locking Read를 하지 않거나, Isolation Level을 변경하면 어떻게 되는지 궁금해졌다. 이걸 변경했을 때 문제가 발생해야 내가 설계한 코드가 잘 작동하는 것이다. 바꿨는데 전부다 테스트를 통과하면 설계와 다르게 동시성 제어를 하고 있을 가능성이 높을 것이다.
우선 Locking Read를 없애 보았다.
// @Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select ma from MainAccount ma where ma.accountPk = :accountPk")
Optional<MainAccount> findByIdForUpdate(@Param("accountPk") long accountPk);
// @Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select sa from SavingAccount sa where sa.accountPk = :accountPk")
Optional<SavingAccount> findByPkForUpdate(@Param("accountPk") long accountPk);
내 테스트에 문제가 없다면, 하나라도 동시에 읽어서 문제가 발생하면 다른 결과가 나와야 한다.
예상대로 문제가 발생했다. 당연하겠지만 메인 계좌나 적금 계좌 둘 중 하나만 Locking Read를 하고, 하나는 하지 않아도 문제가 발생할 것이다. 현재 테스트는 같은 적금 계좌에 동시에 돈을 넣고 있으니까 말이다.
예상대로 테스트가 실패했다. 돈을 빼기 전에 읽어버리니 오히려 돈이 많아져버린 문제가 발생했다.
엇 생각해 보니 Isolation Level은 변경되어도 정합성에는 문제가 없다.. 이건 나중에 nGrinder로 테스트를 하면 그때 시간 측정을 해봐야 차이를 알 수 있을 것 같다.
마지막으로 현재 기능에는 돈이 없으면 적금 계좌로 송금을 하지 않는다. 이것도 동시에 잘 실행되는지 테스트를 해보겠다.
@Test
@DisplayName("메인 계좌 잔고가 부족하면 적금 계좌로 송금에 실패한다.")
void concurrency_send_to_saving_account() throws InterruptedException {
int threadCount = 50;
int sendMoney = 10000;
final ExecutorService executorService = Executors.newFixedThreadPool(25);
final CountDownLatch countDownLatch = new CountDownLatch(threadCount);
AtomicInteger successCount = new AtomicInteger();
AtomicInteger failCount = new AtomicInteger();
// When
for (int i = 0; i < threadCount; i++) {
executorService.submit(() -> {
try {
mainAccountService.sendToSavingAccount(mainAccountPk, savingAccountPk, sendMoney);
successCount.getAndIncrement();
} catch (Exception exception) {
failCount.getAndIncrement();
} finally {
countDownLatch.countDown();
}
});
}
countDownLatch.await();
executorService.shutdown();
// Then
assertEquals(10, successCount.get());
assertEquals(40, failCount.get());
}
10만 원이 계좌에 있으니 50번씩 10000원을 동시에 보내면 10번만 성공하고 나머지 40번은 실패할 것이다.
예상대로 테스트가 성공했다.
이런 고민과 생각은 처음이라 잘하고 있는지 모르겠지만 좋은 시도이고 경험이라고 생각한다. 특히 단순히 외우기만 했던 CS 지식이었던 DB 트랜잭션, Isolation Level, 동시성 문제들을 직접 고민하고 사용해 보는 것이 도움이 많이 된다고 생각한다.