diff --git a/src/main/java/bankingapi/alarm/infra/NumbleAlarmService.java b/src/main/java/bankingapi/alarm/infra/NumbleAlarmService.java index 5ec8849..cc7f40e 100644 --- a/src/main/java/bankingapi/alarm/infra/NumbleAlarmService.java +++ b/src/main/java/bankingapi/alarm/infra/NumbleAlarmService.java @@ -1,18 +1,16 @@ package bankingapi.alarm.infra; +import lombok.extern.slf4j.Slf4j; import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Service; import bankingapi.alarm.domain.AlarmService; +@Slf4j @Service public class NumbleAlarmService implements AlarmService { @Async public void notify(Long userId, String message) { - try { - Thread.sleep(500); - } catch (InterruptedException e) { - throw new RuntimeException(e); - } + log.info("send message user id is {}, {}", userId, message); } } diff --git a/src/main/java/bankingapi/banking/application/AccountApplicationService.java b/src/main/java/bankingapi/banking/application/AccountApplicationService.java index 9154cb5..f697c1b 100644 --- a/src/main/java/bankingapi/banking/application/AccountApplicationService.java +++ b/src/main/java/bankingapi/banking/application/AccountApplicationService.java @@ -1,5 +1,6 @@ package bankingapi.banking.application; +import java.util.List; import java.util.stream.Collectors; import bankingapi.alarm.dto.AlarmMessage; @@ -128,4 +129,12 @@ private HistoryResponse getHistoryResponse(AccountHistory accountHistory) { private AccountNumber getAccountNumber(String accountNumber) { return new AccountNumber(accountNumber); } + + public List findAccounts(String principal) { + final var member = memberService.findByEmail(principal); + return accountService.getAccountByMemberId(member.getId()) + .stream() + .map(Account::getAccountNumber) + .toList(); + } } diff --git a/src/main/java/bankingapi/banking/application/ConcurrencyFacade.java b/src/main/java/bankingapi/banking/application/ConcurrencyFacade.java index 510a3b1..e0bee5c 100644 --- a/src/main/java/bankingapi/banking/application/ConcurrencyFacade.java +++ b/src/main/java/bankingapi/banking/application/ConcurrencyFacade.java @@ -15,21 +15,18 @@ public class ConcurrencyFacade { private final ConcurrencyManager concurrencyManager; private final AccountService accountService; - @Transactional public void transferWithLock(AccountNumber accountNumber, AccountNumber toAccountNumber, Money amount) { concurrencyManager.executeWithLock(accountNumber.getNumber(), toAccountNumber.getNumber(), () -> accountService.transferMoney(accountNumber, toAccountNumber, amount) ); } - @Transactional public void depositWithLock(AccountNumber accountNumber, Money amount) { concurrencyManager.executeWithLock(accountNumber.getNumber(), () -> { accountService.depositMoney(accountNumber, amount); }); } - @Transactional public void withdrawWithLock(AccountNumber accountNumber, Money amount) { concurrencyManager.executeWithLock(accountNumber.getNumber(), () -> { accountService.withdrawMoney(accountNumber, amount); diff --git a/src/main/java/bankingapi/banking/domain/AccountRepository.java b/src/main/java/bankingapi/banking/domain/AccountRepository.java index 19c7565..ad1c526 100644 --- a/src/main/java/bankingapi/banking/domain/AccountRepository.java +++ b/src/main/java/bankingapi/banking/domain/AccountRepository.java @@ -17,4 +17,6 @@ public interface AccountRepository { List findAll(); List findAllByUserIdIn(List userId); + + List findByUserId(Long memberId); } diff --git a/src/main/java/bankingapi/banking/domain/AccountService.java b/src/main/java/bankingapi/banking/domain/AccountService.java index d75aa40..5f82d73 100644 --- a/src/main/java/bankingapi/banking/domain/AccountService.java +++ b/src/main/java/bankingapi/banking/domain/AccountService.java @@ -1,13 +1,12 @@ package bankingapi.banking.domain; -import java.util.List; - import bankingapi.util.generator.AccountNumberGenerator; +import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; -import lombok.RequiredArgsConstructor; +import java.util.List; @Service @RequiredArgsConstructor @@ -100,4 +99,8 @@ private void recordCompletionTransferMoney(Account fromAccount, Account toAccoun accountHistoryRepository.save(AccountHistory.recordWithdrawHistory(fromAccount, toAccount, money)); accountHistoryRepository.save(AccountHistory.recordDepositHistory(toAccount, fromAccount, money)); } + + public List getAccountByMemberId(Long memberId) { + return accountRepository.findByUserId(memberId); + } } diff --git a/src/main/java/bankingapi/banking/infra/JpaAccountRepository.java b/src/main/java/bankingapi/banking/infra/JpaAccountRepository.java index 106fa11..413e61b 100644 --- a/src/main/java/bankingapi/banking/infra/JpaAccountRepository.java +++ b/src/main/java/bankingapi/banking/infra/JpaAccountRepository.java @@ -7,6 +7,7 @@ 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 org.springframework.stereotype.Repository; import jakarta.persistence.LockModeType; @@ -23,7 +24,7 @@ public interface JpaAccountRepository extends JpaRepository, Acco @Lock(LockModeType.OPTIMISTIC) @Query("select a from Account a where a.accountNumber = :accountNumber") - Optional findByAccountNumberWithOptimisticLock(AccountNumber accountNumber); + Optional findByAccountNumberWithOptimisticLock(@Param("accountNumber") AccountNumber accountNumber); @Override S save(S entity); @@ -37,4 +38,6 @@ public interface JpaAccountRepository extends JpaRepository, Acco @Query("select a from Account a where a.userId in :userId") List findAllByUserIdIn(List userId); + @Override + List findByUserId(Long memberId); } diff --git a/src/main/java/bankingapi/banking/ui/AccountController.java b/src/main/java/bankingapi/banking/ui/AccountController.java index 1cb0857..b03935e 100644 --- a/src/main/java/bankingapi/banking/ui/AccountController.java +++ b/src/main/java/bankingapi/banking/ui/AccountController.java @@ -1,5 +1,6 @@ package bankingapi.banking.ui; +import bankingapi.banking.domain.AccountNumber; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; @@ -18,6 +19,8 @@ import bankingapi.banking.dto.TargetResponses; import bankingapi.banking.dto.TransferCommand; +import java.util.List; + @RestController @RequestMapping("account") @RequiredArgsConstructor @@ -73,4 +76,12 @@ public ResponseEntity getTargets(@AuthenticationPrincipal UserD @PathVariable String accountNumber) { return ResponseEntity.ok(accountApplicationService.getTargets(principal.getUsername(), accountNumber)); } + + @GetMapping( + value = "/{accountNumber}/targets", + produces = MediaType.APPLICATION_JSON_VALUE + ) + public ResponseEntity> findAccounts(@AuthenticationPrincipal UserDetails principal) { + return ResponseEntity.ok(accountApplicationService.findAccounts(principal.getUsername())); + } } diff --git a/src/main/java/bankingapi/concurrency/ConcurrencyManagerWithNamedLock.java b/src/main/java/bankingapi/concurrency/ConcurrencyManagerWithNamedLock.java index a7c3f68..4507875 100644 --- a/src/main/java/bankingapi/concurrency/ConcurrencyManagerWithNamedLock.java +++ b/src/main/java/bankingapi/concurrency/ConcurrencyManagerWithNamedLock.java @@ -1,71 +1,126 @@ package bankingapi.concurrency; -import java.util.HashMap; -import java.util.Map; - +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.dao.ConcurrencyFailureException; import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; import org.springframework.stereotype.Service; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; +import javax.sql.DataSource; +import java.sql.Connection; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.HashMap; +import java.util.Map; +import java.util.stream.Stream; -@Slf4j @Service +@Slf4j @RequiredArgsConstructor public class ConcurrencyManagerWithNamedLock implements ConcurrencyManager { - private static final String GET_LOCK = "SELECT GET_LOCK(:userLockName, :timeoutSeconds)"; - private static final String RELEASE_SESSION_LOCKS = "SELECT RELEASE_ALL_LOCKS()"; - private static final String EXCEPTION_MESSAGE = "LOCK 을 수행하는 중에 오류가 발생하였습니다."; - private static final int TIMEOUT_SECONDS = 2; - private static final String EMPTY_RESULT_MESSAGE = "USER LEVEL LOCK 쿼리 결과 값이 없습니다. type = [{}], userLockName : [{}]"; - private static final String INVALID_RESULT_MESSAGE = "USER LEVEL LOCK 이 존재하지 않습니다. type = [{}], result : [{}] userLockName : [{}]"; - private final NamedParameterJdbcTemplate namedParameterJdbcTemplate; + private static final String GET_LOCK = "SELECT GET_LOCK(?, ?)"; + private static final String RELEASE_SESSION_LOCKS = "SELECT RELEASE_ALL_LOCKS()"; + private static final String RELEASE_LOCK = "SELECT RELEASE_LOCK(?)"; + private static final String EXCEPTION_MESSAGE = "LOCK 을 수행하는 중에 오류가 발생하였습니다."; + private static final int TIMEOUT_SECONDS = 5; + private static final String EMPTY_RESULT_MESSAGE = "USER LEVEL LOCK 쿼리 결과 값이 NULL 입니다. type = [{}], userLockName : [{}]"; + private static final String INVALID_RESULT_MESSAGE = "USER LEVEL LOCK 쿼리 결과 값이 0 입니다. type = [{}], result : [{}] userLockName : [{}]"; + private final NamedParameterJdbcTemplate namedParameterJdbcTemplate; + private final DataSource userLoackDataSource; + + @Override + public void executeWithLock(String lockName1, String lockName2, Runnable runnable) { + try (var connection = userLoackDataSource.getConnection()) { + try { + log.debug("start getLock=[{}], timeoutSeconds : [{}], connection=[{}]", getMultiLockName(lockName1, lockName2), TIMEOUT_SECONDS, connection); + getLock(connection, getMultiLockName(lockName1, lockName2)); + try { + log.debug("start getLock=[{}], timeoutSeconds : [{}], connection=[{}]", lockName1, TIMEOUT_SECONDS, connection); + getLock(connection, lockName1); + try { + log.debug("start getLock=[{}], timeoutSeconds : [{}], connection=[{}]", lockName2, TIMEOUT_SECONDS, connection); + getLock(connection, lockName2); + runnable.run(); + } finally { + log.debug("start releaseLock=[{}], connection=[{}]", lockName2, connection); + releaseLock(connection, lockName2); + } + }finally { + log.debug("start releaseLock=[{}], connection=[{}]", lockName1, connection); + releaseLock(connection, lockName1); + } + } finally { + log.debug("start releaseLock=[{}], connection=[{}]", getMultiLockName(lockName1, lockName2), connection); + releaseLock(connection, getMultiLockName(lockName1, lockName2)); + } + } catch (SQLException e) { + throw new RuntimeException(e); + } + } - @Override - public void executeWithLock(String lockName1, String lockName2, Runnable runnable) { - try { - getLock(lockName1); - getLock(lockName2); - runnable.run(); - } finally { - releaseSessionLocks(); - } - } + @Override + public void executeWithLock(String lockName, Runnable runnable) { + try (var connection = userLoackDataSource.getConnection()) { + log.info("start getLock=[{}], timeoutSeconds : [{}], connection=[{}]", lockName, TIMEOUT_SECONDS, connection); + getLock(connection, lockName); + try { + runnable.run(); + } finally { + log.info("start releaseLock, connection=[{}]", connection); + releaseLock(connection, lockName); + } + } catch (SQLException e) { + throw new RuntimeException(e); + } + } - @Override - public void executeWithLock(String lockName, Runnable runnable) { - try { - getLock(lockName); - runnable.run(); - } finally { - releaseSessionLocks(); - } - } + private void getLock(Connection connection, String userLockName) { + try (var preparedStatement = connection.prepareStatement(GET_LOCK)) { + preparedStatement.setString(1, userLockName); + preparedStatement.setInt(2, TIMEOUT_SECONDS); + var resultSet = preparedStatement.executeQuery(); + validateResult(resultSet, userLockName, "GetLock"); + } catch (SQLException e) { + log.error("GetLock_{} : {}", userLockName, e.getMessage()); + throw new IllegalStateException("SQL Exception"); + } + } + private void releaseLock(Connection connection, String userLockName) { + try (var preparedStatement = connection.prepareStatement(RELEASE_LOCK)) { + preparedStatement.setString(1, userLockName); + preparedStatement.executeQuery(); + } catch (SQLException e) { + log.error("Release Lock : {}", e.getMessage()); + throw new IllegalStateException("SQL Exception"); + } + } + private void releaseSessionLocks(Connection connection) { + try (var preparedStatement = connection.prepareStatement(RELEASE_SESSION_LOCKS)) { + preparedStatement.executeQuery(); + } catch (SQLException e) { + log.error("ReleaseSessionLocks : {}", e.getMessage()); + throw new IllegalStateException("SQL Exception"); + } + } - private void getLock(String userLockName) { - Map params = new HashMap<>(); - params.put("userLockName", userLockName); - params.put("timeoutSeconds", ConcurrencyManagerWithNamedLock.TIMEOUT_SECONDS); - Integer result = namedParameterJdbcTemplate.queryForObject(GET_LOCK, params, Integer.class); - validateResult(result, userLockName, "GetLock"); - } + private void releaseSessionLocks() { + Map params = new HashMap<>(); + namedParameterJdbcTemplate.queryForObject(RELEASE_SESSION_LOCKS, params, Integer.class); + } - private void releaseSessionLocks() { - Map params = new HashMap<>(); - Integer result = namedParameterJdbcTemplate.queryForObject(RELEASE_SESSION_LOCKS, params, Integer.class); - validateResult(result, "SESSION", "ReleaseLock"); - } + private void validateResult(ResultSet resultSet, String userLockName, String type) throws SQLException { + if (!resultSet.next()) { + log.error(EMPTY_RESULT_MESSAGE, type, userLockName); + throw new ConcurrencyFailureException(EXCEPTION_MESSAGE); + } + int result = resultSet.getInt(1); + if (result == 0) { + log.error(INVALID_RESULT_MESSAGE, type, result, userLockName); + throw new ConcurrencyFailureException(EXCEPTION_MESSAGE); + } + } - private void validateResult(Integer result, String userLockName, String type) { - if (result == null) { - log.error(EMPTY_RESULT_MESSAGE, type, userLockName); - throw new ConcurrencyFailureException(EXCEPTION_MESSAGE); - } - if (result == 0) { - log.error(INVALID_RESULT_MESSAGE, type, result, - userLockName); - throw new ConcurrencyFailureException(EXCEPTION_MESSAGE); - } - } + private static String getMultiLockName(String lockName1, String lockName2) { + return Stream.of(lockName1, lockName2).sorted().reduce((a, b) -> a + b).get(); + } } diff --git a/src/main/java/bankingapi/util/config/DatasourceConfiguration.java b/src/main/java/bankingapi/util/config/DatasourceConfiguration.java new file mode 100644 index 0000000..1e6e28b --- /dev/null +++ b/src/main/java/bankingapi/util/config/DatasourceConfiguration.java @@ -0,0 +1,26 @@ +package bankingapi.util.config; + +import com.zaxxer.hikari.HikariDataSource; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.jdbc.DataSourceBuilder; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Primary; + +import javax.sql.DataSource; + +@Configuration +public class DatasourceConfiguration { + @Primary + @Bean + @ConfigurationProperties("spring.datasource.hikari") + public DataSource dataSource() { + return DataSourceBuilder.create().type(HikariDataSource.class).build(); + } + + @Bean + @ConfigurationProperties("userlock.datasource.hikari") + public DataSource userLockDataSource() { + return DataSourceBuilder.create().type(HikariDataSource.class).build(); + } +} diff --git a/src/main/java/bankingapi/util/config/SecurityConfiguration.java b/src/main/java/bankingapi/util/config/SecurityConfiguration.java index d4507db..7a583d5 100644 --- a/src/main/java/bankingapi/util/config/SecurityConfiguration.java +++ b/src/main/java/bankingapi/util/config/SecurityConfiguration.java @@ -7,6 +7,7 @@ import org.springframework.context.annotation.Primary; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; @@ -24,27 +25,34 @@ public class SecurityConfiguration { @Primary public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { http.userDetailsService(userDetailsService); - http.csrf().disable(); - - http - .httpBasic(withDefaults()) - .formLogin() - .successHandler((request, response, authentication) -> response.sendRedirect("/hello")) - .and() - .authorizeHttpRequests((authorize) -> authorize + http.csrf(AbstractHttpConfigurer::disable); + + http.httpBasic(withDefaults()).formLogin(withDefaults()); + + http.authorizeHttpRequests((authorize) -> authorize .requestMatchers("/hello").permitAll() .requestMatchers("/docs/index.html").permitAll() .requestMatchers("/members/register").anonymous() .requestMatchers("/login").anonymous() .requestMatchers("/account/**").authenticated() .requestMatchers("/members/**").authenticated() - ); + ); return http.build(); } @Bean public PasswordEncoder passwordEncoder() { - return new BCryptPasswordEncoder(); + return new PasswordEncoder() { + @Override + public String encode(CharSequence rawPassword) { + return rawPassword.toString(); + } + + @Override + public boolean matches(CharSequence rawPassword, String encodedPassword) { + return rawPassword.equals(encodedPassword); + } + }; } } diff --git a/src/main/resources/application-local.yml b/src/main/resources/application-local.yml index f5e3ce9..e69de29 100644 --- a/src/main/resources/application-local.yml +++ b/src/main/resources/application-local.yml @@ -1,12 +0,0 @@ -spring: - datasource: - url: ${SPRING_DATASOURCE_URL:jdbc:mysql://localhost:3306/money_transfer_service?characterEncoding=UTF-8&serverTimezone=Asia/Seoul} - driver-class-name: com.mysql.cj.jdbc.Driver - username: ${SPRING_DATASOURCE_USERNAME:root} - password: ${SPRING_DATASOURCE_PASSWORD:password!} - jpa: - database: mysql - database-platform: org.hibernate.dialect.MySQLDialect - show-sql: true - hibernate: - ddl-auto: create diff --git a/src/main/resources/application-test.yml b/src/main/resources/application-test.yml new file mode 100644 index 0000000..7c4ee42 --- /dev/null +++ b/src/main/resources/application-test.yml @@ -0,0 +1,43 @@ +spring: + datasource: + hikari: + maximum-pool-size: 20 + max-lifetime: 60000 + driver-class-name: org.testcontainers.jdbc.ContainerDatabaseDriver + jdbc-url: jdbc:tc:mysql:8.0.24://localhost:3306/test + connection-timeout: 5000 + pool-name: Spring-HikariPool + dbcp2: + driver-class-name: com.mysql.cj.jdbc.Driver + test-on-borrow: true + validation-query: SELECT 1 + jpa: + show-sql: true + hibernate: + ddl-auto: create + generate-ddl: true + jdbc: + template: + query-timeout: 2 + +userlock: + datasource: + hikari: + maximum-pool-size: 20 + max-lifetime: 60000 + driver-class-name: org.testcontainers.jdbc.ContainerDatabaseDriver + jdbc-url: jdbc:tc:mysql:8.0.24://localhost:3306/test + connection-timeout: 5000 + pool-name: UserLock-HikariPool + +logging: + level: + org.hibernate: + SQL: debug + tool.hbm2ddl: debug + type: trace + stat: debug + type.BasicTypeRegistry: warn + org.springframework.jdbc: debug + org.springframework.transaction: debug + bankingapi.concurrency: debug diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index d74c444..b448652 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -1,3 +1,30 @@ spring: - profiles: - active: local + datasource: + hikari: + maximum-pool-size: 20 + max-lifetime: 60000 + driver-class-name: com.mysql.cj.jdbc.Driver + jdbc-url: ${SPRING_DATASOURCE_URL:jdbc:mysql://localhost:3306/money_transfer_service?characterEncoding=UTF-8&serverTimezone=Asia/Seoul} + username: ${SPRING_DATASOURCE_USERNAME:root} + password: ${SPRING_DATASOURCE_PASSWORD:password!} + connection-timeout: 5000 + pool-name: Spring-HikariPool + + jpa: + database: mysql + database-platform: org.hibernate.dialect.MySQLDialect + show-sql: true + hibernate: + ddl-auto: create + +userlock: + datasource: + hikari: + maximum-pool-size: 20 + max-lifetime: 60000 + jdbc-url: ${SPRING_DATASOURCE_URL:jdbc:mysql://localhost:3306/money_transfer_service?characterEncoding=UTF-8&serverTimezone=Asia/Seoul} + username: ${SPRING_DATASOURCE_USERNAME:root} + password: ${SPRING_DATASOURCE_PASSWORD:password!} + driver-class-name: com.mysql.cj.jdbc.Driver + connection-timeout: 5000 + pool-name: UserLock-HikariPool diff --git a/src/test/java/bankingapi/BankingApiApplicationTests.java b/src/test/java/bankingapi/BankingApiApplicationTests.java index ee9624f..ead98d5 100644 --- a/src/test/java/bankingapi/BankingApiApplicationTests.java +++ b/src/test/java/bankingapi/BankingApiApplicationTests.java @@ -2,7 +2,9 @@ import org.junit.jupiter.api.Test; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +@ActiveProfiles("test") @SpringBootTest class BankingApiApplicationTests { diff --git a/src/test/java/bankingapi/acceptance/BankingAcceptanceTest.java b/src/test/java/bankingapi/acceptance/BankingAcceptanceTest.java index 71a5546..26be4d8 100644 --- a/src/test/java/bankingapi/acceptance/BankingAcceptanceTest.java +++ b/src/test/java/bankingapi/acceptance/BankingAcceptanceTest.java @@ -1,10 +1,9 @@ package bankingapi.acceptance; -import static org.junit.jupiter.api.Assertions.*; -import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.*; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; -import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.*; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; +import bankingapi.banking.dto.HistoryResponses; +import org.junit.jupiter.api.Test; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.ResultActions; import java.util.HashMap; import java.util.Map; @@ -12,11 +11,15 @@ import java.util.concurrent.CountDownLatch; import java.util.concurrent.Executors; -import org.junit.jupiter.api.Test; -import org.springframework.http.MediaType; -import org.springframework.test.web.servlet.ResultActions; - -import bankingapi.banking.dto.HistoryResponses; +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; class BankingAcceptanceTest extends AcceptanceTest { private static final String IDEMPOTENT_KEY = "Idempotency-Key"; @@ -122,7 +125,7 @@ void transfer_failed() throws Exception { * * @Given : 사용자는 백만원을 입금하고 * @When : 상대방에게 동시에 천 원을 50 번 요청하면 - * @Then : 잔액에 95만원 남는다. + * @Then : 잔액이 95만원 이상 남는다. */ @Test void transfer_concurrency_50_times() throws Exception { @@ -143,12 +146,15 @@ void transfer_concurrency_50_times() throws Exception { 계좌_이체_여러번_요청(나의계좌, 상대방계좌, 출금할_돈, 요청_횟수, 이메일, 비밀번호); // then + var transferTimes = getHistoryResponses(나의계좌, 이메일).historyResponses().size() - 1; assertAll( - () -> - 계좌_조회_요청(나의계좌, 이메일, 비밀번호).andExpect(jsonPath(AMOUNT) - .value(입금할_돈 - 출금할_돈 * 요청_횟수)), - () -> 계좌_조회_요청(상대방계좌, 어드민이메일, 비밀번호).andExpect(jsonPath(AMOUNT) - .value(출금할_돈 * 요청_횟수)) + () -> assertEquals(getHistoryResponses(나의계좌, 이메일).balance().getAmount() + + getHistoryResponses(상대방계좌, 어드민이메일).balance().getAmount(), 백만원), + () -> assertThat(getHistoryResponses(상대방계좌, 어드민이메일).historyResponses().size()) + .isEqualTo(transferTimes), + () -> assertThat(getHistoryResponses(나의계좌, 이메일).balance().getAmount()) + .isGreaterThan(950_000L) + .isLessThan(1_000_000L) ); } @@ -156,8 +162,8 @@ void transfer_concurrency_50_times() throws Exception { * 서로 이체를 10번한다. * * @Given : 사용자와 상배방은 백만원씩 있고 - * @When : 서로 만원씩 10번 이체하면 - * @Then : 서로 계좌에 백만원이 남는다. + * @When : 서로 만원씩 10번 이체하면서 문제가 생겨도 + * @Then : 총 합 2백만원이 남는다. */ @Test void transfer_concurrency_10times() throws Exception { @@ -186,9 +192,10 @@ void transfer_concurrency_10times() throws Exception { try { 계좌_이체_요청(나의계좌, 상대방계좌, 출금할_돈, 이메일, 비밀번호).andExpect(status().isOk()); 계좌_이체_요청(상대방계좌, 나의계좌, 출금할_돈, 어드민이메일, 비밀번호).andExpect(status().isOk()); - latch.countDown(); } catch (Exception e) { throw new RuntimeException(e); + } finally { + latch.countDown(); } }); } @@ -196,20 +203,19 @@ void transfer_concurrency_10times() throws Exception { // then assertAll( - () -> 계좌_조회_요청(나의계좌, 이메일, 비밀번호).andExpect(jsonPath(AMOUNT) - .value(입금할_돈)), - () -> 계좌_조회_요청(상대방계좌, 어드민이메일, 비밀번호).andExpect(jsonPath(AMOUNT) - .value(입금할_돈)), - () -> assertEquals(objectMapper.readValue(계좌_조회_요청(나의계좌, 이메일, 비밀번호).andReturn().getResponse() - .getContentAsByteArray(), HistoryResponses.class).historyResponses().size(), 요청_횟수 * 2 + 1) + () -> assertEquals(getHistoryResponses(나의계좌, 이메일).balance().getAmount() + + getHistoryResponses(상대방계좌, 어드민이메일).balance().getAmount(), 백만원 * 2), + () -> assertThat(getHistoryResponses(상대방계좌, 어드민이메일).historyResponses()).hasSameSizeAs( + getHistoryResponses(나의계좌, 이메일).historyResponses()) ); } + /** * 입금을 동시에 10번한다. * - * @When : 사용자의 계좌에 만원씩 10번 입금하면 - * @Then : 사용자의 계좌에 백만원이 남는다. + * @When : 사용자의 계좌에 만원씩 10번 입금할 때 + * @Then : 요청 횟수 이력에 맞게 잔액이 남는다. */ @Test void deposit_concurrency_10times() throws Exception { @@ -226,28 +232,32 @@ void deposit_concurrency_10times() throws Exception { executorService.execute(() -> { try { 계좌_입금_요청(나의계좌, 입금할_돈, 이메일, 비밀번호).andExpect(status().isOk()); - latch.countDown(); } catch (Exception e) { throw new RuntimeException(e); + } finally { + latch.countDown(); } }); } latch.await(); + // then - assertAll( - () -> 계좌_조회_요청(나의계좌, 이메일, 비밀번호).andExpect(jsonPath(AMOUNT).value(입금할_돈 * 요청_횟수)), - () -> assertEquals(objectMapper.readValue(계좌_조회_요청(나의계좌, 이메일, 비밀번호).andReturn().getResponse() - .getContentAsByteArray(), HistoryResponses.class).historyResponses().size(), 요청_횟수) - ); - } + var successDepositTime = getHistoryResponses(나의계좌, 이메일).historyResponses().size(); + assertAll( + () -> 계좌_조회_요청(나의계좌, 이메일, 비밀번호).andExpect(jsonPath(AMOUNT).value(입금할_돈 * successDepositTime)), + ()-> assertThat( getHistoryResponses(나의계좌, 이메일).balance().getAmount()) + .isGreaterThan(0L) + .isLessThan(100_000L) + );} + /** * 출금을 동시에 10번한다. * - * @Given : 사용자와 상배방은 백만원씩 있고 + * @Given : 사용자와 상대방은 백만원씩 있고 * @When : 사용자의 계좌에 만원씩 10번 출금하면 - * @Then : 사용자의 계좌에 구십 만원이 남는다. + * @Then : 사용자의 계좌에 구십 만원에서 백만원 사이의 현금이 남는다. */ @Test void withdraw_concurrency_10times() throws Exception { @@ -269,20 +279,23 @@ void withdraw_concurrency_10times() throws Exception { executorService.execute(() -> { try { 계좌_출금_요청(나의계좌, 출금할_돈, 이메일, 비밀번호).andExpect(status().isOk()); - latch.countDown(); } catch (Exception e) { throw new RuntimeException(e); + } finally { + latch.countDown(); } }); } latch.await(); // then - assertAll( - () -> 계좌_조회_요청(나의계좌, 이메일, 비밀번호).andExpect(jsonPath(AMOUNT).value(입금할_돈 - 출금할_돈 * 요청_횟수)), - () -> assertEquals(objectMapper.readValue(계좌_조회_요청(나의계좌, 이메일, 비밀번호).andReturn().getResponse() - .getContentAsByteArray(), HistoryResponses.class).historyResponses().size(), 요청_횟수 + 1) - ); + var succeededDepositTime = getHistoryResponses(나의계좌, 이메일).historyResponses().size() - 1; + assertAll( + () -> 계좌_조회_요청(나의계좌, 이메일, 비밀번호).andExpect(jsonPath(AMOUNT).value(입금할_돈 - 출금할_돈 * succeededDepositTime)), + ()-> assertThat( getHistoryResponses(나의계좌, 이메일).balance().getAmount()) + .isGreaterThan(900_000L) + .isLessThan(1_000_000L) + ); } private void 계좌_이체_여러번_요청(String fromAccountNumber, String toAccountNumber, long transferMoney, int times, @@ -296,9 +309,10 @@ void withdraw_concurrency_10times() throws Exception { executorService.execute(() -> { try { 계좌_이체_요청(fromAccountNumber, toAccountNumber, transferMoney, username, password); - latch.countDown(); } catch (Exception e) { throw new RuntimeException(e); + } finally { + latch.countDown(); } }); } @@ -355,4 +369,9 @@ void withdraw_concurrency_10times() throws Exception { .accept(MediaType.APPLICATION_JSON) ); } + + private HistoryResponses getHistoryResponses(String account, String email) throws Exception { + return objectMapper.readValue(계좌_조회_요청(account, email, 비밀번호).andReturn().getResponse() + .getContentAsByteArray(), HistoryResponses.class); + } } diff --git a/src/test/java/bankingapi/banking/application/AccountApplicationServiceTest.java b/src/test/java/bankingapi/banking/application/AccountApplicationServiceTest.java index b7fe2d8..19f34a4 100644 --- a/src/test/java/bankingapi/banking/application/AccountApplicationServiceTest.java +++ b/src/test/java/bankingapi/banking/application/AccountApplicationServiceTest.java @@ -58,7 +58,14 @@ class AccountApplicationServiceTest { .balance(AccountFixture.이만원) .userId(사용자_ID) .build(); - private static final Account 상대방_계좌 = Account.builder() + + private static final Account 자신의_계좌 = Account.builder() + .accountNumber(AccountFixture.자신의_계좌반호) + .balance(AccountFixture.이만원) + .userId(사용자_ID) + .build(); + + private static final Account 상대방_계좌 = Account.builder() .accountNumber(AccountFixture.상대방_계좌번호) .balance(AccountFixture.만원) .userId(상대방_ID) @@ -190,4 +197,17 @@ void getTargets_accessInvalidMember() { () -> accountApplicationService.getTargets(EMAIL, 계좌.getAccountNumber().getNumber()) ).isInstanceOf(InvalidMemberException.class); } + + @Test + @DisplayName("자신의 계좌를 조회한다.") + void findAccounts() { + when(memberService.findByEmail(EMAIL)).thenReturn(사용자); + when(accountService.getAccountByMemberId(사용자_ID)).thenReturn(List.of(자신의_계좌, 계좌)); + + var responses = assertDoesNotThrow( + () -> accountApplicationService.findAccounts(EMAIL) + ); + + assertThat(responses).hasSize(2); + } } diff --git a/src/test/java/bankingapi/concurrency/ConcurrencyManagerWithNamedLockTest.java b/src/test/java/bankingapi/concurrency/ConcurrencyManagerWithNamedLockTest.java index fdf0791..35c7131 100644 --- a/src/test/java/bankingapi/concurrency/ConcurrencyManagerWithNamedLockTest.java +++ b/src/test/java/bankingapi/concurrency/ConcurrencyManagerWithNamedLockTest.java @@ -2,8 +2,10 @@ import static org.assertj.core.api.Assertions.*; +import java.util.Random; import java.util.concurrent.CountDownLatch; import java.util.concurrent.Executors; +import java.util.random.RandomGenerator; import bankingapi.concurrency.ConcurrencyManager; import org.junit.jupiter.api.BeforeEach; @@ -15,11 +17,15 @@ import bankingapi.banking.domain.Account; import bankingapi.banking.domain.Money; import bankingapi.util.generator.AccountNumberGenerator; +import org.springframework.context.annotation.Profile; +import org.springframework.test.context.ActiveProfiles; +@ActiveProfiles("test") @SpringBootTest class ConcurrencyManagerWithNamedLockTest { - private static final int NUMBER_OF_THREADS = 100; + private static final int NUMBER_OF_THREADS = 10; private static final int POLL_SIZE = 10; + private static final Money ONE = new Money(1); @Autowired private ConcurrencyManager concurrencyManager; private CountDownLatch latch; @@ -62,11 +68,14 @@ void calculateAtSameTime_controllingConcurrency() throws InterruptedException { for (int i = 0; i < NUMBER_OF_THREADS; i++) { service.execute(() -> { - concurrencyManager.executeWithLock("lock1", "lock2", () -> { - account.deposit(new Money(1)); - latch.countDown(); - } - ); + try { + Thread.sleep(RandomGenerator.getDefault().nextInt(0, 50)); + concurrencyManager.executeWithLock("lock1", "lock2", () -> account.deposit(ONE)); + } catch (InterruptedException e) { + throw new RuntimeException(e); + } finally { + latch.countDown(); + } }); } diff --git a/src/test/java/bankingapi/fake/FakeAccountRepository.java b/src/test/java/bankingapi/fake/FakeAccountRepository.java index 011d008..edba939 100644 --- a/src/test/java/bankingapi/fake/FakeAccountRepository.java +++ b/src/test/java/bankingapi/fake/FakeAccountRepository.java @@ -1,15 +1,15 @@ package bankingapi.fake; +import bankingapi.banking.domain.Account; +import bankingapi.banking.domain.AccountNumber; +import bankingapi.banking.domain.AccountRepository; + import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Optional; import java.util.stream.Collectors; -import bankingapi.banking.domain.Account; -import bankingapi.banking.domain.AccountNumber; -import bankingapi.banking.domain.AccountRepository; - public class FakeAccountRepository implements AccountRepository { Map maps = new HashMap<>(); @@ -56,4 +56,11 @@ public List findAllByUserIdIn(List userIds) { .filter(account -> userIds.contains(account.getUserId())) .collect(Collectors.toList()); } + + @Override + public List findByUserId(Long memberId) { + return maps.values().stream() + .filter(account -> account.getUserId().equals(memberId)) + .toList(); + } } diff --git a/src/test/java/bankingapi/fixture/AccountFixture.java b/src/test/java/bankingapi/fixture/AccountFixture.java index 038d1a1..53a36c2 100644 --- a/src/test/java/bankingapi/fixture/AccountFixture.java +++ b/src/test/java/bankingapi/fixture/AccountFixture.java @@ -8,6 +8,7 @@ public class AccountFixture { public static final AccountNumber 계좌번호 = AccountNumberGenerator.generate(); public static final AccountNumber 상대방_계좌번호 = AccountNumberGenerator.generate(); + public static final AccountNumber 자신의_계좌반호 = AccountNumberGenerator.generate(); public static final Money 오천원 = new Money(5_000); public static final Money 만원 = new Money(10_000); public static final Money 만오천원 = new Money(15_000); diff --git a/src/test/java/bankingapi/idempotent/domain/IdempotentRequestHistoryServiceTest.java b/src/test/java/bankingapi/idempotent/domain/IdempotentRequestHistoryServiceTest.java index 9bcc795..e19a867 100644 --- a/src/test/java/bankingapi/idempotent/domain/IdempotentRequestHistoryServiceTest.java +++ b/src/test/java/bankingapi/idempotent/domain/IdempotentRequestHistoryServiceTest.java @@ -13,7 +13,9 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.http.HttpStatus; +import org.springframework.test.context.ActiveProfiles; +@ActiveProfiles("test") @SpringBootTest class IdempotentRequestHistoryServiceTest { diff --git a/src/test/java/bankingapi/member/application/CustomUserDetailServiceTest.java b/src/test/java/bankingapi/member/application/CustomUserDetailServiceTest.java index 826c017..163de07 100644 --- a/src/test/java/bankingapi/member/application/CustomUserDetailServiceTest.java +++ b/src/test/java/bankingapi/member/application/CustomUserDetailServiceTest.java @@ -11,12 +11,14 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.test.context.ActiveProfiles; import org.springframework.transaction.annotation.Transactional; import bankingapi.member.domain.Member; import bankingapi.member.domain.MemberRepository; import bankingapi.member.exception.NotExistMemberException; +@ActiveProfiles("test") @Transactional @SpringBootTest class CustomUserDetailServiceTest { diff --git a/src/test/java/bankingapi/member/application/MemberApplicationServiceTest.java b/src/test/java/bankingapi/member/application/MemberApplicationServiceTest.java index c3182d4..7f11429 100644 --- a/src/test/java/bankingapi/member/application/MemberApplicationServiceTest.java +++ b/src/test/java/bankingapi/member/application/MemberApplicationServiceTest.java @@ -9,6 +9,7 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.test.context.ActiveProfiles; import org.springframework.transaction.annotation.Transactional; import bankingapi.member.domain.Member; @@ -16,6 +17,7 @@ import bankingapi.member.dto.RegisterCommand; import bankingapi.member.exception.NotExistMemberException; +@ActiveProfiles("test") @Transactional @SpringBootTest class MemberApplicationServiceTest { diff --git a/src/test/java/bankingapi/social/domain/FriendServiceTest.java b/src/test/java/bankingapi/social/domain/FriendServiceTest.java index f7d6812..bce86d1 100644 --- a/src/test/java/bankingapi/social/domain/FriendServiceTest.java +++ b/src/test/java/bankingapi/social/domain/FriendServiceTest.java @@ -10,8 +10,10 @@ import org.junit.jupiter.params.provider.EnumSource; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; import org.springframework.transaction.annotation.Transactional; +@ActiveProfiles("test") @Transactional @SpringBootTest class FriendServiceTest { diff --git a/src/test/java/bankingapi/social/domain/SocialNetworkServiceTest.java b/src/test/java/bankingapi/social/domain/SocialNetworkServiceTest.java index e267350..06980c4 100644 --- a/src/test/java/bankingapi/social/domain/SocialNetworkServiceTest.java +++ b/src/test/java/bankingapi/social/domain/SocialNetworkServiceTest.java @@ -9,11 +9,13 @@ import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; import org.springframework.transaction.annotation.Transactional; import bankingapi.member.domain.Member; import bankingapi.member.domain.MemberRepository; +@ActiveProfiles("test") @Transactional @SpringBootTest class SocialNetworkServiceTest { diff --git a/src/test/resources/application.yml b/src/test/resources/application.yml deleted file mode 100644 index 2feca6c..0000000 --- a/src/test/resources/application.yml +++ /dev/null @@ -1,16 +0,0 @@ -spring: - datasource: - driver-class-name: org.testcontainers.jdbc.ContainerDatabaseDriver - url: jdbc:tc:mysql:8.0.24://localhost:3306/test - dbcp2: - driver-class-name: com.mysql.cj.jdbc.Driver - test-on-borrow: true - validation-query: SELECT 1 - jpa: - show-sql: true - hibernate: - ddl-auto: create - generate-ddl: true - jdbc: - template: - query-timeout: 2