diff --git a/build.gradle b/build.gradle index 28f83ab..562a365 100644 --- a/build.gradle +++ b/build.gradle @@ -35,6 +35,8 @@ 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 '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' 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) { 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/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 new file mode 100644 index 0000000..d934187 --- /dev/null +++ b/src/main/java/org/creditto/core_banking/domain/account/service/AccountLockService.java @@ -0,0 +1,92 @@ +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; + +@Slf4j +@Service +@RequiredArgsConstructor +public class AccountLockService { + + private final RedissonClient redissonClient; + private final AccountLockProperties accountLockProperties; + + public T executeWithLock(Long accountId, LockCallback callback) { + RLock lock = redissonClient.getLock(accountLockProperties.getAccountLockPrefix() + accountId); + boolean redisLockAcquired = false; + boolean redisAvailable = true; + + try { + redisLockAcquired = lock.tryLock( + accountLockProperties.getWaitMillis(), + accountLockProperties.getLeaseMillis(), + TimeUnit.MILLISECONDS + ); + } 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 { + 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 (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, new LockCallback() { + @Override + public Void invoke() { + runnable.run(); + return null; + } + + @Override + public Void invokeFallback() { + runnable.run(); + return null; + } + }); + } + + public interface LockCallback { + 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 52e51d9..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 @@ -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,16 @@ 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/config/RedisConfig.java b/src/main/java/org/creditto/core_banking/global/config/RedisConfig.java new file mode 100644 index 0000000..7e7b0e5 --- /dev/null +++ b/src/main/java/org/creditto/core_banking/global/config/RedisConfig.java @@ -0,0 +1,28 @@ +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())); + + return template; + } +} 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 - 서버 오류 diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 17757ec..7579322 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: ${REDIS_HOST} + port: ${REDIS_PORT} + timeout: 2000 scheduler: remittance: @@ -34,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} 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..c84c40f --- /dev/null +++ b/src/test/java/org/creditto/core_banking/domain/account/AccountConcurrencyIntegrationTest.java @@ -0,0 +1,222 @@ +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.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 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; +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; + private ReentrantLock localReentrantLock; + + @BeforeEach + void setUpLock() throws InterruptedException { + mockLock = Mockito.mock(RLock.class); + localReentrantLock = new ReentrantLock(); + when(redissonClient.getLock(anyString())).thenReturn(mockLock); + 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 + 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")); + } + + @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 { + + @Bean + @Primary + public RedissonClient mockRedissonClient() { + return Mockito.mock(RedissonClient.class); + } + } +} 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 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); + } + } } 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..3f1e3fb --- /dev/null +++ b/src/test/java/org/creditto/core_banking/domain/redis/RedisTest.java @@ -0,0 +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.RedisTemplate; + +@Disabled +@SpringBootTest +public class RedisTest { + + @Autowired + private RedisTemplate redisTemplate; + + @Test + void testRedisConnection() { + String key = "test-key"; + String expectedValue = "redis"; + redisTemplate.opsForValue().set(key, expectedValue); + Object value = redisTemplate.opsForValue().get(key); + System.out.println("Redis Value: " + value); + } +}