diff --git a/.gitignore b/.gitignore index 9b820d83..661ab51d 100644 --- a/.gitignore +++ b/.gitignore @@ -38,4 +38,5 @@ out/ ### 로컬 환경변수 ### local.properties -/logs \ No newline at end of file +/logs +docker-compose.override.yml \ No newline at end of file diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 2c7e82ef..6a4c95e3 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -7,15 +7,16 @@ services: volumes: - ../build/libs/coin-0.0.1-SNAPSHOT.jar:/app/coin-0.0.1-SNAPSHOT.jar - /etc/localtime:/etc/localtime:ro - - /home/ubuntu/logs/springboot:/app/logs working_dir: /app - command: ["java", "-jar", "coin-0.0.1-SNAPSHOT.jar", "--spring.profiles.active=dev,mariadb-local"] + command: ["java", "-jar", "coin-0.0.1-SNAPSHOT.jar", "--spring.profiles.active=dev,it,mariadb-local"] ports: - "8080:8080" + - "5005:5005" env_file: - ./local.properties environment: - TZ=Asia/Seoul + - JAVA_TOOL_OPTIONS=-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:5005 depends_on: mariadb: condition: service_healthy diff --git a/docker/mariadb/init.sql b/docker/mariadb/init.sql index 824352af..29b57767 100644 --- a/docker/mariadb/init.sql +++ b/docker/mariadb/init.sql @@ -6,6 +6,9 @@ create table account user_id int not null ); +create or replace index idx_account_user_id + on account (user_id); + create table asset ( ticker varchar(10) not null @@ -91,6 +94,9 @@ create table wallet ticker varchar(10) not null ); +create or replace index idx_wallet_account_id_ticker + on wallet (account_id, ticker); + INSERT INTO `if`.users (user_id, created_at) VALUES (1, '2025-05-16 09:30:00.000000'); diff --git a/src/main/java/com/cleanengine/coin/common/annotation/TransactionLoggingAspect.java b/src/main/java/com/cleanengine/coin/common/annotation/TransactionLoggingAspect.java new file mode 100644 index 00000000..2a25d7fa --- /dev/null +++ b/src/main/java/com/cleanengine/coin/common/annotation/TransactionLoggingAspect.java @@ -0,0 +1,28 @@ +package com.cleanengine.coin.common.annotation; + +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.annotation.Around; +import org.aspectj.lang.annotation.Aspect; +import org.slf4j.MDC; +import org.springframework.stereotype.Component; + +import java.util.UUID; + +@Aspect +@Component +public class TransactionLoggingAspect { + + private static final String TRANSACTION_ID_KEY = "txId"; + + @Around("@annotation(org.springframework.transaction.annotation.Transactional)") + public Object logTransaction(ProceedingJoinPoint joinPoint) throws Throwable { + String txId = UUID.randomUUID().toString().substring(0, 8); + MDC.put(TRANSACTION_ID_KEY, txId); + + try { + return joinPoint.proceed(); + } finally { + MDC.remove(TRANSACTION_ID_KEY); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/cleanengine/coin/configuration/bootstrap/DBInitRunner.java b/src/main/java/com/cleanengine/coin/configuration/bootstrap/DBInitRunner.java index d5529057..052ae521 100644 --- a/src/main/java/com/cleanengine/coin/configuration/bootstrap/DBInitRunner.java +++ b/src/main/java/com/cleanengine/coin/configuration/bootstrap/DBInitRunner.java @@ -1,13 +1,13 @@ package com.cleanengine.coin.configuration.bootstrap; -import com.cleanengine.coin.order.domain.Asset; -import com.cleanengine.coin.order.adapter.out.persistentce.wallet.OrderWalletRepository; import com.cleanengine.coin.order.adapter.out.persistentce.asset.AssetRepository; +import com.cleanengine.coin.order.domain.Asset; import com.cleanengine.coin.user.domain.Account; import com.cleanengine.coin.user.domain.User; import com.cleanengine.coin.user.domain.Wallet; import com.cleanengine.coin.user.info.infra.AccountRepository; import com.cleanengine.coin.user.info.infra.UserRepository; +import com.cleanengine.coin.user.info.infra.WalletRepository; import lombok.RequiredArgsConstructor; import org.springframework.boot.CommandLineRunner; import org.springframework.context.annotation.Profile; @@ -24,7 +24,7 @@ public class DBInitRunner implements CommandLineRunner { private final AccountRepository accountRepository; private final UserRepository userRepository; - private final OrderWalletRepository orderWalletRepository; + private final WalletRepository walletRepository; private final AssetRepository assetRepository; @Transactional @@ -58,7 +58,7 @@ protected void initSellBotData(){ wallet2.setTicker("TRUMP"); wallet2.setAccountId(account.getId()); wallet2.setSize(500_000_000.0); - orderWalletRepository.saveAll(List.of(wallet, wallet2)); + walletRepository.saveAll(List.of(wallet, wallet2)); } @Transactional @@ -80,7 +80,7 @@ protected void initBuyBotData() { wallet2.setTicker("TRUMP"); wallet2.setAccountId(account.getId()); wallet2.setSize(0.0); - orderWalletRepository.saveAll(List.of(wallet, wallet2)); + walletRepository.saveAll(List.of(wallet, wallet2)); } @Transactional diff --git a/src/main/java/com/cleanengine/coin/order/adapter/out/persistentce/account/OrderAccountRepository.java b/src/main/java/com/cleanengine/coin/order/adapter/out/persistentce/account/OrderAccountRepository.java deleted file mode 100644 index 0ad71a03..00000000 --- a/src/main/java/com/cleanengine/coin/order/adapter/out/persistentce/account/OrderAccountRepository.java +++ /dev/null @@ -1,10 +0,0 @@ -package com.cleanengine.coin.order.adapter.out.persistentce.account; - -import com.cleanengine.coin.user.domain.Account; -import org.springframework.data.repository.CrudRepository; - -import java.util.Optional; - -public interface OrderAccountRepository extends CrudRepository { - Optional findByUserId(Integer userId); -} diff --git a/src/main/java/com/cleanengine/coin/order/adapter/out/persistentce/wallet/OrderWalletRepository.java b/src/main/java/com/cleanengine/coin/order/adapter/out/persistentce/wallet/OrderWalletRepository.java deleted file mode 100644 index 6e3d3981..00000000 --- a/src/main/java/com/cleanengine/coin/order/adapter/out/persistentce/wallet/OrderWalletRepository.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.cleanengine.coin.order.adapter.out.persistentce.wallet; - -import com.cleanengine.coin.user.domain.Wallet; -import org.springframework.data.repository.CrudRepository; - -public interface OrderWalletRepository extends CrudRepository, OrderWalletRepositoryCustom { -} diff --git a/src/main/java/com/cleanengine/coin/order/adapter/out/persistentce/wallet/OrderWalletRepositoryCustom.java b/src/main/java/com/cleanengine/coin/order/adapter/out/persistentce/wallet/OrderWalletRepositoryCustom.java deleted file mode 100644 index 9f73edaa..00000000 --- a/src/main/java/com/cleanengine/coin/order/adapter/out/persistentce/wallet/OrderWalletRepositoryCustom.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.cleanengine.coin.order.adapter.out.persistentce.wallet; - -import com.cleanengine.coin.user.domain.Wallet; - -import java.util.Optional; - -public interface OrderWalletRepositoryCustom { - Optional findWalletBy(Integer userId, String ticker); -} diff --git a/src/main/java/com/cleanengine/coin/order/adapter/out/persistentce/wallet/OrderWalletRepositoryCustomImpl.java b/src/main/java/com/cleanengine/coin/order/adapter/out/persistentce/wallet/OrderWalletRepositoryCustomImpl.java deleted file mode 100644 index 471adcc3..00000000 --- a/src/main/java/com/cleanengine/coin/order/adapter/out/persistentce/wallet/OrderWalletRepositoryCustomImpl.java +++ /dev/null @@ -1,34 +0,0 @@ -package com.cleanengine.coin.order.adapter.out.persistentce.wallet; - -import com.cleanengine.coin.user.domain.Wallet; -import jakarta.persistence.EntityManager; -import jakarta.persistence.NoResultException; -import jakarta.persistence.TypedQuery; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Repository; -import org.springframework.transaction.annotation.Transactional; - -import java.util.Optional; - -@Repository -@Transactional -@RequiredArgsConstructor -public class OrderWalletRepositoryCustomImpl implements OrderWalletRepositoryCustom { - private final EntityManager em; - - @Override - public Optional findWalletBy(Integer userId, String ticker) { - TypedQuery query = em.createQuery( - "select w from Wallet w inner join Account a on w.accountId = a.id where a.userId = :userId and w.ticker = :ticker", - Wallet.class); - query.setParameter("userId", userId); - query.setParameter("ticker", ticker); - - try{ - Wallet wallet = query.getSingleResult(); - return Optional.of(wallet); - } catch (NoResultException e) { - return Optional.empty(); - } - } -} diff --git a/src/main/java/com/cleanengine/coin/order/application/AssetService.java b/src/main/java/com/cleanengine/coin/order/application/AssetService.java index cdea7404..e013b9dc 100644 --- a/src/main/java/com/cleanengine/coin/order/application/AssetService.java +++ b/src/main/java/com/cleanengine/coin/order/application/AssetService.java @@ -33,6 +33,10 @@ public List getAllAssetInfos(){ return assetRepository.findAll().stream().map(AssetInfo::from).toList(); } + public List getAllAssetTickers(){ + return assetRepository.findAll().stream().map(Asset::getTicker).toList(); + } + public boolean isAssetExist(String ticker){ if(assetCacheRepository.isAssetExists(ticker)) return true; diff --git a/src/main/java/com/cleanengine/coin/order/application/OrderService.java b/src/main/java/com/cleanengine/coin/order/application/OrderService.java index ab11b755..1a8ff5aa 100644 --- a/src/main/java/com/cleanengine/coin/order/application/OrderService.java +++ b/src/main/java/com/cleanengine/coin/order/application/OrderService.java @@ -8,6 +8,7 @@ import jakarta.validation.Validator; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Isolation; import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; import org.springframework.validation.annotation.Validated; @@ -22,11 +23,11 @@ @Service @RequiredArgsConstructor @Validated -public class OrderService { +public class OrderService { private final List> createOrderStrategies; private final Validator validator; - @Transactional + @Transactional(isolation = Isolation.READ_COMMITTED) public OrderInfo createOrder(OrderCommand.CreateOrder createOrder){ validateCreateOrder(createOrder); CreateOrderStrategy createOrderStrategy = createOrderStrategies.stream() @@ -35,14 +36,14 @@ public OrderInfo createOrder(OrderCommand.CreateOrder createOrder){ return createOrderStrategy.processCreatingOrder(createOrder); } - @Transactional(propagation = Propagation.REQUIRES_NEW) - public OrderInfo createOrderWithBot(String ticker, Boolean isBuyOrder, Double orderSize, Double price){ + @Transactional(propagation = Propagation.REQUIRES_NEW, isolation = Isolation.READ_COMMITTED) + public void createOrderWithBot(String ticker, Boolean isBuyOrder, Double orderSize, Double price){ Integer userId = isBuyOrder? BUY_ORDER_BOT_ID : SELL_ORDER_BOT_ID; OrderCommand.CreateOrder createOrder = new OrderCommand.CreateOrder(ticker, userId, isBuyOrder, false, orderSize, price, LocalDateTime.now(), true); - return createOrder(createOrder); + createOrder(createOrder); } protected void validateCreateOrder(OrderCommand.CreateOrder createOrder) { diff --git a/src/main/java/com/cleanengine/coin/order/application/strategy/BuyOrderStrategy.java b/src/main/java/com/cleanengine/coin/order/application/strategy/BuyOrderStrategy.java index ef23efea..7ce7c577 100644 --- a/src/main/java/com/cleanengine/coin/order/application/strategy/BuyOrderStrategy.java +++ b/src/main/java/com/cleanengine/coin/order/application/strategy/BuyOrderStrategy.java @@ -1,9 +1,7 @@ package com.cleanengine.coin.order.application.strategy; import com.cleanengine.coin.common.error.DomainValidationException; -import com.cleanengine.coin.order.adapter.out.persistentce.account.OrderAccountRepository; import com.cleanengine.coin.order.adapter.out.persistentce.order.command.BuyOrderRepository; -import com.cleanengine.coin.order.adapter.out.persistentce.wallet.OrderWalletRepository; import com.cleanengine.coin.order.application.AssetService; import com.cleanengine.coin.order.application.dto.OrderInfo; import com.cleanengine.coin.order.application.port.out.PublishOrderCreatedPort; @@ -12,6 +10,8 @@ import com.cleanengine.coin.order.domain.domainservice.CreateBuyOrderDomainService; import com.cleanengine.coin.order.domain.domainservice.CreateOrderDomainService; import com.cleanengine.coin.user.domain.Account; +import com.cleanengine.coin.user.info.infra.AccountRepository; +import com.cleanengine.coin.user.info.infra.WalletRepository; import org.springframework.stereotype.Component; import org.springframework.validation.FieldError; @@ -57,11 +57,11 @@ protected OrderInfo.BuyOrderInfo extractOrderInfo(Order order) { public BuyOrderStrategy(PublishOrderCreatedPort publishOrderCreatedPort, AssetService assetService, - OrderWalletRepository orderWalletRepository, - OrderAccountRepository orderAccountRepository, + WalletRepository walletRepository, + AccountRepository accountRepository, BuyOrderRepository buyOrderRepository, CreateBuyOrderDomainService createOrderDomainService) { - super(publishOrderCreatedPort, assetService, orderWalletRepository, orderAccountRepository); + super(publishOrderCreatedPort, assetService, walletRepository, accountRepository); this.buyOrderRepository = buyOrderRepository; this.createOrderDomainService = createOrderDomainService; } diff --git a/src/main/java/com/cleanengine/coin/order/application/strategy/CreateOrderStrategy.java b/src/main/java/com/cleanengine/coin/order/application/strategy/CreateOrderStrategy.java index ee6c4c59..d7da7108 100644 --- a/src/main/java/com/cleanengine/coin/order/application/strategy/CreateOrderStrategy.java +++ b/src/main/java/com/cleanengine/coin/order/application/strategy/CreateOrderStrategy.java @@ -1,8 +1,6 @@ package com.cleanengine.coin.order.application.strategy; import com.cleanengine.coin.common.error.DomainValidationException; -import com.cleanengine.coin.order.adapter.out.persistentce.account.OrderAccountRepository; -import com.cleanengine.coin.order.adapter.out.persistentce.wallet.OrderWalletRepository; import com.cleanengine.coin.order.application.AssetService; import com.cleanengine.coin.order.application.dto.OrderCommand; import com.cleanengine.coin.order.application.dto.OrderInfo; @@ -10,8 +8,8 @@ import com.cleanengine.coin.order.application.port.out.PublishOrderCreatedPort; import com.cleanengine.coin.order.domain.Order; import com.cleanengine.coin.order.domain.domainservice.CreateOrderDomainService; -import com.cleanengine.coin.user.domain.Account; -import com.cleanengine.coin.user.domain.Wallet; +import com.cleanengine.coin.user.info.infra.AccountRepository; +import com.cleanengine.coin.user.info.infra.WalletRepository; import lombok.AllArgsConstructor; import org.springframework.validation.FieldError; @@ -21,14 +19,13 @@ public abstract class CreateOrderStrategy> { protected final PublishOrderCreatedPort publishOrderCreatedPort; protected final AssetService assetService; - protected final OrderWalletRepository walletRepository; - protected final OrderAccountRepository accountRepository; + protected final WalletRepository walletRepository; + protected final AccountRepository accountRepository; public S processCreatingOrder(OrderCommand.CreateOrder createOrderCommand){ validateTicker(createOrderCommand.ticker()); T order = createOrder(createOrderCommand); saveOrder(order); - createWalletIfNeeded(order.getUserId(), order.getTicker()); keepHoldings(order); publishOrderCreatedPort.publish(new OrderCreated(order)); return extractOrderInfo(order); @@ -49,21 +46,12 @@ protected void validateTicker(String ticker){ } protected T createOrder(OrderCommand.CreateOrder createOrderCommand){ - T order = createOrderDomainService().createOrder( + + return createOrderDomainService().createOrder( createOrderCommand.ticker(), createOrderCommand.userId(), createOrderCommand.isBuyOrder(), createOrderCommand.isMarketOrder(), createOrderCommand.orderSize(), createOrderCommand.price(), createOrderCommand.createdAt(), createOrderCommand.isBot()); - - return order; } - // TODO 책임이 너무 많은 - protected void createWalletIfNeeded(Integer userId, String ticker){ - if(walletRepository.findWalletBy(userId, ticker).isEmpty()){ - Account account = accountRepository.findByUserId(userId).orElseThrow(); - Wallet wallet = Wallet.generateEmptyWallet(ticker, account.getId()); - walletRepository.save(wallet); - } - } } diff --git a/src/main/java/com/cleanengine/coin/order/application/strategy/SellOrderStrategy.java b/src/main/java/com/cleanengine/coin/order/application/strategy/SellOrderStrategy.java index bb16c60c..c4c4b966 100644 --- a/src/main/java/com/cleanengine/coin/order/application/strategy/SellOrderStrategy.java +++ b/src/main/java/com/cleanengine/coin/order/application/strategy/SellOrderStrategy.java @@ -1,9 +1,7 @@ package com.cleanengine.coin.order.application.strategy; import com.cleanengine.coin.common.error.DomainValidationException; -import com.cleanengine.coin.order.adapter.out.persistentce.account.OrderAccountRepository; import com.cleanengine.coin.order.adapter.out.persistentce.order.command.SellOrderRepository; -import com.cleanengine.coin.order.adapter.out.persistentce.wallet.OrderWalletRepository; import com.cleanengine.coin.order.application.AssetService; import com.cleanengine.coin.order.application.dto.OrderInfo; import com.cleanengine.coin.order.application.port.out.PublishOrderCreatedPort; @@ -12,6 +10,8 @@ import com.cleanengine.coin.order.domain.domainservice.CreateOrderDomainService; import com.cleanengine.coin.order.domain.domainservice.CreateSellOrderDomainService; import com.cleanengine.coin.user.domain.Wallet; +import com.cleanengine.coin.user.info.infra.AccountRepository; +import com.cleanengine.coin.user.info.infra.WalletRepository; import org.springframework.stereotype.Component; import org.springframework.validation.FieldError; @@ -39,7 +39,7 @@ protected void keepHoldings(SellOrder order) throws RuntimeException { Double orderSize = order.getOrderSize(); Wallet wallet = walletRepository - .findWalletBy(userId, ticker) + .findByAccountIdAndTicker(userId, ticker) .orElseThrow(()-> new DomainValidationException("Wallet not found", List.of(new FieldError("wallet", "userId", "user might not exist"), @@ -62,11 +62,11 @@ protected OrderInfo.SellOrderInfo extractOrderInfo(Order order) { public SellOrderStrategy(PublishOrderCreatedPort publishOrderCreatedPort, AssetService assetService, - OrderWalletRepository orderWalletRepository, - OrderAccountRepository orderAccountRepository, + WalletRepository walletRepository, + AccountRepository accountRepository, SellOrderRepository sellOrderRepository, CreateSellOrderDomainService createOrderDomainService) { - super(publishOrderCreatedPort, assetService, orderWalletRepository, orderAccountRepository); + super(publishOrderCreatedPort, assetService, walletRepository, accountRepository); this.sellOrderRepository = sellOrderRepository; this.createOrderDomainService = createOrderDomainService; } diff --git a/src/main/java/com/cleanengine/coin/realitybot/service/OrderGenerateService.java b/src/main/java/com/cleanengine/coin/realitybot/service/OrderGenerateService.java index 17c0599d..4529fe97 100644 --- a/src/main/java/com/cleanengine/coin/realitybot/service/OrderGenerateService.java +++ b/src/main/java/com/cleanengine/coin/realitybot/service/OrderGenerateService.java @@ -1,15 +1,12 @@ package com.cleanengine.coin.realitybot.service; -import com.cleanengine.coin.order.adapter.out.persistentce.account.OrderAccountRepository; -import com.cleanengine.coin.order.adapter.out.persistentce.wallet.OrderWalletRepository; import com.cleanengine.coin.order.application.OrderService; import com.cleanengine.coin.realitybot.api.UnitPriceRefresher; import com.cleanengine.coin.realitybot.domain.VWAPMetricsRecorder; import com.cleanengine.coin.realitybot.vo.DeviationPricePolicy; import com.cleanengine.coin.realitybot.vo.OrderPricePolicy; import com.cleanengine.coin.realitybot.vo.OrderVolumePolicy; -import com.cleanengine.coin.user.domain.Account; -import com.cleanengine.coin.user.domain.Wallet; +import com.cleanengine.coin.user.info.application.AccountService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; @@ -18,36 +15,28 @@ import java.text.DecimalFormat; -import static com.cleanengine.coin.common.CommonValues.BUY_ORDER_BOT_ID; -import static com.cleanengine.coin.common.CommonValues.SELL_ORDER_BOT_ID; - @Slf4j @Service @Order(5) @RequiredArgsConstructor public class OrderGenerateService { - private final VWAPMetricsRecorder VWAPMetricsRecorder; @Value("${bot-handler.order-level}") private int[] orderLevels; //체결 강도 - private double unitPrice = 0; //TODO : 거래쌍 시세에 따른 호가 정책 개발 필요 private final UnitPriceRefresher unitPriceRefresher; private final PlatformVWAPService platformVWAPService; private final OrderService orderService; private final OrderPricePolicy orderPricePolicy; private final DeviationPricePolicy deviationPricePolicy; private final OrderVolumePolicy orderVolumePolicy; - private final OrderWalletRepository orderWalletRepository; - private final OrderAccountRepository accountExternalRepository; + private final AccountService accountService; private final VWAPMetricsRecorder recorder; - private String ticker; - public void generateOrder(String ticker, double apiVWAP, double avgVolume) {//기준 주문금액, 주문량 받기 (tick당 계산되어 들어옴) - this.ticker = ticker; //호가 정책 적용 - this.unitPrice = unitPriceRefresher.getUnitPriceByTicker(ticker); + //TODO : 거래쌍 시세에 따른 호가 정책 개발 필요 + double unitPrice = unitPriceRefresher.getUnitPriceByTicker(ticker); // //최근 체결 내역 가져오기 // List trades = tradeRepository.findTop10ByTickerOrderByTradeTimeDesc(ticker); @@ -58,7 +47,7 @@ public void generateOrder(String ticker, double apiVWAP, double avgVolume) {// //편차 계산 (vwap 기준) double trendLineRate = (platformVWAP - apiVWAP)/ apiVWAP; for(int level : orderLevels) { //1주문당 3회 매수매도 처리 - OrderPricePolicy.OrderPrice basePrice = orderPricePolicy.calculatePrice(level,platformVWAP,unitPrice,trendLineRate); + OrderPricePolicy.OrderPrice basePrice = orderPricePolicy.calculatePrice(level,platformVWAP, unitPrice,trendLineRate); DeviationPricePolicy.AdjustPrice adjustPrice = deviationPricePolicy.adjust( basePrice.sell(), basePrice.buy(), trendLineRate, apiVWAP, unitPrice); @@ -103,7 +92,7 @@ private void createOrderWithFallback(String ticker,boolean isBuy, double volume, } catch (IllegalArgumentException e) { log.debug("잔량 부족: {}", e.getMessage()); try { - resetBot(ticker); + accountService.resetBot(ticker); orderService.createOrderWithBot(ticker, isBuy, volume, price); } catch (Exception e1) { log.error("주문 재시도 실패", e1); @@ -111,21 +100,4 @@ private void createOrderWithFallback(String ticker,boolean isBuy, double volume, } } - protected void resetBot(String ticker){ - this.ticker = ticker; - Wallet wallet = orderWalletRepository.findWalletBy(SELL_ORDER_BOT_ID,ticker).get(); - wallet.setSize(500_000_000.0); - Wallet wallet2 = orderWalletRepository.findWalletBy(BUY_ORDER_BOT_ID,ticker).get(); - wallet2.setSize(0.0); - orderWalletRepository.save(wallet); - orderWalletRepository.save(wallet2); - - Account account = accountExternalRepository.findByUserId(SELL_ORDER_BOT_ID).get(); - account.setCash(0.0); - Account account2 = accountExternalRepository.findByUserId(BUY_ORDER_BOT_ID).get(); - account2.setCash(500_000_000.0); - accountExternalRepository.save(account); - accountExternalRepository.save(account2); - } - } diff --git a/src/main/java/com/cleanengine/coin/trade/application/TradeBatchProcessor.java b/src/main/java/com/cleanengine/coin/trade/application/TradeBatchProcessor.java deleted file mode 100644 index 4dbffceb..00000000 --- a/src/main/java/com/cleanengine/coin/trade/application/TradeBatchProcessor.java +++ /dev/null @@ -1,78 +0,0 @@ -package com.cleanengine.coin.trade.application; - -import com.cleanengine.coin.order.domain.spi.WaitingOrdersManager; -import jakarta.annotation.PreDestroy; -import lombok.Getter; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.boot.ApplicationArguments; -import org.springframework.boot.ApplicationRunner; -import org.springframework.core.annotation.Order; -import org.springframework.stereotype.Service; - -import java.util.ArrayList; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; -import java.util.concurrent.TimeUnit; - -@Order(4) -@Slf4j -@RequiredArgsConstructor -@Service -public class TradeBatchProcessor implements ApplicationRunner { - - private final WaitingOrdersManager waitingOrdersManager; - private final TradeFlowService tradeFlowService; - private final List executors = new ArrayList<>(); - - @Getter - private final Map tradeQueueManagers = new HashMap<>(); - - @Value("${order.tickers}") String[] tickers; - - @Override - public void run(ApplicationArguments args) { - processTrades(); - } - - private void processTrades() { - for (String ticker : tickers) { - TradeQueueManager tradeQueueManager = new TradeQueueManager(tradeFlowService); - tradeQueueManagers.put(ticker, tradeQueueManager); // 정상 종료를 위해 저장 - - ExecutorService tradeExecutor = Executors.newSingleThreadExecutor(r -> { - Thread thread = new Thread(r); - thread.setName("Trade-" + ticker); - return thread; - }); - executors.add(tradeExecutor); - } - } - - @PreDestroy - public void shutdown() { - // 스레드풀 종료 - for (ExecutorService executor : executors) { - try { - executor.shutdown(); - - // 2초 동안 종료 대기 후 강제 종료 - if (!executor.awaitTermination(2, TimeUnit.SECONDS)) { - executor.shutdownNow(); - // 추가로 1초 더 대기 - if (!executor.awaitTermination(1, TimeUnit.SECONDS)) { - log.error("스레드풀이 완전히 종료되지 않았습니다"); - } - } - } catch (InterruptedException e) { - executor.shutdownNow(); - Thread.currentThread().interrupt(); - } - } - } - -} \ No newline at end of file diff --git a/src/main/java/com/cleanengine/coin/trade/application/TradeExecutor.java b/src/main/java/com/cleanengine/coin/trade/application/TradeExecutor.java index a7256f70..cb7338f4 100644 --- a/src/main/java/com/cleanengine/coin/trade/application/TradeExecutor.java +++ b/src/main/java/com/cleanengine/coin/trade/application/TradeExecutor.java @@ -16,6 +16,7 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Isolation; import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; @@ -34,10 +35,12 @@ public class TradeExecutor { private final TradeExecutedEventPublisher tradeExecutedEventPublisher; private final TradeService tradeService; - @Transactional(propagation = Propagation.REQUIRES_NEW) + @Transactional(propagation = Propagation.REQUIRES_NEW, isolation = Isolation.READ_COMMITTED) public void executeTrade(WaitingOrders waitingOrders, TradePair tradePair, String ticker) { BuyOrder buyOrder = tradePair.getBuyOrder(); SellOrder sellOrder = tradePair.getSellOrder(); + log.trace("{} - 체결 시작: 매수[{} {}원 {}개] / 매도[{} {}원 {}개]", ticker, buyOrder.getId(), buyOrder.getPrice(), buyOrder.getRemainingSize(), + sellOrder.getId(), sellOrder.getPrice(), sellOrder.getRemainingSize()); double tradedPrice; double tradedSize; @@ -48,10 +51,9 @@ public void executeTrade(WaitingOrders waitingOrders, TradePair tr tradedSize = tradeUnitPriceAndSize.tradedSize(); tradedPrice = tradeUnitPriceAndSize.tradedPrice(); if (approxEquals(tradedSize, 0.0)) { - log.debug("체결 중단! 체결 시도 수량 : {}, 매수단가 : {}, 매도단가 : {}", tradedSize, buyOrder.getPrice(), sellOrder.getPrice()); - return ; + checkZeroOrderAndThrowException(buyOrder, sellOrder); } - this.writeTradingLog(buyOrder, sellOrder); + writeTradingLog(buyOrder, sellOrder); totalTradedPrice = tradedPrice * tradedSize; // 주문 잔여수량, 잔여금액 감소 @@ -62,8 +64,8 @@ public void executeTrade(WaitingOrders waitingOrders, TradePair tr sellOrder.decreaseRemainingSize(tradedSize); // 주문 완전체결 처리(잔여금액 or 잔여수량이 0) - this.removeCompletedBuyOrder(waitingOrders, buyOrder); - this.removeCompletedSellOrder(waitingOrders, sellOrder); + removeCompletedBuyOrder(waitingOrders, buyOrder); + removeCompletedSellOrder(waitingOrders, sellOrder); tradeService.updateOrder(buyOrder); tradeService.updateOrder(sellOrder); @@ -89,12 +91,24 @@ public void executeTrade(WaitingOrders waitingOrders, TradePair tr tradeExecutedEventPublisher.publish(tradeExecutedEvent); } - private Account increaseAccountCash(Order order, Double amount) { + private static void checkZeroOrderAndThrowException(BuyOrder buyOrder, SellOrder sellOrder) { + Order zeroOrder = null; + if (approxEquals(buyOrder.getRemainingDeposit(), 0.0)) + zeroOrder = buyOrder; + else if (approxEquals(sellOrder.getRemainingSize(), 0.0)) + zeroOrder = sellOrder; + if (zeroOrder == null) + throw new RuntimeException("수량이 0인 주문이 없는데도 체결 수량이 0인 현상 발생"); + throw new TradeZeroOrderException(String.format("체결 중단: 체결 수량이 0! 매수단가 : %s, 매도단가 : %s", + buyOrder.getPrice(), sellOrder.getPrice()), zeroOrder); + } + + private void increaseAccountCash(Order order, Double amount) { Account account = accountService.findAccountByUserId(order.getUserId()).orElseThrow(); - return accountService.save(account.increaseCash(amount)); + accountService.save(account.increaseCash(amount)); } - private Wallet updateWalletAfterTrade(Order order, String ticker, double tradedSize, double totalTradedPrice) { + private void updateWalletAfterTrade(Order order, String ticker, double tradedSize, double totalTradedPrice) { if (order instanceof BuyOrder) { Wallet buyerWallet = walletService.findWalletByUserIdAndTicker(order.getUserId(), ticker); double updatedBuySize = buyerWallet.getSize() + tradedSize; @@ -103,11 +117,11 @@ private Wallet updateWalletAfterTrade(Order order, String ticker, double tradedS buyerWallet.setSize(updatedBuySize); buyerWallet.setBuyPrice(updatedBuyPrice); // TODO : ROI 계산 - return walletService.save(buyerWallet); + walletService.save(buyerWallet); } else if (order instanceof SellOrder) { // 매도 시에는 평단가 변동 없음 Wallet sellerWallet = walletService.findWalletByUserIdAndTicker(order.getUserId(), ticker); - return walletService.save(sellerWallet); + walletService.save(sellerWallet); } else { throw new BusinessException("Unsupported order type: " + order.getClass().getName(), ErrorStatus.INTERNAL_SERVER_ERROR); } @@ -151,7 +165,7 @@ private static double getTradedUnitPrice(BuyOrder buyOrder, SellOrder sellOrder) } } - private void writeTradingLog(BuyOrder buyOrder, SellOrder sellOrder) { + private static void writeTradingLog(BuyOrder buyOrder, SellOrder sellOrder) { log.debug("[{}] 체결 확정! 종목: {}, ({}: {}가 {}로 {}만큼 매수주문), ({}: {}가 {}로 {}만큼 매도주문)", Thread.currentThread().threadId(), buyOrder.getTicker(), @@ -165,26 +179,26 @@ private void writeTradingLog(BuyOrder buyOrder, SellOrder sellOrder) { sellOrder.getRemainingSize()); } - private void removeCompletedBuyOrder(WaitingOrders waitingOrders, BuyOrder order) { + private static void removeCompletedBuyOrder(WaitingOrders waitingOrders, BuyOrder order) { boolean isOrderCompleted = (isMarketOrder(order) && approxEquals(order.getRemainingDeposit(), 0.0)) || (isLimitOrder(order) && approxEquals(order.getRemainingSize(), 0.0)); if (isOrderCompleted) { waitingOrders.removeOrder(order); - this.updateCompletedOrderStatus(order); + updateCompletedOrderStatus(order); } } - private void removeCompletedSellOrder(WaitingOrders waitingOrders, SellOrder order) { + private static void removeCompletedSellOrder(WaitingOrders waitingOrders, SellOrder order) { boolean isOrderCompleted = approxEquals(order.getRemainingSize(), 0.0); if (isOrderCompleted) { waitingOrders.removeOrder(order); - this.updateCompletedOrderStatus(order); + updateCompletedOrderStatus(order); } } - private void updateCompletedOrderStatus(Order order) { + private static void updateCompletedOrderStatus(Order order) { order.setState(OrderStatus.DONE); } @@ -196,4 +210,4 @@ private static boolean isLimitOrder(Order order) { return !order.getIsMarketOrder(); } -} +} \ No newline at end of file diff --git a/src/main/java/com/cleanengine/coin/trade/application/TradeFlowService.java b/src/main/java/com/cleanengine/coin/trade/application/TradeFlowService.java index df367924..f0880bbb 100644 --- a/src/main/java/com/cleanengine/coin/trade/application/TradeFlowService.java +++ b/src/main/java/com/cleanengine/coin/trade/application/TradeFlowService.java @@ -1,5 +1,6 @@ package com.cleanengine.coin.trade.application; +import com.cleanengine.coin.order.domain.BuyOrder; import com.cleanengine.coin.order.domain.Order; import com.cleanengine.coin.order.domain.spi.WaitingOrders; import com.cleanengine.coin.order.domain.spi.WaitingOrdersManager; @@ -29,9 +30,14 @@ public void execMatchAndTrade(String ticker) { tradeExecutor.executeTrade(waitingOrders, tradePair.get(), ticker); tradePair = tradeMatcher.matchOrders(waitingOrders); continueProcessing = tradePair.isPresent(); + } catch (TradeZeroOrderException e) { + Order order = e.getOrder(); + waitingOrdersManager.getWaitingOrders(order.getTicker()).removeOrder(order); + log.warn("{} - {} 주문 {} 이 주문 수량 0 이므로 제거되었음.", order.getTicker(), order instanceof BuyOrder ? "매수" : "매도", order.getId()); + tradePair = tradeMatcher.matchOrders(waitingOrders); + continueProcessing = tradePair.isPresent(); } catch (Exception e) { - // TODO : 회복 필요 - log.error("Error processing trades for {}: {}", ticker, e.getMessage()); + log.error("{} - 체결 에러 발생: {}", ticker, e.getMessage()); continueProcessing = false; } } diff --git a/src/main/java/com/cleanengine/coin/trade/application/TradeQueueManager.java b/src/main/java/com/cleanengine/coin/trade/application/TradeQueueManager.java deleted file mode 100644 index 29e564ff..00000000 --- a/src/main/java/com/cleanengine/coin/trade/application/TradeQueueManager.java +++ /dev/null @@ -1,24 +0,0 @@ -package com.cleanengine.coin.trade.application; - -import com.cleanengine.coin.order.application.event.OrderInsertedToQueue; -import lombok.extern.slf4j.Slf4j; -import org.springframework.context.event.EventListener; -import org.springframework.stereotype.Component; - -@Slf4j -@Component -public class TradeQueueManager { - - private final TradeFlowService tradeFlowService; - - public TradeQueueManager(TradeFlowService tradeFlowService) { - this.tradeFlowService = tradeFlowService; - } - - @EventListener - public void handleOrderInserted(OrderInsertedToQueue orderInsertedToQueue) { - String ticker = orderInsertedToQueue.order().getTicker(); - tradeFlowService.execMatchAndTrade(ticker); - } - -} \ No newline at end of file diff --git a/src/main/java/com/cleanengine/coin/trade/application/TradeZeroOrderException.java b/src/main/java/com/cleanengine/coin/trade/application/TradeZeroOrderException.java new file mode 100644 index 00000000..1646a098 --- /dev/null +++ b/src/main/java/com/cleanengine/coin/trade/application/TradeZeroOrderException.java @@ -0,0 +1,16 @@ +package com.cleanengine.coin.trade.application; + +import com.cleanengine.coin.order.domain.Order; +import lombok.Getter; + +@Getter +public class TradeZeroOrderException extends RuntimeException { + + Order order; + + public TradeZeroOrderException(String message, Order order) { + super(message); + this.order = order; + } + +} diff --git a/src/main/java/com/cleanengine/coin/trade/event/OrderInsertedQueueStartMatchingHandler.java b/src/main/java/com/cleanengine/coin/trade/event/OrderInsertedQueueStartMatchingHandler.java new file mode 100644 index 00000000..197862be --- /dev/null +++ b/src/main/java/com/cleanengine/coin/trade/event/OrderInsertedQueueStartMatchingHandler.java @@ -0,0 +1,53 @@ +package com.cleanengine.coin.trade.event; + +import com.cleanengine.coin.order.application.event.OrderInsertedToQueue; +import com.cleanengine.coin.trade.application.TradeFlowService; +import jakarta.annotation.PreDestroy; +import lombok.RequiredArgsConstructor; +import org.springframework.context.event.EventListener; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; + +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +@Component +@Order(4) +@RequiredArgsConstructor +public class OrderInsertedQueueStartMatchingHandler { + private final Map tickerExecutorServices = new ConcurrentHashMap<>(); + private final TradeFlowService tradeFlowService; + + @EventListener + public void handleOrderInserted(OrderInsertedToQueue orderInsertedToQueue) { + String ticker = orderInsertedToQueue.order().getTicker(); + + if(!tickerExecutorServices.containsKey(ticker)) { + addThreadExecutor(ticker); + } + + ExecutorService executorService = tickerExecutorServices.get(ticker); + executorService.execute(() -> tradeFlowService.execMatchAndTrade(ticker)); + } + + protected synchronized void addThreadExecutor(String ticker) { + if (tickerExecutorServices.containsKey(ticker)) { + return; + } + + ExecutorService executorService = Executors.newSingleThreadExecutor(r->{ + Thread thread = new Thread(r); + thread.setName("Trade-" + ticker); + return thread; + }); + + tickerExecutorServices.put(ticker, executorService); + } + + @PreDestroy + public void shutdown() { + tickerExecutorServices.values().forEach(ExecutorService::shutdown); + } +} diff --git a/src/main/java/com/cleanengine/coin/trade/service/TradeService.java b/src/main/java/com/cleanengine/coin/trade/service/TradeService.java deleted file mode 100644 index 970e54e1..00000000 --- a/src/main/java/com/cleanengine/coin/trade/service/TradeService.java +++ /dev/null @@ -1,24 +0,0 @@ -package com.cleanengine.coin.trade.service; - -import com.cleanengine.coin.trade.entity.Trade; - -public interface TradeService { - - /** - * 거래 데이터를 생성하여 저장합니다. - * - * @param ticker 종목 코드 - * @param price 가격 - * @param size 거래량 - * @return 저장된 Trade 엔티티 - */ - Trade createTrade(String ticker, Double price, Double size, Integer buyUserId, Integer sellUserId); - - /** - * 임의의 거래 데이터를 생성하여 저장합니다. - * 테스트/개발용 메서드입니다. - * - * @return 저장된 Trade 엔티티 - */ - Trade generateRandomTrade(); -} \ No newline at end of file diff --git a/src/main/java/com/cleanengine/coin/trade/service/TradeServiceImpl.java b/src/main/java/com/cleanengine/coin/trade/service/TradeServiceImpl.java deleted file mode 100644 index 7617d3b9..00000000 --- a/src/main/java/com/cleanengine/coin/trade/service/TradeServiceImpl.java +++ /dev/null @@ -1,53 +0,0 @@ -package com.cleanengine.coin.trade.service; - -import com.cleanengine.coin.trade.entity.Trade; -import com.cleanengine.coin.trade.repository.TradeRepository; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; - -import java.time.LocalDateTime; -import java.util.Random; - -@Service -@RequiredArgsConstructor -public class TradeServiceImpl implements TradeService { - - private final TradeRepository tradeRepository; - private final Random random = new Random(); - - @Override - public Trade createTrade(String ticker, Double price, Double size, Integer buyUserId, Integer sellUserId) { - Trade trade = new Trade(); - trade.setTicker(ticker); - trade.setPrice(price); - trade.setSize(size); - trade.setBuyUserId(buyUserId); - trade.setSellUserId(sellUserId); - trade.setTradeTime(LocalDateTime.now()); - return tradeRepository.save(trade); - } - - @Override - public Trade generateRandomTrade() { - // 임의의 티커 목록 - String[] tickers = {"BTC", "TRUMP"}; - String ticker = tickers[random.nextInt(tickers.length)]; - - // 가격: 1000 ~ 50000 (소수점 2자리로 반올림) - double price = Math.round((1000 + (50000 - 1000) * random.nextDouble()) * 100) / 100.0; - - // 거래량: 0.1 ~ 5.0 (소수점 2자리로 반올림) - double size = Math.round((0.1 + (5.0 - 0.1) * random.nextDouble()) * 100) / 100.0; - - // 임의의 사용자 ID 생성 - int buyUserId = random.nextInt(1000) + 1; // 1 ~ 1000 - int sellUserId = random.nextInt(1000) + 1; - - // 동일한 사용자 간 거래 방지 - while (buyUserId == sellUserId) { - sellUserId = random.nextInt(1000) + 1; - } - - return createTrade(ticker, price, size, buyUserId, sellUserId); - } -} \ No newline at end of file diff --git a/src/main/java/com/cleanengine/coin/user/info/application/AccountService.java b/src/main/java/com/cleanengine/coin/user/info/application/AccountService.java index cbc78928..94329ad2 100644 --- a/src/main/java/com/cleanengine/coin/user/info/application/AccountService.java +++ b/src/main/java/com/cleanengine/coin/user/info/application/AccountService.java @@ -1,20 +1,26 @@ package com.cleanengine.coin.user.info.application; import com.cleanengine.coin.user.domain.Account; +import com.cleanengine.coin.user.domain.Wallet; import com.cleanengine.coin.user.info.infra.AccountRepository; +import com.cleanengine.coin.user.info.infra.WalletRepository; +import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; import java.util.Optional; +import static com.cleanengine.coin.common.CommonValues.BUY_ORDER_BOT_ID; +import static com.cleanengine.coin.common.CommonValues.SELL_ORDER_BOT_ID; + +@RequiredArgsConstructor @Service public class AccountService { private final AccountRepository accountRepository; + private final WalletRepository walletRepository; - public AccountService(AccountRepository accountRepository) { - this.accountRepository = accountRepository; - } - + @Transactional public Account retrieveAccountByUserId(Integer userId) { return accountRepository.findByUserId(userId).orElse(null); } @@ -32,4 +38,20 @@ public Account createNewAccount(Integer userId, double cash) { return accountRepository.save(account); } + @Transactional + public void resetBot(String ticker) { + Account sellBotAccount = accountRepository.findByUserId(SELL_ORDER_BOT_ID).orElseThrow(); + sellBotAccount.setCash(0.0); + Account buyBotAccount = accountRepository.findByUserId(BUY_ORDER_BOT_ID).orElseThrow(); + buyBotAccount.setCash(500_000_000.0); + accountRepository.save(sellBotAccount); + accountRepository.save(buyBotAccount); + + Wallet wallet = walletRepository.findByAccountIdAndTicker(SELL_ORDER_BOT_ID, ticker).orElseThrow(); + wallet.setSize(500_000_000.0); + Wallet wallet2 = walletRepository.findByAccountIdAndTicker(BUY_ORDER_BOT_ID, ticker).orElseThrow(); + wallet2.setSize(0.0); + walletRepository.save(wallet); + walletRepository.save(wallet2); + } } diff --git a/src/main/java/com/cleanengine/coin/user/info/application/WalletService.java b/src/main/java/com/cleanengine/coin/user/info/application/WalletService.java index 2dd61722..10fbcef3 100644 --- a/src/main/java/com/cleanengine/coin/user/info/application/WalletService.java +++ b/src/main/java/com/cleanengine/coin/user/info/application/WalletService.java @@ -1,9 +1,11 @@ package com.cleanengine.coin.user.info.application; +import com.cleanengine.coin.order.adapter.out.persistentce.asset.AssetRepository; import com.cleanengine.coin.user.domain.Wallet; import com.cleanengine.coin.user.info.infra.AccountRepository; import com.cleanengine.coin.user.info.infra.WalletRepository; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; import java.util.List; @@ -12,12 +14,15 @@ public class WalletService { private final WalletRepository walletRepository; private final AccountRepository accountRepository; + private final AssetRepository assetRepository; - public WalletService(WalletRepository walletRepository, AccountRepository accountRepository) { + public WalletService(WalletRepository walletRepository, AccountRepository accountRepository, AssetRepository assetRepository) { this.walletRepository = walletRepository; this.accountRepository = accountRepository; + this.assetRepository = assetRepository; } + @Transactional public List findByAccountId(Integer accountId) { return walletRepository.findByAccountId(accountId); } @@ -32,4 +37,11 @@ public Wallet findWalletByUserIdAndTicker(Integer userId, String ticker) { .orElseGet(() -> Wallet.of(ticker, accountId)); } + public void createNewWallets(Integer accountId) { + assetRepository.findAll() + .stream() + .map(asset -> Wallet.of(asset.getTicker(), accountId)) + .forEach(walletRepository::save); + } + } diff --git a/src/main/java/com/cleanengine/coin/user/info/infra/AccountRepository.java b/src/main/java/com/cleanengine/coin/user/info/infra/AccountRepository.java index 59390cba..4c337cc5 100644 --- a/src/main/java/com/cleanengine/coin/user/info/infra/AccountRepository.java +++ b/src/main/java/com/cleanengine/coin/user/info/infra/AccountRepository.java @@ -1,12 +1,15 @@ package com.cleanengine.coin.user.info.infra; import com.cleanengine.coin.user.domain.Account; +import jakarta.persistence.LockModeType; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; import java.util.Optional; public interface AccountRepository extends JpaRepository { + @Lock(LockModeType.PESSIMISTIC_WRITE) Optional findByUserId(Integer userId); } diff --git a/src/main/java/com/cleanengine/coin/user/info/infra/WalletRepository.java b/src/main/java/com/cleanengine/coin/user/info/infra/WalletRepository.java index a9f24c2f..25bf9a9b 100644 --- a/src/main/java/com/cleanengine/coin/user/info/infra/WalletRepository.java +++ b/src/main/java/com/cleanengine/coin/user/info/infra/WalletRepository.java @@ -1,13 +1,17 @@ package com.cleanengine.coin.user.info.infra; import com.cleanengine.coin.user.domain.Wallet; +import jakarta.persistence.LockModeType; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; import java.util.List; import java.util.Optional; public interface WalletRepository extends JpaRepository { + @Lock(LockModeType.PESSIMISTIC_WRITE) Optional findByAccountIdAndTicker(Integer accountId, String ticker); + @Lock(LockModeType.PESSIMISTIC_WRITE) List findByAccountId(Integer accountId); } diff --git a/src/main/java/com/cleanengine/coin/user/login/application/CustomOAuth2UserService.java b/src/main/java/com/cleanengine/coin/user/login/application/CustomOAuth2UserService.java index 22717307..ac090bfb 100644 --- a/src/main/java/com/cleanengine/coin/user/login/application/CustomOAuth2UserService.java +++ b/src/main/java/com/cleanengine/coin/user/login/application/CustomOAuth2UserService.java @@ -1,15 +1,18 @@ package com.cleanengine.coin.user.login.application; import com.cleanengine.coin.common.CommonValues; +import com.cleanengine.coin.user.domain.Account; import com.cleanengine.coin.user.domain.OAuth; import com.cleanengine.coin.user.domain.User; import com.cleanengine.coin.user.info.application.AccountService; +import com.cleanengine.coin.user.info.application.WalletService; import com.cleanengine.coin.user.login.infra.CustomOAuth2User; import com.cleanengine.coin.user.login.infra.KakaoResponse; import com.cleanengine.coin.user.login.infra.OAuth2Response; import com.cleanengine.coin.user.login.infra.UserOAuthDetails; import com.cleanengine.coin.user.info.infra.OAuthRepository; import com.cleanengine.coin.user.info.infra.UserRepository; +import org.jetbrains.annotations.NotNull; import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService; import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest; import org.springframework.security.oauth2.core.OAuth2AuthenticationException; @@ -22,11 +25,13 @@ public class CustomOAuth2UserService extends DefaultOAuth2UserService { private final UserRepository userRepository; private final OAuthRepository oAuthRepository; private final AccountService accountService; + private final WalletService walletService; - public CustomOAuth2UserService(UserRepository userRepository, OAuthRepository oAuthRepository, AccountService accountService) { + public CustomOAuth2UserService(UserRepository userRepository, OAuthRepository oAuthRepository, AccountService accountService, WalletService walletService) { this.userRepository = userRepository; this.oAuthRepository = oAuthRepository; this.accountService = accountService; + this.walletService = walletService; } @Override @@ -51,21 +56,7 @@ public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2Authentic UserOAuthDetails existData = userRepository.findUserByOAuthProviderAndProviderId(provider, providerUserId); if (existData == null) { - User newUser = new User(); - userRepository.save(newUser); - - OAuth newOAuth = new OAuth(); - newOAuth.setUserId(newUser.getId()); - newOAuth.setProvider(provider); - newOAuth.setProviderUserId(providerUserId); - newOAuth.setEmail(email); - newOAuth.setNickname(name); - // TODO : KAKAO Token 관련 정보 추가 - oAuthRepository.save(newOAuth); - accountService.createNewAccount(newUser.getId(), CommonValues.INITIAL_USER_CASH); - - UserOAuthDetails newUserOAuthDetails = UserOAuthDetails.of(newUser, newOAuth); - return CustomOAuth2User.of(newUserOAuthDetails); + return createNewUser(provider, providerUserId, email, name); } else { OAuth existOAuth = oAuthRepository.findByProviderAndProviderUserId(provider, providerUserId); @@ -80,6 +71,26 @@ public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2Authentic } } + @NotNull + protected CustomOAuth2User createNewUser(String provider, String providerUserId, String email, String name) { + User newUser = new User(); + userRepository.save(newUser); + + OAuth newOAuth = new OAuth(); + newOAuth.setUserId(newUser.getId()); + newOAuth.setProvider(provider); + newOAuth.setProviderUserId(providerUserId); + newOAuth.setEmail(email); + newOAuth.setNickname(name); + // TODO : KAKAO Token 관련 정보 추가 + oAuthRepository.save(newOAuth); + Account newAccount = accountService.createNewAccount(newUser.getId(), CommonValues.INITIAL_USER_CASH); + walletService.createNewWallets(newAccount.getId()); + + UserOAuthDetails newUserOAuthDetails = UserOAuthDetails.of(newUser, newOAuth); + return CustomOAuth2User.of(newUserOAuthDetails); + } + protected OAuth2User doSuperLoadMethod(OAuth2UserRequest userRequest) { return super.loadUser(userRequest); } diff --git a/src/main/resources/logback-spring.xml b/src/main/resources/logback-spring.xml index acfdc5cc..974b3fe7 100644 --- a/src/main/resources/logback-spring.xml +++ b/src/main/resources/logback-spring.xml @@ -1,7 +1,7 @@ - + @@ -54,6 +54,12 @@ + + + + + + \ No newline at end of file diff --git a/src/test/java/com/cleanengine/coin/base/MariaDBAdapterTest.java b/src/test/java/com/cleanengine/coin/base/MariaDBAdapterTest.java index 2e1c0698..27c80d8c 100644 --- a/src/test/java/com/cleanengine/coin/base/MariaDBAdapterTest.java +++ b/src/test/java/com/cleanengine/coin/base/MariaDBAdapterTest.java @@ -46,7 +46,10 @@ static void mariadbProperties(DynamicPropertyRegistry registry) { registry.add("spring.datasource.username", MariaDBTestContainerExtension.container::getUsername); registry.add("spring.datasource.password", MariaDBTestContainerExtension.container::getPassword); registry.add("logging.level.org.hibernate.SQL", () -> "debug"); - registry.add("spring.jpa.show-sql", () -> "true"); + registry.add("logging.level.org.springframework.data", () -> "debug"); + registry.add("spring.jpa.properties.hibernate.format-sql", () -> "false"); + registry.add("logging.level.org.hibernate.orm.jdbc.bind", () -> "trace"); + registry.add("logging.level.org.hibernate.orm.jdbc.extract", () -> "trace"); } } } diff --git a/src/test/java/com/cleanengine/coin/base/MariaDBIntegrationTest.java b/src/test/java/com/cleanengine/coin/base/MariaDBIntegrationTest.java new file mode 100644 index 00000000..a3a805d5 --- /dev/null +++ b/src/test/java/com/cleanengine/coin/base/MariaDBIntegrationTest.java @@ -0,0 +1,54 @@ +package com.cleanengine.coin.base; + +import com.cleanengine.coin.configuration.TimeZoneConfig; +import com.cleanengine.coin.tool.extension.MariaDBTestContainerExtension; +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.springframework.test.context.jdbc.Sql; +import org.springframework.test.context.jdbc.SqlConfig; + +/** + * Mariadb가 적용된 영속성 Adapter(Repository)를 통합 테스트하기 위한 Base 테스트 클래스입니다. + * JPA Entity가 MariaDB에서 제대로 매핑되는지 확인을 위한 기본적인 insert/select와 직접 작성한 쿼리를 테스트바랍니다. + * API단으로 수행하는 통합 테스트는 AcceptanceTest를 사용바랍니다. + */ +@SpringBootTest +@Tag("testcontainers") +@ActiveProfiles({"dev", "it"}) +@ExtendWith(MariaDBTestContainerExtension.class) +@Import(TimeZoneConfig.class) +@Sql( + scripts = "classpath:db/mariadb/data/delete.sql", + executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD, + config=@SqlConfig(transactionMode = SqlConfig.TransactionMode.ISOLATED) +) +public abstract class MariaDBIntegrationTest { + @PersistenceContext + protected EntityManager em; + + @DynamicPropertySource + static void mariadbProperties(DynamicPropertyRegistry registry) { + if(MariaDBTestContainerExtension.container != null && MariaDBTestContainerExtension.container.isRunning()) { + registry.add("spring.datasource.url", MariaDBTestContainerExtension.container::getJdbcUrl); + registry.add("spring.datasource.driver-class-name", MariaDBTestContainerExtension.container::getDriverClassName); + registry.add("spring.datasource.database", MariaDBTestContainerExtension.container::getDatabaseName); + registry.add("spring.datasource.username", MariaDBTestContainerExtension.container::getUsername); + registry.add("spring.datasource.password", MariaDBTestContainerExtension.container::getPassword); + registry.add("logging.level.org.hibernate.SQL", () -> "debug"); + registry.add("logging.level.org.springframework.data", () -> "debug"); + registry.add("spring.jpa.properties.hibernate.format-sql", () -> "false"); + registry.add("logging.level.org.hibernate.orm.jdbc.bind", () -> "trace"); + registry.add("logging.level.org.hibernate.orm.jdbc.extract", () -> "trace"); + } + } +} diff --git a/src/test/java/com/cleanengine/coin/order/application/OrderServiceTest.java b/src/test/java/com/cleanengine/coin/order/application/OrderServiceTest.java new file mode 100644 index 00000000..6223c10c --- /dev/null +++ b/src/test/java/com/cleanengine/coin/order/application/OrderServiceTest.java @@ -0,0 +1,99 @@ +package com.cleanengine.coin.order.application; + +import com.cleanengine.coin.base.MariaDBIntegrationTest; +import com.cleanengine.coin.common.CommonValues; +import com.cleanengine.coin.order.adapter.out.persistentce.order.command.BuyOrderRepository; +import com.cleanengine.coin.order.adapter.out.persistentce.order.command.SellOrderRepository; +import com.cleanengine.coin.order.application.dto.OrderCommand; +import com.cleanengine.coin.trade.repository.TradeRepository; +import com.cleanengine.coin.user.domain.Account; +import com.cleanengine.coin.user.info.infra.AccountRepository; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.test.context.jdbc.Sql; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +public class OrderServiceTest extends MariaDBIntegrationTest { + @Autowired + OrderService orderService; + + @Autowired + TradeRepository tradeRepository; + + @Autowired + BuyOrderRepository buyOrderRepository; + @Autowired + SellOrderRepository sellOrderRepository; + + @Autowired + AccountRepository accountRepository; + + @Sql("initializeBotUser.sql") + @DisplayName("동시에 5개의 매도요청과 5개의 매수요청이 들어왔을 때 주문에 대한 체결이 정상적으로 처리된다.") + @Test + public void create10OrdersSimultaneously_orderShouldBeProcessedSuccessfully() throws InterruptedException { + + int numberOfThreads = 100; + + ExecutorService executorService = Executors.newFixedThreadPool(numberOfThreads); + CountDownLatch startLatch = new CountDownLatch(1); + CountDownLatch endLatch = new CountDownLatch(numberOfThreads); + + OrderCommand.CreateOrder buyOrderCommand = new OrderCommand.CreateOrder("BTC", CommonValues.BUY_ORDER_BOT_ID,true, false, 100.0, 100.0, LocalDateTime.now(),false); + OrderCommand.CreateOrder sellOrderCommand = new OrderCommand.CreateOrder("BTC", CommonValues.SELL_ORDER_BOT_ID,false, false, 100.0, 100.0, LocalDateTime.now(),false); + + for (int i = 0; i < numberOfThreads; i++) { + executorService.submit(() -> { + try{ + startLatch.await(); + orderService.createOrder(buyOrderCommand); + orderService.createOrder(buyOrderCommand); + orderService.createOrder(buyOrderCommand); + orderService.createOrder(buyOrderCommand); + orderService.createOrder(buyOrderCommand); + orderService.createOrder(buyOrderCommand); + orderService.createOrder(buyOrderCommand); + orderService.createOrder(buyOrderCommand); + orderService.createOrder(buyOrderCommand); + orderService.createOrder(buyOrderCommand); +// orderService.createOrder(sellOrderCommand); + } catch (Exception e) { + System.out.println(e.getMessage()); + } + finally { + endLatch.countDown(); + } + }); + } + + startLatch.countDown(); + + endLatch.await(); + // 비동기적으로 처리되는 체결이 완료될때까지 대기 + Thread.sleep(2000); + + long resultCount = tradeRepository.count(); + long buyOrderCount = buyOrderRepository.count(); + long sellOrderCount = sellOrderRepository.count(); + + System.out.println(buyOrderCount); + System.out.println(sellOrderCount); +// extracted(); + assertEquals(5, resultCount); + } + + @Transactional(propagation = Propagation.REQUIRES_NEW) + protected void extracted() { + Account account = accountRepository.findByUserId(CommonValues.BUY_ORDER_BOT_ID).get(); + System.out.println(account.getCash()); + } +} diff --git a/src/test/java/com/cleanengine/coin/order/integration/buyorder/BuyOrderIntegrationTest.java b/src/test/java/com/cleanengine/coin/order/integration/buyorder/BuyOrderIntegrationTest.java index 7c4e6a81..538e25f7 100644 --- a/src/test/java/com/cleanengine/coin/order/integration/buyorder/BuyOrderIntegrationTest.java +++ b/src/test/java/com/cleanengine/coin/order/integration/buyorder/BuyOrderIntegrationTest.java @@ -1,13 +1,12 @@ package com.cleanengine.coin.order.integration.buyorder; import com.cleanengine.coin.common.error.DomainValidationException; -import com.cleanengine.coin.order.adapter.out.persistentce.account.OrderAccountRepository; -import com.cleanengine.coin.order.adapter.out.persistentce.wallet.OrderWalletRepository; import com.cleanengine.coin.order.application.OrderService; import com.cleanengine.coin.order.application.dto.OrderCommand; import com.cleanengine.coin.order.application.dto.OrderInfo; import com.cleanengine.coin.user.domain.Account; -import com.cleanengine.coin.user.domain.Wallet; +import com.cleanengine.coin.user.info.infra.AccountRepository; +import com.cleanengine.coin.user.info.infra.WalletRepository; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -27,10 +26,10 @@ public class BuyOrderIntegrationTest { OrderService orderService; @Autowired - OrderAccountRepository orderAccountRepository; + AccountRepository accountRepository; @Autowired - OrderWalletRepository orderWalletRepository; + WalletRepository walletRepository; //TODO 3,2가 예약어로 사용하는 만큼 1을 insert하는 테스트가 깨질 수 있다. 또한, sql로 초기화보다 EntityManager나 Repository로 초기화하는게 나은듯 @DisplayName("충분한 돈이 있는 유저가 시장가 매수주문 생성시 주문이 정상 생성됨.") @@ -41,7 +40,7 @@ void givenEnoughMoneyUser_WhenCreateMarketBuyOrder_ThenBuyOrderIsCreated() { true, true, null, 30.0, LocalDateTime.now(),false); OrderInfo.BuyOrderInfo buyOrderInfo = (OrderInfo.BuyOrderInfo) orderService.createOrder(command); - Account account = orderAccountRepository.findByUserId(3).orElseThrow(); + Account account = accountRepository.findByUserId(3).orElseThrow(); assertNotNull(buyOrderInfo.getId()); assertEquals(200000-30.0, account.getCash()); @@ -55,7 +54,7 @@ void givenEnoughMoneyUser_WhenCreateLimitBuyOrder_ThenSellOrderIsCreated() { true, false, 30.0, 40.0, LocalDateTime.now(),false); OrderInfo.BuyOrderInfo buyOrderInfo = (OrderInfo.BuyOrderInfo) orderService.createOrder(command); - Account account = orderAccountRepository.findByUserId(3).orElseThrow(); + Account account = accountRepository.findByUserId(3).orElseThrow(); assertNotNull(buyOrderInfo.getId()); assertEquals(200000-30.0*40.0, account.getCash()); @@ -107,18 +106,4 @@ void givenCommandWithoutOrderSize_WhenCreateLimitBuyOrder_ThenExceptionIsThrown( assertThrows(DomainValidationException.class, () -> orderService.createOrder(command)); } - - @DisplayName("Wallet이 없는 사용자가 주문 요청을 할 경우 Wallet이 생성된다.") - @Sql("classpath:db/user/user_without_wallet.sql") - @Test - void givenUserWithoutWallet_WhenCreateOrder_ThenWalletIsCreated() { - OrderCommand.CreateOrder command = new OrderCommand.CreateOrder("BTC", 3, - true, false, 30.0, 40.0, LocalDateTime.now(),false); - - orderService.createOrder(command); - - Wallet wallet = orderWalletRepository.findWalletBy(3, "BTC").orElseThrow(); - assertNotNull(wallet); - assertEquals("BTC", wallet.getTicker()); - } } diff --git a/src/test/java/com/cleanengine/coin/order/integration/sellorder/SellOrderIntegrationTest.java b/src/test/java/com/cleanengine/coin/order/integration/sellorder/SellOrderIntegrationTest.java index 90aaf4be..d13bd365 100644 --- a/src/test/java/com/cleanengine/coin/order/integration/sellorder/SellOrderIntegrationTest.java +++ b/src/test/java/com/cleanengine/coin/order/integration/sellorder/SellOrderIntegrationTest.java @@ -1,11 +1,11 @@ package com.cleanengine.coin.order.integration.sellorder; import com.cleanengine.coin.common.error.DomainValidationException; -import com.cleanengine.coin.order.adapter.out.persistentce.wallet.OrderWalletRepository; import com.cleanengine.coin.order.application.OrderService; import com.cleanengine.coin.order.application.dto.OrderCommand; import com.cleanengine.coin.order.application.dto.OrderInfo; import com.cleanengine.coin.user.domain.Wallet; +import com.cleanengine.coin.user.info.infra.WalletRepository; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -25,7 +25,7 @@ public class SellOrderIntegrationTest { OrderService orderService; @Autowired - OrderWalletRepository orderWalletRepository; + WalletRepository walletRepository; @DisplayName("충분한 가상화폐가 있는 유저가 시장가 매도주문 생성시 주문이 생성됨.") @Sql("classpath:db/user/user_enough_holdings.sql") @@ -35,7 +35,7 @@ void givenEnoughMoneyUser_WhenCreateMarketSellOrder_ThenSellOrderIsCreated() { false, true, 30.0, null, LocalDateTime.now(),false); OrderInfo.SellOrderInfo sellOrderInfo = (OrderInfo.SellOrderInfo) orderService.createOrder(command); - Wallet wallet = orderWalletRepository.findWalletBy(3, "BTC").orElseThrow(); + Wallet wallet = walletRepository.findByAccountIdAndTicker(3, "BTC").orElseThrow(); assertNotNull(sellOrderInfo.getId()); assertEquals(200000-30.0, wallet.getSize()); @@ -49,7 +49,7 @@ void givenEnoughMoneyUser_WhenCreateLimitSellOrder_ThenSellOrderIsCreated() { false, false, 30.0, 40.0, LocalDateTime.now(),false); OrderInfo.SellOrderInfo sellOrderInfo = (OrderInfo.SellOrderInfo) orderService.createOrder(command); - Wallet wallet = orderWalletRepository.findWalletBy(3, "BTC").orElseThrow(); + Wallet wallet = walletRepository.findByAccountIdAndTicker(3, "BTC").orElseThrow(); assertNotNull(sellOrderInfo.getId()); assertEquals(200000-30.0, wallet.getSize()); @@ -101,22 +101,4 @@ void givenCommandWithoutOrderSize_WhenCreateLimitSellOrder_ThenExceptionIsThrown assertThrows(DomainValidationException.class, () -> orderService.createOrder(command)); } - - @DisplayName("Wallet이 없는 사용자가 주문 요청을 할 경우 Wallet이 생성된다.") - @Sql("classpath:db/user/user_without_wallet.sql") - @Test - void givenUserWithoutWallet_WhenCreateOrder_ThenWalletIsCreated() { - OrderCommand.CreateOrder command = new OrderCommand.CreateOrder("BTC", 3, - false, false, 30.0, 40.0, LocalDateTime.now(),false); - - try{ - orderService.createOrder(command); - } catch (Exception e) { - System.out.println(e.getMessage()); - } - - Wallet wallet = orderWalletRepository.findWalletBy(3, "BTC").orElseThrow(); - assertNotNull(wallet); - assertEquals("BTC", wallet.getTicker()); - } } diff --git a/src/test/java/com/cleanengine/coin/trade/application/TradeExecuteLoadTest.java b/src/test/java/com/cleanengine/coin/trade/application/TradeExecuteLoadTest.java index dd4aa933..733a3a38 100644 --- a/src/test/java/com/cleanengine/coin/trade/application/TradeExecuteLoadTest.java +++ b/src/test/java/com/cleanengine/coin/trade/application/TradeExecuteLoadTest.java @@ -1,90 +1,210 @@ package com.cleanengine.coin.trade.application; import com.cleanengine.coin.common.domain.port.PriorityQueueStore; -import com.cleanengine.coin.order.application.OrderService; -import com.cleanengine.coin.order.application.dto.OrderCommand; +import com.cleanengine.coin.order.adapter.out.persistentce.order.command.BuyOrderRepository; +import com.cleanengine.coin.order.adapter.out.persistentce.order.command.SellOrderRepository; +import com.cleanengine.coin.order.application.event.OrderInsertedToQueue; import com.cleanengine.coin.order.domain.BuyOrder; import com.cleanengine.coin.order.domain.OrderType; import com.cleanengine.coin.order.domain.SellOrder; import com.cleanengine.coin.order.domain.spi.WaitingOrders; import com.cleanengine.coin.order.domain.spi.WaitingOrdersManager; import com.cleanengine.coin.trade.repository.TradeRepository; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Disabled; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.*; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.ApplicationArguments; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.test.context.ActiveProfiles; import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; -@SpringBootTest +@ActiveProfiles({"dev", "it", "h2-mem"}) @Disabled +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +@SpringBootTest class TradeExecuteLoadTest { @Autowired - TradeBatchProcessor tradeBatchProcessor; + private ApplicationEventPublisher eventPublisher; @Autowired - ApplicationArguments applicationArguments; + WaitingOrdersManager waitingOrdersManager; @Autowired - OrderService orderService; + TradeRepository tradeRepository; @Autowired - WaitingOrdersManager waitingOrdersManager; + SellOrderRepository sellOrderRepository; @Autowired - TradeRepository tradeRepository; + BuyOrderRepository buyOrderRepository; private final String ticker = "BTC"; + @DisplayName("워밍업: Spring 컨텍스트 및 JVM 최적화") + @Order(1) + @Test + void warmUp() throws InterruptedException { + runSingleTest(1000); + runSingleTest(10000); + runSingleTest(50000); + } + @BeforeEach void setUp() { - tradeBatchProcessor.shutdown(); - waitingOrdersManager.getWaitingOrders(ticker); - // TODO : 티커마다 큐, DB 초기화 + WaitingOrders waitingOrders = waitingOrdersManager.getWaitingOrders(ticker); + waitingOrders.clearAllQueues(); + tradeRepository.deleteAll(); + sellOrderRepository.deleteAll(); + buyOrderRepository.deleteAll(); + } + @DisplayName("매수, 매도 각 1000건에 대한 처리 성능을 10회 진행한다.") + @Order(2) + @Test + void basicLoadTestWith1000OrdersEachSide() throws InterruptedException { + // given + int orderCount = 1000; + int repeatCount = 10; + List executionTimes = new ArrayList<>(); + List queueInsertTimes = new ArrayList<>(); + + // when + for (int i = 0; i < repeatCount; ++i) { + long[] times = runSingleTest(orderCount); + queueInsertTimes.add(times[0]); + executionTimes.add(times[1]); + System.out.printf("Run-%d: 큐 삽입 소요시간 = %d ms, 체결 소요시간 = %d ms%n", (i + 1), times[0], times[1]); + } + + // 통계 출력 + printStatistics(queueInsertTimes, executionTimes, orderCount); } - @DisplayName("1000건의 매수 매도 주문을 요청 후 처리 성능을 조회한다.") + @DisplayName("매수, 매도 각 10000건에 대한 처리 성능을 10회 진행한다.") + @Order(3) @Test - void basicLoadTestWith1000OrdersEachSide() { - // given 1000건의 매수, 매도 주문 요청 - for (int i = 0; i < 1000; i++) { - OrderCommand.CreateOrder sellOrderCommand = new OrderCommand.CreateOrder(ticker, 1, - false, false, 30.0, 40.0, LocalDateTime.now(),false); - orderService.createOrder(sellOrderCommand); - - OrderCommand.CreateOrder buyOrderCommand = new OrderCommand.CreateOrder(ticker, 2, - true, false, 30.0, 40.0, LocalDateTime.now(),false); - orderService.createOrder(buyOrderCommand); + void basicLoadTestWith10000OrdersEachSide() throws InterruptedException { + // given + int orderCount = 10000; + int repeatCount = 10; + List executionTimes = new ArrayList<>(); + List queueInsertTimes = new ArrayList<>(); + + // when + for (int i = 0; i < repeatCount; ++i) { + long[] times = runSingleTest(orderCount); + queueInsertTimes.add(times[0]); + executionTimes.add(times[1]); + System.out.printf("Run-%d: 큐 삽입 소요시간 = %d ms, 체결 소요시간 = %d ms%n", (i + 1), times[0], times[1]); } - WaitingOrders waitingOrders = waitingOrdersManager.getWaitingOrders(ticker); - PriorityQueueStore buyOrderPriorityQueueStore = waitingOrders.getBuyOrderPriorityQueueStore(OrderType.LIMIT); - PriorityQueueStore sellOrderPriorityQueueStore = waitingOrders.getSellOrderPriorityQueueStore(OrderType.LIMIT); - System.out.println("buyOrderPriorityQueueStore.size() : " + buyOrderPriorityQueueStore.size()); - System.out.println("sellOrderPriorityQueueStore.size() : " + sellOrderPriorityQueueStore.size()); - long testStart = System.currentTimeMillis(); + // 통계 출력 + printStatistics(queueInsertTimes, executionTimes, orderCount); + } + + @DisplayName("매수, 매도 각 100000건에 대한 처리 성능을 10회 진행한다.") + @Order(4) + @Test + void basicLoadTestWith100000OrdersEachSide() throws InterruptedException { + // given + int orderCount = 100000; + int repeatCount = 10; + List executionTimes = new ArrayList<>(); + List queueInsertTimes = new ArrayList<>(); // when - tradeBatchProcessor.run(applicationArguments); - try { - Thread.sleep(1000); - } catch (InterruptedException e) { - throw new RuntimeException(e); + for (int i = 0; i < repeatCount; ++i) { + long[] times = runSingleTest(orderCount); + queueInsertTimes.add(times[0]); + executionTimes.add(times[1]); + System.out.printf("Run-%d: 큐 삽입 소요시간 = %d ms, 체결 소요시간 = %d ms%n", (i + 1), times[0], times[1]); + } + + // 통계 출력 + printStatistics(queueInsertTimes, executionTimes, orderCount); + } + + private long[] runSingleTest(int orderCount) throws InterruptedException { + WaitingOrders waitingOrders = waitingOrdersManager.getWaitingOrders(ticker); + long testStart = System.nanoTime(); + + // 주문 생성 및 큐 삽입 + ExecutorService executor = Executors.newFixedThreadPool(10); + for (int i = 0; i < orderCount; i++) { + executor.submit(() -> { + SellOrder limitSellOrder = SellOrder.createLimitSellOrder(ticker, 1, 10.0, 130_000_000.0, LocalDateTime.now(), true); + BuyOrder limitBuyOrder = BuyOrder.createLimitBuyOrder(ticker, 2, 10.0, 130_000_000.0, LocalDateTime.now(), true); + waitingOrders.addOrder(limitSellOrder); + waitingOrders.addOrder(limitBuyOrder); + }); + } + + // 큐 삽입 완료 대기 + executor.shutdown(); + boolean queueTerminated = executor.awaitTermination(10, TimeUnit.SECONDS); + long queueInsertEnd = System.nanoTime(); + long queueInsertTime = (queueInsertEnd - testStart) / 1_000_000; + + // 단일 이벤트 발행 (체결 시작) + long eventStart = System.nanoTime(); + SellOrder dummyOrder = SellOrder.createLimitSellOrder(ticker, 1, 10.0, 130_000_000.0, LocalDateTime.now(), true); + eventPublisher.publishEvent(new OrderInsertedToQueue(dummyOrder)); + + long eventEnd = System.nanoTime(); + long executionTime = (eventEnd - eventStart) / 1_000_000; + + // 결과 출력 + if (tradeRepository.findAll().size() != orderCount) { + PriorityQueueStore buyOrderPriorityQueueStore = waitingOrders.getBuyOrderPriorityQueueStore(OrderType.LIMIT); + PriorityQueueStore sellOrderPriorityQueueStore = waitingOrders.getSellOrderPriorityQueueStore(OrderType.LIMIT); + System.out.print("체결 종료 - 체결내역[: " + tradeRepository.findAll().size() + "건]"); + System.out.println("잔여 주문[매도 " + sellOrderPriorityQueueStore.size() + "건, 매수 " + buyOrderPriorityQueueStore.size() + "건]"); } - // then - tradeBatchProcessor.shutdown(); - long testEnd = System.currentTimeMillis(); + // 큐와 DB 초기화 + waitingOrders.clearAllQueues(); + tradeRepository.deleteAll(); + sellOrderRepository.deleteAll(); + buyOrderRepository.deleteAll(); + + return new long[]{queueInsertTime, executionTime}; + } - System.out.println("trade table size : " + tradeRepository.findAll().size()); + private void printStatistics(List queueInsertTimes, List executionTimes, int orderCount) { + // 큐 삽입 시간 통계 + double queueAvg = queueInsertTimes.stream().mapToLong(Long::longValue).average().orElse(0.0); + long queueMin = queueInsertTimes.stream().mapToLong(Long::longValue).min().orElse(0); + long queueMax = queueInsertTimes.stream().mapToLong(Long::longValue).max().orElse(0); + double queueStdDev = calculateStdDev(queueInsertTimes, queueAvg); + + // 체결 시간 통계 + double executionAvg = executionTimes.stream().mapToLong(Long::longValue).average().orElse(0.0); + long executionMin = executionTimes.stream().mapToLong(Long::longValue).min().orElse(0); + long executionMax = executionTimes.stream().mapToLong(Long::longValue).max().orElse(0); + double executionStdDev = calculateStdDev(executionTimes, executionAvg); + + // 처리량 통계 (체결 시간 기반) + double throughputAvg = (orderCount * 2) / (executionAvg / 1000.0); + double throughputMin = (orderCount * 2) / (executionMax / 1000.0); + double throughputMax = (orderCount * 2) / (executionMin / 1000.0); + + System.out.printf("=== 통계 결과 ===%n"); + System.out.printf("큐 삽입 시간 - 평균: %.2f ms, 최소: %d ms, 최대: %d ms, 표준편차: %.2f ms%n", + queueAvg, queueMin, queueMax, queueStdDev); + System.out.printf("체결 시간 - 평균: %.2f ms, 최소: %d ms, 최대: %d ms, 표준편차: %.2f ms%n", + executionAvg, executionMin, executionMax, executionStdDev); + System.out.printf("처리량 - 평균: %.2f orders/sec, 최소: %.2f orders/sec, 최대: %.2f orders/sec%n", + throughputAvg, throughputMin, throughputMax); + } - System.out.println("test time : " + (testEnd - testStart) + " ms"); - System.out.println("buyOrderPriorityQueueStore.size() : " + buyOrderPriorityQueueStore.size()); - System.out.println("sellOrderPriorityQueueStore.size() : " + sellOrderPriorityQueueStore.size()); + private double calculateStdDev(List times, double mean) { + double sum = times.stream().mapToDouble(t -> Math.pow(t - mean, 2)).sum(); + return Math.sqrt(sum / times.size()); } } diff --git a/src/test/java/com/cleanengine/coin/trade/application/TradeFlowServiceIntegrationTest.java b/src/test/java/com/cleanengine/coin/trade/application/TradeFlowServiceIntegrationTest.java index fd75eb3a..5ba177d0 100644 --- a/src/test/java/com/cleanengine/coin/trade/application/TradeFlowServiceIntegrationTest.java +++ b/src/test/java/com/cleanengine/coin/trade/application/TradeFlowServiceIntegrationTest.java @@ -27,8 +27,6 @@ @Disabled class TradeFlowServiceIntegrationTest { - private static TradeBatchProcessor staticTradeBatchProcessor; - private static final double MINIMUM_ORDER_SIZE = 0.00000001; @Autowired @@ -38,8 +36,6 @@ class TradeFlowServiceIntegrationTest { @Autowired TradeRepository tradeRepository; @Autowired - TradeBatchProcessor tradeBatchProcessor; - @Autowired private WaitingOrdersManager waitingOrdersManager; private final String ticker = "BTC"; @@ -47,9 +43,6 @@ class TradeFlowServiceIntegrationTest { @BeforeEach void setUp() { - if (staticTradeBatchProcessor == null) { - staticTradeBatchProcessor = tradeBatchProcessor; - } WaitingOrders waitingOrders = waitingOrdersManager.getWaitingOrders(ticker); waitingOrders.clearAllQueues(); tradeRepository.deleteAll(); @@ -57,11 +50,6 @@ void setUp() { sellOrderRepository.deleteAll(); } - @AfterAll - static void cleanup() { - staticTradeBatchProcessor.shutdown(); - } - // TODO : 모든 케이스에서 각 객체의 값까지 정합성이 맞는지 테스트 필요 @DisplayName("지정가매수-지정가매도 완전체결") diff --git a/src/test/java/com/cleanengine/coin/trade/application/TradeQueueManagerTest.java b/src/test/java/com/cleanengine/coin/trade/application/TradeQueueManagerTest.java deleted file mode 100644 index bce3c445..00000000 --- a/src/test/java/com/cleanengine/coin/trade/application/TradeQueueManagerTest.java +++ /dev/null @@ -1,80 +0,0 @@ -package com.cleanengine.coin.trade.application; - -import ch.qos.logback.classic.spi.ILoggingEvent; -import ch.qos.logback.core.read.ListAppender; -import com.cleanengine.coin.order.domain.spi.WaitingOrders; -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.slf4j.LoggerFactory; -import ch.qos.logback.classic.Logger; -import ch.qos.logback.classic.Level; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.Mockito.*; - -class TradeQueueManagerTest { - - private ListAppender listAppender; - private Logger tradeQueueManagerLogger; - - @BeforeEach - void setUp() { - // TradeQueueManager 클래스의 로거를 가져옵니다. - tradeQueueManagerLogger = (Logger) LoggerFactory.getLogger(TradeQueueManager.class); - - // 로그 이벤트를 캡처하기 위한 ListAppender를 설정합니다. - listAppender = new ListAppender<>(); - // ListAppender가 올바르게 동작하기 위해 LoggerContext를 설정하는 것이 중요합니다. - listAppender.setContext((ch.qos.logback.classic.LoggerContext) LoggerFactory.getILoggerFactory()); - listAppender.start(); - - // 설정한 Appender를 로거에 추가합니다. - tradeQueueManagerLogger.addAppender(listAppender); - // ERROR 레벨의 로그만 캡처하도록 설정합니다 (테스트 대상이 ERROR 로그이므로). - tradeQueueManagerLogger.setLevel(Level.ERROR); - } - - @AfterEach - void tearDown() { - // 테스트 후 Appender를 정리하여 다른 테스트에 영향을 주지 않도록 합니다. - if (tradeQueueManagerLogger != null && listAppender != null) { - tradeQueueManagerLogger.detachAppender(listAppender); - listAppender.stop(); - } - } - - @DisplayName("체결 엔진 동작 중 예외 발생 시 catch 후 로깅되어야 한다.") - @Test - void catchExceptionWhenExecMatchAndTrade() { - // given - String ticker = "BTC"; - String errorMessage = "예외 발생"; - TradeFlowService mockTradeFlowService = mock(TradeFlowService.class); - WaitingOrders mockWaitingOrders = mock(WaitingOrders.class); - - when(mockWaitingOrders.getTicker()).thenReturn(ticker); - - TradeQueueManager tradeQueueManager = new TradeQueueManager(mockTradeFlowService); - - doAnswer(invocation -> { - throw new RuntimeException(errorMessage); - }).when(mockTradeFlowService).execMatchAndTrade(ticker); - - // when, then -// tradeQueueManager.run(); - - // then -// verify(mockTradeFlowService, times(1)).execMatchAndTrade(ticker); -// -// assertThat(listAppender.list).hasSize(1); -// ILoggingEvent loggingEvent = listAppender.list.get(0); -// -// assertThat(loggingEvent.getLevel()).isEqualTo(Level.ERROR); -// assertThat(loggingEvent.getFormattedMessage()) -// .isEqualTo("Error processing trades for " + ticker + ": " + errorMessage); - - } - -} \ No newline at end of file diff --git a/src/test/java/com/cleanengine/coin/user/login/application/CustomOAuth2UserServiceTest.java b/src/test/java/com/cleanengine/coin/user/login/application/CustomOAuth2UserServiceTest.java index 13c3f7b1..c2e1f850 100644 --- a/src/test/java/com/cleanengine/coin/user/login/application/CustomOAuth2UserServiceTest.java +++ b/src/test/java/com/cleanengine/coin/user/login/application/CustomOAuth2UserServiceTest.java @@ -4,6 +4,7 @@ import com.cleanengine.coin.user.domain.OAuth; import com.cleanengine.coin.user.domain.User; import com.cleanengine.coin.user.info.application.AccountService; +import com.cleanengine.coin.user.info.application.WalletService; import com.cleanengine.coin.user.login.infra.CustomOAuth2User; import com.cleanengine.coin.user.login.infra.UserOAuthDetails; import com.cleanengine.coin.user.info.infra.OAuthRepository; @@ -42,6 +43,8 @@ class CustomOAuth2UserServiceTest { @Mock private AccountService accountService; @Mock + private WalletService walletService; + @Mock private OAuth2UserRequest userRequest; @Mock private OAuth2User oAuth2UserFromSuper; @@ -57,7 +60,7 @@ class CustomOAuth2UserServiceTest { @BeforeEach void setUp() { // DefaultOAuth2UserService.loadUser만 mocking하기 위해 spy 사용 - customOAuth2UserService = Mockito.spy(new CustomOAuth2UserService(userRepository, oAuthRepository, accountService)); + customOAuth2UserService = Mockito.spy(new CustomOAuth2UserService(userRepository, oAuthRepository, accountService, walletService)); Map profile = Map.of("nickname", "Test User"); Map kakaoAccount = Map.of( diff --git a/src/test/resources/com/cleanengine/coin/order/application/initializeBotUser.sql b/src/test/resources/com/cleanengine/coin/order/application/initializeBotUser.sql new file mode 100644 index 00000000..420258ea --- /dev/null +++ b/src/test/resources/com/cleanengine/coin/order/application/initializeBotUser.sql @@ -0,0 +1,12 @@ +INSERT INTO asset(ticker, name) VALUES ('BTC', '비트코인'); + +INSERT INTO users (user_id, created_at) VALUES (1, '2025-05-16 09:30:00.000000'); +INSERT INTO users (user_id, created_at) VALUES (2, '2025-05-16 09:30:00.000000'); + +INSERT INTO account (account_id, cash, user_id) VALUES (1, 0, 1); +INSERT INTO account (account_id, cash, user_id) VALUES (2, 500000000, 2); + +INSERT INTO wallet (wallet_id, account_id, buy_price, roi, size, ticker) VALUES (1, 1, 0, 0, 500000000, 'BTC'); +INSERT INTO wallet (wallet_id, account_id, buy_price, roi, size, ticker) VALUES (2, 1, 0, 0, 500000000, 'TRUMP'); +INSERT INTO wallet (wallet_id, account_id, buy_price, roi, size, ticker) VALUES (3, 2, 0, 0, 500000000, 'BTC'); +INSERT INTO wallet (wallet_id, account_id, buy_price, roi, size, ticker) VALUES (4, 2, 0, 0, 500000000, 'TRUMP');