From d3ac619067f114a82d2c215ab479dd9b4eeb7f38 Mon Sep 17 00:00:00 2001 From: kswdot Date: Sat, 6 Dec 2025 11:25:02 +0900 Subject: [PATCH 01/11] test/#redis --- build.gradle | 1 + .../global/config/RedisConfig.java | 30 +++++++++++++++++++ src/main/resources/application.yml | 5 ++++ .../core_banking/domain/redis/RedisTest.java | 20 +++++++++++++ 4 files changed, 56 insertions(+) create mode 100644 src/main/java/org/creditto/core_banking/global/config/RedisConfig.java create mode 100644 src/test/java/org/creditto/core_banking/domain/redis/RedisTest.java diff --git a/build.gradle b/build.gradle index 28f83ab..3f3b3dd 100644 --- a/build.gradle +++ b/build.gradle @@ -35,6 +35,7 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'org.springframework.security:spring-security-crypto' implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.boot:spring-boot-starter-data-redis' implementation 'io.micrometer:micrometer-tracing-bridge-brave' implementation 'io.zipkin.reporter2:zipkin-reporter-brave' implementation 'me.paulschwarz:spring-dotenv:4.0.0' diff --git a/src/main/java/org/creditto/core_banking/global/config/RedisConfig.java b/src/main/java/org/creditto/core_banking/global/config/RedisConfig.java new file mode 100644 index 0000000..81f6669 --- /dev/null +++ b/src/main/java/org/creditto/core_banking/global/config/RedisConfig.java @@ -0,0 +1,30 @@ +package org.creditto.core_banking.global.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer; +import org.springframework.data.redis.serializer.StringRedisSerializer; + +@Configuration +public class RedisConfig { + + @Bean + public RedisTemplate redisTemplate(RedisConnectionFactory connectionFactory) { + RedisTemplate template = new RedisTemplate<>(); + template.setConnectionFactory(connectionFactory); + + // key:value + template.setKeySerializer(new StringRedisSerializer()); + template.setValueSerializer(new GenericJackson2JsonRedisSerializer()); + + // hash key:value + template.setHashKeySerializer(new StringRedisSerializer()); + template.setHashValueSerializer((new GenericJackson2JsonRedisSerializer())); + + template.afterPropertiesSet(); + + return template; + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 17757ec..3bca718 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -6,6 +6,11 @@ spring: username: ${DB_USERNAME} password: ${DB_PASSWORD} driver-class-name: com.mysql.cj.jdbc.Driver + data: + redis: + host: localhost + port: 6379 + timeout: scheduler: remittance: diff --git a/src/test/java/org/creditto/core_banking/domain/redis/RedisTest.java b/src/test/java/org/creditto/core_banking/domain/redis/RedisTest.java new file mode 100644 index 0000000..f637ee1 --- /dev/null +++ b/src/test/java/org/creditto/core_banking/domain/redis/RedisTest.java @@ -0,0 +1,20 @@ +package org.creditto.core_banking.domain.redis; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.data.redis.core.StringRedisTemplate; + +@SpringBootTest +public class RedisTest { + + @Autowired + private StringRedisTemplate redisTemplate; + + @Test + void testRedisConnection() { + redisTemplate.opsForValue().set("test-key", "redis"); + String value = redisTemplate.opsForValue().get("test-key"); + System.out.println("Redis Value: " + value); + } +} From 6591dc4b354bd0a24ac34a163717d1879cdecc5c Mon Sep 17 00:00:00 2001 From: kswdot Date: Sat, 6 Dec 2025 11:34:36 +0900 Subject: [PATCH 02/11] test/#redis --- .../core_banking/global/config/RedisConfig.java | 2 -- src/main/resources/application.yml | 2 +- .../core_banking/domain/redis/RedisTest.java | 12 ++++++++---- 3 files changed, 9 insertions(+), 7 deletions(-) diff --git a/src/main/java/org/creditto/core_banking/global/config/RedisConfig.java b/src/main/java/org/creditto/core_banking/global/config/RedisConfig.java index 81f6669..7e7b0e5 100644 --- a/src/main/java/org/creditto/core_banking/global/config/RedisConfig.java +++ b/src/main/java/org/creditto/core_banking/global/config/RedisConfig.java @@ -23,8 +23,6 @@ public RedisTemplate redisTemplate(RedisConnectionFactory connec template.setHashKeySerializer(new StringRedisSerializer()); template.setHashValueSerializer((new GenericJackson2JsonRedisSerializer())); - template.afterPropertiesSet(); - return template; } } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 3bca718..9630eeb 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -10,7 +10,7 @@ spring: redis: host: localhost port: 6379 - timeout: + timeout: 2000 scheduler: remittance: diff --git a/src/test/java/org/creditto/core_banking/domain/redis/RedisTest.java b/src/test/java/org/creditto/core_banking/domain/redis/RedisTest.java index f637ee1..3f1e3fb 100644 --- a/src/test/java/org/creditto/core_banking/domain/redis/RedisTest.java +++ b/src/test/java/org/creditto/core_banking/domain/redis/RedisTest.java @@ -1,20 +1,24 @@ package org.creditto.core_banking.domain.redis; +import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.core.RedisTemplate; +@Disabled @SpringBootTest public class RedisTest { @Autowired - private StringRedisTemplate redisTemplate; + private RedisTemplate redisTemplate; @Test void testRedisConnection() { - redisTemplate.opsForValue().set("test-key", "redis"); - String value = redisTemplate.opsForValue().get("test-key"); + String key = "test-key"; + String expectedValue = "redis"; + redisTemplate.opsForValue().set(key, expectedValue); + Object value = redisTemplate.opsForValue().get(key); System.out.println("Redis Value: " + value); } } From efcc8e2cac43d2e38c745813f7f9b56d1f05f89d Mon Sep 17 00:00:00 2001 From: Jeyong Date: Sun, 7 Dec 2025 01:25:48 +0900 Subject: [PATCH 03/11] =?UTF-8?q?build:=20redisson=20=EC=9D=98=EC=A1=B4?= =?UTF-8?q?=EC=84=B1=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 1 + 1 file changed, 1 insertion(+) diff --git a/build.gradle b/build.gradle index 3f3b3dd..562a365 100644 --- a/build.gradle +++ b/build.gradle @@ -36,6 +36,7 @@ dependencies { implementation 'org.springframework.security:spring-security-crypto' implementation 'org.springframework.boot:spring-boot-starter-web' implementation 'org.springframework.boot:spring-boot-starter-data-redis' + implementation 'org.redisson:redisson-spring-boot-starter:3.45.1' implementation 'io.micrometer:micrometer-tracing-bridge-brave' implementation 'io.zipkin.reporter2:zipkin-reporter-brave' implementation 'me.paulschwarz:spring-dotenv:4.0.0' From 0791d6a4ca51ec93d83b85797a24a7dcae7d3ed2 Mon Sep 17 00:00:00 2001 From: Jeyong Date: Sun, 7 Dec 2025 01:26:37 +0900 Subject: [PATCH 04/11] =?UTF-8?q?feat:=20Redisson=EC=9D=84=20=EC=A0=81?= =?UTF-8?q?=EC=9A=A9=ED=95=9C=20=EC=8A=A4=EB=A0=88=EB=93=9C=20=EB=9D=BD=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../account/service/AccountLockService.java | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 src/main/java/org/creditto/core_banking/domain/account/service/AccountLockService.java diff --git a/src/main/java/org/creditto/core_banking/domain/account/service/AccountLockService.java b/src/main/java/org/creditto/core_banking/domain/account/service/AccountLockService.java new file mode 100644 index 0000000..520547b --- /dev/null +++ b/src/main/java/org/creditto/core_banking/domain/account/service/AccountLockService.java @@ -0,0 +1,50 @@ +package org.creditto.core_banking.domain.account.service; + +import java.util.concurrent.TimeUnit; +import lombok.RequiredArgsConstructor; +import org.creditto.core_banking.global.response.error.ErrorBaseCode; +import org.creditto.core_banking.global.response.exception.CustomBaseException; +import org.redisson.api.RLock; +import org.redisson.api.RedissonClient; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class AccountLockService { + + private static final String ACCOUNT_LOCK_PREFIX = "lock:account:"; + private static final long LOCK_WAIT_MILLIS = 3000L; + private static final long LOCK_LEASE_MILLIS = 15000L; + + private final RedissonClient redissonClient; + + public T executeWithLock(Long accountId, LockCallback callback) { + RLock lock = redissonClient.getLock(ACCOUNT_LOCK_PREFIX + accountId); + try { + boolean acquired = lock.tryLock(LOCK_WAIT_MILLIS, LOCK_LEASE_MILLIS, TimeUnit.MILLISECONDS); + if (!acquired) { + throw new CustomBaseException(ErrorBaseCode.ACCOUNT_LOCK_TIMEOUT); + } + return callback.invoke(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new CustomBaseException(ErrorBaseCode.ACCOUNT_LOCK_INTERRUPTED); + } finally { + if (lock.isHeldByCurrentThread()) { + lock.unlock(); + } + } + } + + public void executeWithLock(Long accountId, Runnable runnable) { + executeWithLock(accountId, () -> { + runnable.run(); + return null; + }); + } + + @FunctionalInterface + public interface LockCallback { + T invoke(); + } +} From 8f802e9a94f5e95df954ca21fce00343448e17a9 Mon Sep 17 00:00:00 2001 From: Jeyong Date: Sun, 7 Dec 2025 01:27:09 +0900 Subject: [PATCH 05/11] =?UTF-8?q?refactor:=20=EA=B8=B0=EC=A1=B4=20?= =?UTF-8?q?=EC=B6=9C=EA=B8=88=20=EB=B0=8F=20=EC=86=A1=EA=B8=88=20=EA=B4=80?= =?UTF-8?q?=EB=A0=A8=20=EB=A9=94=EC=84=9C=EB=93=9C=EC=97=90=20Redisson=20?= =?UTF-8?q?=EB=B0=8F=20=EB=B9=84=EA=B4=80=EC=A0=81=20=EB=9D=BD=20=EC=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../account/repository/AccountRepository.java | 16 +++- .../account/service/AccountService.java | 14 ++-- .../service/RemittanceProcessorService.java | 79 ++++++++++--------- .../global/response/error/ErrorBaseCode.java | 2 + 4 files changed, 60 insertions(+), 51 deletions(-) diff --git a/src/main/java/org/creditto/core_banking/domain/account/repository/AccountRepository.java b/src/main/java/org/creditto/core_banking/domain/account/repository/AccountRepository.java index 8c19dba..64dc0cd 100644 --- a/src/main/java/org/creditto/core_banking/domain/account/repository/AccountRepository.java +++ b/src/main/java/org/creditto/core_banking/domain/account/repository/AccountRepository.java @@ -1,16 +1,19 @@ package org.creditto.core_banking.domain.account.repository; +import jakarta.persistence.LockModeType; +import jakarta.persistence.QueryHint; +import java.math.BigDecimal; +import java.util.List; +import java.util.Optional; import org.creditto.core_banking.domain.account.dto.AccountSummaryRes; import org.creditto.core_banking.domain.account.entity.Account; 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.jpa.repository.QueryHints; import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; -import java.math.BigDecimal; -import java.util.List; -import java.util.Optional; - @Repository public interface AccountRepository extends JpaRepository { @@ -27,4 +30,9 @@ public interface AccountRepository extends JpaRepository { "COUNT(ac), COALESCE(SUM(ac.balance), 0)) " + "FROM Account ac WHERE ac.userId = :userId") AccountSummaryRes findAccountSummaryByUserId(@Param("userId") Long userId); + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @QueryHints(@QueryHint(name = "jakarta.persistence.lock.timeout", value = "3000")) + @Query("SELECT a FROM Account a WHERE a.id = :id") + Optional findByIdForUpdate(@Param("id") Long id); } diff --git a/src/main/java/org/creditto/core_banking/domain/account/service/AccountService.java b/src/main/java/org/creditto/core_banking/domain/account/service/AccountService.java index 52e51d9..4e3a0b3 100644 --- a/src/main/java/org/creditto/core_banking/domain/account/service/AccountService.java +++ b/src/main/java/org/creditto/core_banking/domain/account/service/AccountService.java @@ -28,6 +28,7 @@ public class AccountService { private final TransactionStrategyFactory strategyFactory; private final PasswordValidator passwordValidator; private final PasswordEncoder passwordEncoder; + private final AccountLockService accountLockService; /** @@ -82,15 +83,12 @@ public void verifyPassword(Long accountId, String rawPassword) { */ @Transactional public void processTransaction(Long accountId, BigDecimal amount, TxnType txnType, Long typeId) { - // 팩토리에서 거래 유행에 맞는 전략 호출 TransactionStrategy strategy = strategyFactory.getStrategy(txnType); - - // 계좌 정보 조회 - Account account = accountRepository.findById(accountId) - .orElseThrow(() -> new CustomBaseException(ErrorBaseCode.NOT_FOUND_ACCOUNT)); - - // 거래 타입 실행 - strategy.execute(account, amount, typeId); + accountLockService.executeWithLock(accountId, () -> { + Account account = accountRepository.findByIdForUpdate(accountId) + .orElseThrow(() -> new CustomBaseException(ErrorBaseCode.NOT_FOUND_ACCOUNT)); + strategy.execute(account, amount, typeId); + }); } public AccountRes getAccountById(Long id) { diff --git a/src/main/java/org/creditto/core_banking/domain/overseasremittance/service/RemittanceProcessorService.java b/src/main/java/org/creditto/core_banking/domain/overseasremittance/service/RemittanceProcessorService.java index c71072f..ee7b0e3 100644 --- a/src/main/java/org/creditto/core_banking/domain/overseasremittance/service/RemittanceProcessorService.java +++ b/src/main/java/org/creditto/core_banking/domain/overseasremittance/service/RemittanceProcessorService.java @@ -3,6 +3,7 @@ import lombok.RequiredArgsConstructor; import org.creditto.core_banking.domain.account.entity.Account; import org.creditto.core_banking.domain.account.repository.AccountRepository; +import org.creditto.core_banking.domain.account.service.AccountLockService; import org.creditto.core_banking.domain.exchange.dto.ExchangeReq; import org.creditto.core_banking.domain.exchange.dto.ExchangeRes; import org.creditto.core_banking.domain.exchange.entity.Exchange; @@ -50,6 +51,7 @@ public class RemittanceProcessorService { private final ExchangeService exchangeService; private final TransactionService transactionService; private final RemittanceFeeService remittanceFeeService; + private final AccountLockService accountLockService; /** * 전달된 Command를 기반으로 해외송금의 모든 단계를 실행합니다. @@ -67,7 +69,6 @@ public class RemittanceProcessorService { @Transactional public OverseasRemittanceResponseDto execute(final ExecuteRemittanceCommand command) { - // 관련 엔티티 조회 Account account = accountRepository.findById(command.accountId()) .orElseThrow(() -> new CustomBaseException(NOT_FOUND_ACCOUNT)); @@ -98,44 +99,44 @@ public OverseasRemittanceResponseDto execute(final ExecuteRemittanceCommand comm // 총 차감될 금액 계산 (실제 보낼 금액 + 총 수수료) BigDecimal totalDeduction = actualSendAmount.add(totalFee); - // 잔액 확인 - if (!account.checkSufficientBalance(totalDeduction)) { - // 실패 트랜잭션 기록 - transactionService.saveTransaction(account, actualSendAmount, TxnType.WITHDRAWAL, null, TxnResult.FAILURE); - throw new CustomBaseException(ErrorBaseCode.INSUFFICIENT_FUNDS); - } - // 3. DTO에 담겨올 ID로 Exchange 엔티티 다시 조회 - Long exchangeId = exchangeRes.exchangeId(); - - Exchange savedExchange = exchangeRepository.findById(exchangeId) - .orElseThrow(() -> new CustomBaseException(NOT_FOUND_EXCHANGE_RECORD)); - - // 5. 송금 이력 생성 - OverseasRemittance overseasRemittance = OverseasRemittance.of( - recipient, - account, - regularRemittance, - savedExchange, - feeRecord, - actualSendAmount, - command - ); - remittanceRepository.save(overseasRemittance); - - // 수수료 차감 및 거래 내역 생성 - if (totalFee.compareTo(BigDecimal.ZERO) > 0) { - account.withdraw(totalFee); - transactionService.saveTransaction(account, totalFee, TxnType.FEE, overseasRemittance.getRemittanceId(), TxnResult.SUCCESS); - } - - // 송금액 차감 및 거래 내역 생성 - account.withdraw(actualSendAmount); - transactionService.saveTransaction(account, actualSendAmount, TxnType.WITHDRAWAL, overseasRemittance.getRemittanceId(), TxnResult.SUCCESS); - - accountRepository.save(account); - - return OverseasRemittanceResponseDto.from(overseasRemittance); + Long exchangeId = exchangeRes.exchangeId(); + + Exchange savedExchange = exchangeRepository.findById(exchangeId) + .orElseThrow(() -> new CustomBaseException(NOT_FOUND_EXCHANGE_RECORD)); + + return accountLockService.executeWithLock(command.accountId(), () -> { + Account lockedAccount = accountRepository.findByIdForUpdate(command.accountId()) + .orElseThrow(() -> new CustomBaseException(NOT_FOUND_ACCOUNT)); + + if (!lockedAccount.checkSufficientBalance(totalDeduction)) { + transactionService.saveTransaction(lockedAccount, actualSendAmount, TxnType.WITHDRAWAL, null, TxnResult.FAILURE); + throw new CustomBaseException(ErrorBaseCode.INSUFFICIENT_FUNDS); + } + + OverseasRemittance overseasRemittance = OverseasRemittance.of( + recipient, + lockedAccount, + regularRemittance, + savedExchange, + feeRecord, + actualSendAmount, + command + ); + remittanceRepository.save(overseasRemittance); + + if (totalFee.compareTo(BigDecimal.ZERO) > 0) { + lockedAccount.withdraw(totalFee); + transactionService.saveTransaction(lockedAccount, totalFee, TxnType.FEE, overseasRemittance.getRemittanceId(), TxnResult.SUCCESS); + } + + lockedAccount.withdraw(actualSendAmount); + transactionService.saveTransaction(lockedAccount, actualSendAmount, TxnType.WITHDRAWAL, overseasRemittance.getRemittanceId(), TxnResult.SUCCESS); + + accountRepository.save(lockedAccount); + + return OverseasRemittanceResponseDto.from(overseasRemittance); + }); } private ExchangeRes exchange(Long userId, ExecuteRemittanceCommand command) { @@ -152,4 +153,4 @@ private FeeRecord calculateFee(ExchangeRes exchangeRes, CurrencyCode currencyCod ); return remittanceFeeService.calculateAndSaveFee(feeReq); } -} \ No newline at end of file +} diff --git a/src/main/java/org/creditto/core_banking/global/response/error/ErrorBaseCode.java b/src/main/java/org/creditto/core_banking/global/response/error/ErrorBaseCode.java index 1feb20c..3f2c2f9 100644 --- a/src/main/java/org/creditto/core_banking/global/response/error/ErrorBaseCode.java +++ b/src/main/java/org/creditto/core_banking/global/response/error/ErrorBaseCode.java @@ -71,6 +71,8 @@ public enum ErrorBaseCode implements ErrorCode { CONFLICT(HttpStatus.CONFLICT, 409, "이미 존재하는 리소스입니다."), DB_CONFLICT(HttpStatus.CONFLICT, 409, "DB 관련 충돌 문제입니다."), DUPLICATE_REMITTANCE(HttpStatus.CONFLICT, 40911, "동일한 내용의 자동이체가 이미 등록되어 있습니다."), + ACCOUNT_LOCK_TIMEOUT(HttpStatus.LOCKED, 42301, "계좌 처리 대기 시간이 초과되었습니다."), + ACCOUNT_LOCK_INTERRUPTED(HttpStatus.LOCKED, 42302, "계좌 잠금 처리 중 오류가 발생했습니다."), /** * 500 INTERNAL SERVER ERROR - 서버 오류 From 18ba70f296dd97a45535c6b2f17b97feba3d3d5e Mon Sep 17 00:00:00 2001 From: Jeyong Date: Sun, 7 Dec 2025 01:27:29 +0900 Subject: [PATCH 06/11] =?UTF-8?q?test:=20Redisson=20=EB=B0=8F=20=EB=B9=84?= =?UTF-8?q?=EA=B4=80=EC=A0=81=20=EB=9D=BD=20=EA=B4=80=EB=A0=A8=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../AccountConcurrencyIntegrationTest.java | 142 ++++++++++++++++++ .../controller/ExchangeControllerTest.java | 28 +++- 2 files changed, 165 insertions(+), 5 deletions(-) create mode 100644 src/test/java/org/creditto/core_banking/domain/account/AccountConcurrencyIntegrationTest.java diff --git a/src/test/java/org/creditto/core_banking/domain/account/AccountConcurrencyIntegrationTest.java b/src/test/java/org/creditto/core_banking/domain/account/AccountConcurrencyIntegrationTest.java new file mode 100644 index 0000000..5baee8a --- /dev/null +++ b/src/test/java/org/creditto/core_banking/domain/account/AccountConcurrencyIntegrationTest.java @@ -0,0 +1,142 @@ +package org.creditto.core_banking.domain.account; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.when; + +import java.math.BigDecimal; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import org.creditto.core_banking.domain.account.entity.Account; +import org.creditto.core_banking.domain.account.entity.AccountState; +import org.creditto.core_banking.domain.account.entity.AccountType; +import org.creditto.core_banking.domain.account.repository.AccountRepository; +import org.creditto.core_banking.domain.account.service.AccountService; +import org.creditto.core_banking.domain.transaction.entity.TxnType; +import org.creditto.core_banking.domain.transaction.repository.TransactionRepository; +import org.creditto.core_banking.global.response.error.ErrorBaseCode; +import org.creditto.core_banking.global.response.exception.CustomBaseException; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.redisson.api.RLock; +import org.redisson.api.RedissonClient; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Primary; + +@SpringBootTest +class AccountConcurrencyIntegrationTest { + + @Autowired + private AccountRepository accountRepository; + + @Autowired + private AccountService accountService; + + @Autowired + private RedissonClient redissonClient; + + @Autowired + private TransactionRepository transactionRepository; + + private RLock mockLock; + + @BeforeEach + void setUpLock() throws InterruptedException { + mockLock = org.mockito.Mockito.mock(RLock.class); + when(redissonClient.getLock(anyString())).thenReturn(mockLock); + when(mockLock.tryLock(anyLong(), anyLong(), any(TimeUnit.class))).thenReturn(true); + when(mockLock.isHeldByCurrentThread()).thenReturn(true); + doNothing().when(mockLock).unlock(); + } + + @AfterEach + void tearDown() { + transactionRepository.deleteAll(); + accountRepository.deleteAll(); + } + + @Test + @DisplayName("동시 출금 시에도 잔액 정합성이 보장된다") + void concurrentWithdrawalMaintainsConsistency() throws InterruptedException { + Account prepared = Account.of( + null, + "encoded-password", + "테스트 계좌", + new BigDecimal("100000"), + AccountType.DEPOSIT, + AccountState.ACTIVE, + 1L + ); + + Account saved = accountRepository.save(prepared); + + int threadCount = 2; + ExecutorService executorService = Executors.newFixedThreadPool(threadCount); + CountDownLatch readyLatch = new CountDownLatch(threadCount); + CountDownLatch startLatch = new CountDownLatch(1); + CountDownLatch doneLatch = new CountDownLatch(threadCount); + + AtomicInteger successCount = new AtomicInteger(); + AtomicInteger insufficientCount = new AtomicInteger(); + AtomicInteger failedCount = new AtomicInteger(); + + Runnable withdrawTask = () -> { + try { + readyLatch.countDown(); + startLatch.await(); + accountService.processTransaction(saved.getId(), new BigDecimal("70000"), TxnType.WITHDRAWAL, null); + successCount.incrementAndGet(); + } catch (CustomBaseException e) { + if (e.getErrorCode() == ErrorBaseCode.INSUFFICIENT_FUNDS) { + insufficientCount.incrementAndGet(); + } else if (e.getErrorCode() == ErrorBaseCode.TRANSACTION_FAILED) { + failedCount.incrementAndGet(); + } else { + throw e; + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } finally { + doneLatch.countDown(); + } + }; + + for (int i = 0; i < threadCount; i++) { + executorService.submit(withdrawTask); + } + + readyLatch.await(3, TimeUnit.SECONDS); + startLatch.countDown(); + doneLatch.await(5, TimeUnit.SECONDS); + executorService.shutdownNow(); + + Account reloaded = accountRepository.findById(saved.getId()) + .orElseThrow(); + + assertThat(successCount.get()).isEqualTo(1); + assertThat(failedCount.get() + insufficientCount.get()).isEqualTo(1); + assertThat(reloaded.getBalance()).isEqualByComparingTo(new BigDecimal("30000")); + } + + @TestConfiguration + static class MockRedissonConfiguration { + + @Bean + @Primary + public RedissonClient mockRedissonClient() { + return Mockito.mock(RedissonClient.class); + } + } +} diff --git a/src/test/java/org/creditto/core_banking/domain/exchange/controller/ExchangeControllerTest.java b/src/test/java/org/creditto/core_banking/domain/exchange/controller/ExchangeControllerTest.java index 3b86bcf..9633f06 100644 --- a/src/test/java/org/creditto/core_banking/domain/exchange/controller/ExchangeControllerTest.java +++ b/src/test/java/org/creditto/core_banking/domain/exchange/controller/ExchangeControllerTest.java @@ -1,19 +1,21 @@ package org.creditto.core_banking.domain.exchange.controller; +import java.math.BigDecimal; import org.creditto.core_banking.domain.creditscore.service.CreditScoreService; import org.creditto.core_banking.domain.exchange.dto.PreferentialRateRes; import org.creditto.core_banking.domain.exchange.service.ExchangeService; import org.creditto.core_banking.global.common.CurrencyCode; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import org.mockito.Mockito; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Primary; import org.springframework.test.web.servlet.MockMvc; -import java.math.BigDecimal; - import static org.mockito.BDDMockito.given; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; @@ -26,10 +28,10 @@ class ExchangeControllerTest { @Autowired private MockMvc mockMvc; - @MockBean + @Autowired private ExchangeService exchangeService; - @MockBean + @Autowired private CreditScoreService creditScoreService; @Test @@ -51,4 +53,20 @@ void getPreferentialRate_Success() throws Exception { .andExpect(jsonPath("$.data.preferentialRate").value(preferentialRate)) .andExpect(jsonPath("$.data.appliedRate").value(appliedRate.doubleValue())); } + + @TestConfiguration + static class MockConfig { + + @Bean + @Primary + ExchangeService exchangeService() { + return Mockito.mock(ExchangeService.class); + } + + @Bean + @Primary + CreditScoreService creditScoreService() { + return Mockito.mock(CreditScoreService.class); + } + } } From 3b1830c7de6a3b99351af2c709857e76d216d080 Mon Sep 17 00:00:00 2001 From: Jeyong Date: Mon, 8 Dec 2025 13:35:41 +0900 Subject: [PATCH 07/11] =?UTF-8?q?refactor:=20Redis=20=EC=9E=A5=EC=95=A0?= =?UTF-8?q?=EC=8B=9C=20fallback=20=EC=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/AccountLockProperties.java | 29 +++++++++ .../account/service/AccountLockService.java | 65 +++++++++++++++---- .../account/service/AccountService.java | 4 ++ 3 files changed, 84 insertions(+), 14 deletions(-) create mode 100644 src/main/java/org/creditto/core_banking/domain/account/service/AccountLockProperties.java diff --git a/src/main/java/org/creditto/core_banking/domain/account/service/AccountLockProperties.java b/src/main/java/org/creditto/core_banking/domain/account/service/AccountLockProperties.java new file mode 100644 index 0000000..783ff4c --- /dev/null +++ b/src/main/java/org/creditto/core_banking/domain/account/service/AccountLockProperties.java @@ -0,0 +1,29 @@ +package org.creditto.core_banking.domain.account.service; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "core.account-lock") +public class AccountLockProperties { + + private final long waitMillis; + private final long leaseMillis; + private final String accountLockPrefix; + + public AccountLockProperties(long waitMillis, long leaseMillis, String accountLockPrefix) { + this.waitMillis = waitMillis; + this.leaseMillis = leaseMillis; + this.accountLockPrefix = accountLockPrefix; + } + + public long getWaitMillis() { + return waitMillis; + } + + public long getLeaseMillis() { + return leaseMillis; + } + + public String getAccountLockPrefix() { + return accountLockPrefix; + } +} diff --git a/src/main/java/org/creditto/core_banking/domain/account/service/AccountLockService.java b/src/main/java/org/creditto/core_banking/domain/account/service/AccountLockService.java index 520547b..5c55731 100644 --- a/src/main/java/org/creditto/core_banking/domain/account/service/AccountLockService.java +++ b/src/main/java/org/creditto/core_banking/domain/account/service/AccountLockService.java @@ -2,49 +2,86 @@ import java.util.concurrent.TimeUnit; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.creditto.core_banking.global.response.error.ErrorBaseCode; import org.creditto.core_banking.global.response.exception.CustomBaseException; import org.redisson.api.RLock; import org.redisson.api.RedissonClient; import org.springframework.stereotype.Service; +@Slf4j @Service @RequiredArgsConstructor public class AccountLockService { - private static final String ACCOUNT_LOCK_PREFIX = "lock:account:"; - private static final long LOCK_WAIT_MILLIS = 3000L; - private static final long LOCK_LEASE_MILLIS = 15000L; - private final RedissonClient redissonClient; + private final AccountLockProperties accountLockProperties; public T executeWithLock(Long accountId, LockCallback callback) { - RLock lock = redissonClient.getLock(ACCOUNT_LOCK_PREFIX + accountId); + RLock lock = redissonClient.getLock(accountLockProperties.getAccountLockPrefix() + accountId); + boolean redisLockAcquired = false; + boolean redisAvailable = true; + try { - boolean acquired = lock.tryLock(LOCK_WAIT_MILLIS, LOCK_LEASE_MILLIS, TimeUnit.MILLISECONDS); - if (!acquired) { + redisLockAcquired = lock.tryLock( + accountLockProperties.getWaitMillis(), + accountLockProperties.getLeaseMillis(), + TimeUnit.MILLISECONDS + ); + } catch (Exception redisException) { + redisAvailable = false; + log.warn("Redis lock 불가, fallback 전략을 사용합니다. accountId={}, reason={}", accountId, redisException.getMessage()); + } + + try { + if (!redisAvailable) { + // Redis 사용 불가 & DB 분산락만 적용 + return callback.invokeFallback(); + } + + if (!redisLockAcquired) { + // Lock 획득 실패 throw new CustomBaseException(ErrorBaseCode.ACCOUNT_LOCK_TIMEOUT); } + return callback.invoke(); + } catch (InterruptedException e) { + // Interrupt 관련 에러 Thread.currentThread().interrupt(); throw new CustomBaseException(ErrorBaseCode.ACCOUNT_LOCK_INTERRUPTED); } finally { - if (lock.isHeldByCurrentThread()) { - lock.unlock(); + if (redisLockAcquired && lock.isHeldByCurrentThread()) { + try { + lock.unlock(); + } catch (RuntimeException unlockException) { + log.warn("Redis lock 해제 실패. accountId={}, reason={}", accountId, unlockException.getMessage()); + } } } } public void executeWithLock(Long accountId, Runnable runnable) { - executeWithLock(accountId, () -> { - runnable.run(); - return null; + executeWithLock(accountId, new LockCallback() { + @Override + public Void invoke() { + runnable.run(); + return null; + } + + @Override + public Void invokeFallback() { + runnable.run(); + return null; + } }); } - @FunctionalInterface public interface LockCallback { - T invoke(); + T invoke() throws InterruptedException; + + default T invokeFallback() throws InterruptedException { + return invoke(); + } } } diff --git a/src/main/java/org/creditto/core_banking/domain/account/service/AccountService.java b/src/main/java/org/creditto/core_banking/domain/account/service/AccountService.java index 4e3a0b3..0733c36 100644 --- a/src/main/java/org/creditto/core_banking/domain/account/service/AccountService.java +++ b/src/main/java/org/creditto/core_banking/domain/account/service/AccountService.java @@ -83,8 +83,12 @@ public void verifyPassword(Long accountId, String rawPassword) { */ @Transactional public void processTransaction(Long accountId, BigDecimal amount, TxnType txnType, Long typeId) { + // 전략 조회 TransactionStrategy strategy = strategyFactory.getStrategy(txnType); + + // 분산 락 적용 accountLockService.executeWithLock(accountId, () -> { + // 비관적 락 적용 Account account = accountRepository.findByIdForUpdate(accountId) .orElseThrow(() -> new CustomBaseException(ErrorBaseCode.NOT_FOUND_ACCOUNT)); strategy.execute(account, amount, typeId); From a3bcd4e9e47c4868d7027d5ac062ecc858d02b24 Mon Sep 17 00:00:00 2001 From: Jeyong Date: Mon, 8 Dec 2025 13:36:12 +0900 Subject: [PATCH 08/11] =?UTF-8?q?refactor:=20@ConfigurationPropertiesScan?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/org/creditto/core_banking/CoreBankingApplication.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/main/java/org/creditto/core_banking/CoreBankingApplication.java b/src/main/java/org/creditto/core_banking/CoreBankingApplication.java index 4f4ba0f..8f475ef 100644 --- a/src/main/java/org/creditto/core_banking/CoreBankingApplication.java +++ b/src/main/java/org/creditto/core_banking/CoreBankingApplication.java @@ -2,10 +2,12 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.context.properties.ConfigurationPropertiesScan; import org.springframework.data.jpa.repository.config.EnableJpaAuditing; @EnableJpaAuditing @SpringBootApplication +@ConfigurationPropertiesScan public class CoreBankingApplication { public static void main(String[] args) { From cfa2e965e806e899a69565ad1e05a25527d50d15 Mon Sep 17 00:00:00 2001 From: Jeyong Date: Mon, 8 Dec 2025 13:36:35 +0900 Subject: [PATCH 09/11] =?UTF-8?q?docs:=20redis=20=EC=84=A4=EC=A0=95=20?= =?UTF-8?q?=EB=B3=80=EC=88=98=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/application.yml | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 9630eeb..7579322 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -8,8 +8,8 @@ spring: driver-class-name: com.mysql.cj.jdbc.Driver data: redis: - host: localhost - port: 6379 + host: ${REDIS_HOST} + port: ${REDIS_PORT} timeout: 2000 scheduler: @@ -39,3 +39,9 @@ server: logging: level: org.hibernate.sql: DEBUG + +core: + account-lock: + account-lock-prefix: ${ACCOUNT_LOCK_PREFIX} + wait-millis: ${ACCOUNT_LOCK_WAIT_MILLIS} + lease-millis: ${ACCOUNT_LOCK_LEASE_MILLIS} From e601ae229a7ab47e2038b646cbbdb409bb6dad8f Mon Sep 17 00:00:00 2001 From: Jeyong Date: Mon, 8 Dec 2025 13:37:13 +0900 Subject: [PATCH 10/11] =?UTF-8?q?test:=20=EB=B6=84=EC=82=B0=20=EB=9D=BD=20?= =?UTF-8?q?=EB=B0=8F=20=EC=A0=84=EC=97=AD=20=EB=9D=BD=20=EC=A0=81=EC=9A=A9?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EC=9D=B8=ED=95=9C=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=BD=94=EB=93=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/account/AccountServiceTest.java | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/src/test/java/org/creditto/core_banking/domain/account/AccountServiceTest.java b/src/test/java/org/creditto/core_banking/domain/account/AccountServiceTest.java index b873f21..24a5785 100644 --- a/src/test/java/org/creditto/core_banking/domain/account/AccountServiceTest.java +++ b/src/test/java/org/creditto/core_banking/domain/account/AccountServiceTest.java @@ -8,6 +8,7 @@ import org.creditto.core_banking.domain.account.entity.AccountState; import org.creditto.core_banking.domain.account.entity.AccountType; import org.creditto.core_banking.domain.account.repository.AccountRepository; +import org.creditto.core_banking.domain.account.service.AccountLockService; import org.creditto.core_banking.domain.account.service.AccountService; import org.creditto.core_banking.domain.account.service.PasswordValidator; import org.creditto.core_banking.domain.account.service.strategy.TransactionStrategy; @@ -53,6 +54,9 @@ class AccountServiceTest { @Mock private TransactionStrategy mockStrategy; + @Mock + private AccountLockService accountLockService; + @InjectMocks private AccountService accountService; @@ -80,7 +84,7 @@ void createAccount_Success() { // When - AccountRes result = accountService.createAccount(request, userId); + accountService.createAccount(request, userId); // Then verify(passwordValidator).validatePassword(rawPassword); @@ -195,16 +199,22 @@ void processTransaction_Success() { Account mockAccount = Account.of("ACC001", "테스트 계좌", "password", BigDecimal.valueOf(50000), AccountType.DEPOSIT, AccountState.ACTIVE, 1L); - given(accountRepository.findById(accountId)).willReturn(Optional.of(mockAccount)); + given(accountRepository.findByIdForUpdate(accountId)).willReturn(Optional.of(mockAccount)); given(strategyFactory.getStrategy(txnType)).willReturn(mockStrategy); + doAnswer(invocation -> { + Runnable runnable = invocation.getArgument(1); + runnable.run(); + return null; + }).when(accountLockService).executeWithLock(eq(accountId), any(Runnable.class)); // when accountService.processTransaction(accountId, amount, txnType, relatedId); // then - verify(accountRepository).findById(accountId); + verify(accountRepository).findByIdForUpdate(accountId); verify(strategyFactory).getStrategy(txnType); verify(mockStrategy).execute(mockAccount, amount, relatedId); + verify(accountLockService).executeWithLock(eq(accountId), any(Runnable.class)); } @Test From 8dbbbdf0255e8fde6c584ece57957a16b38ea691 Mon Sep 17 00:00:00 2001 From: Jeyong Date: Mon, 8 Dec 2025 20:25:36 +0900 Subject: [PATCH 11/11] =?UTF-8?q?refactor:=20PR=20Review=20=EB=B0=98?= =?UTF-8?q?=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../account/service/AccountLockService.java | 7 +- .../AccountConcurrencyIntegrationTest.java | 90 +++++++++++++++++-- 2 files changed, 91 insertions(+), 6 deletions(-) diff --git a/src/main/java/org/creditto/core_banking/domain/account/service/AccountLockService.java b/src/main/java/org/creditto/core_banking/domain/account/service/AccountLockService.java index 5c55731..d934187 100644 --- a/src/main/java/org/creditto/core_banking/domain/account/service/AccountLockService.java +++ b/src/main/java/org/creditto/core_banking/domain/account/service/AccountLockService.java @@ -1,10 +1,12 @@ package org.creditto.core_banking.domain.account.service; import java.util.concurrent.TimeUnit; + import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.creditto.core_banking.global.response.error.ErrorBaseCode; import org.creditto.core_banking.global.response.exception.CustomBaseException; +import org.redisson.RedissonShutdownException; import org.redisson.api.RLock; import org.redisson.api.RedissonClient; import org.springframework.stereotype.Service; @@ -28,9 +30,12 @@ public T executeWithLock(Long accountId, LockCallback callback) { accountLockProperties.getLeaseMillis(), TimeUnit.MILLISECONDS ); - } catch (Exception redisException) { + } catch (RedissonShutdownException redisException) { redisAvailable = false; log.warn("Redis lock 불가, fallback 전략을 사용합니다. accountId={}, reason={}", accountId, redisException.getMessage()); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new CustomBaseException(ErrorBaseCode.ACCOUNT_LOCK_INTERRUPTED); } try { diff --git a/src/test/java/org/creditto/core_banking/domain/account/AccountConcurrencyIntegrationTest.java b/src/test/java/org/creditto/core_banking/domain/account/AccountConcurrencyIntegrationTest.java index 5baee8a..c84c40f 100644 --- a/src/test/java/org/creditto/core_banking/domain/account/AccountConcurrencyIntegrationTest.java +++ b/src/test/java/org/creditto/core_banking/domain/account/AccountConcurrencyIntegrationTest.java @@ -4,7 +4,6 @@ import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.doNothing; import static org.mockito.Mockito.when; import java.math.BigDecimal; @@ -13,6 +12,7 @@ import java.util.concurrent.Executors; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.locks.ReentrantLock; import org.creditto.core_banking.domain.account.entity.Account; import org.creditto.core_banking.domain.account.entity.AccountState; import org.creditto.core_banking.domain.account.entity.AccountType; @@ -51,14 +51,23 @@ class AccountConcurrencyIntegrationTest { private TransactionRepository transactionRepository; private RLock mockLock; + private ReentrantLock localReentrantLock; @BeforeEach void setUpLock() throws InterruptedException { - mockLock = org.mockito.Mockito.mock(RLock.class); + mockLock = Mockito.mock(RLock.class); + localReentrantLock = new ReentrantLock(); when(redissonClient.getLock(anyString())).thenReturn(mockLock); - when(mockLock.tryLock(anyLong(), anyLong(), any(TimeUnit.class))).thenReturn(true); - when(mockLock.isHeldByCurrentThread()).thenReturn(true); - doNothing().when(mockLock).unlock(); + when(mockLock.tryLock(anyLong(), anyLong(), any(TimeUnit.class))).thenAnswer(invocation -> { + long wait = invocation.getArgument(0); + TimeUnit unit = invocation.getArgument(2); + return localReentrantLock.tryLock(wait, unit); + }); + when(mockLock.isHeldByCurrentThread()).thenAnswer(invocation -> localReentrantLock.isHeldByCurrentThread()); + Mockito.doAnswer(invocation -> { + localReentrantLock.unlock(); + return null; + }).when(mockLock).unlock(); } @AfterEach @@ -130,6 +139,77 @@ void concurrentWithdrawalMaintainsConsistency() throws InterruptedException { assertThat(reloaded.getBalance()).isEqualByComparingTo(new BigDecimal("30000")); } + @Test + @DisplayName("100건의 동시 출금 시도 시 100건의 출금이 정확히 완료된다") + void hundredConcurrentWithdrawalsCompleteSuccessfully() throws InterruptedException { + Account prepared = Account.of( + null, + "encoded-password", + "테스트 계좌", + new BigDecimal("1000000"), + AccountType.DEPOSIT, + AccountState.ACTIVE, + 1L + ); + + Account saved = accountRepository.save(prepared); + + int threadCount = 100; + ExecutorService executorService = Executors.newFixedThreadPool(threadCount); + CountDownLatch readyLatch = new CountDownLatch(threadCount); + CountDownLatch startLatch = new CountDownLatch(1); + CountDownLatch doneLatch = new CountDownLatch(threadCount); + BigDecimal withdrawAmount = new BigDecimal("10000"); + + AtomicInteger successCount = new AtomicInteger(); + AtomicInteger failureCount = new AtomicInteger(); + + Runnable withdrawTask = () -> { + try { + readyLatch.countDown(); + startLatch.await(); + boolean completed = false; + while (!completed && successCount.get() < threadCount) { + try { + accountService.processTransaction(saved.getId(), withdrawAmount, TxnType.WITHDRAWAL, null); + int current = successCount.incrementAndGet(); + assertThat(current).isLessThanOrEqualTo(threadCount); + completed = true; + } catch (CustomBaseException e) { + if (e.getErrorCode() == ErrorBaseCode.ACCOUNT_LOCK_TIMEOUT && successCount.get() < threadCount) { + continue; + } + + failureCount.incrementAndGet(); + completed = true; + } + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } finally { + doneLatch.countDown(); + } + }; + + for (int i = 0; i < threadCount; i++) { + executorService.submit(withdrawTask); + } + + readyLatch.await(3, TimeUnit.SECONDS); + startLatch.countDown(); + doneLatch.await(15, TimeUnit.SECONDS); + executorService.shutdownNow(); + + Account reloaded = accountRepository.findById(saved.getId()) + .orElseThrow(); + + System.out.println("송금 횟수 : " + successCount); + System.out.println("송금 실패 횟수 : " + failureCount); + assertThat(successCount.get()).isEqualTo(threadCount); + assertThat(failureCount.get()).isZero(); + assertThat(reloaded.getBalance()).isEqualByComparingTo(BigDecimal.ZERO); + } + @TestConfiguration static class MockRedissonConfiguration {