From 0d6667dde5c9d776c7ee235c61ce20f6b9069633 Mon Sep 17 00:00:00 2001 From: caniro Date: Sun, 8 Jun 2025 20:58:49 +0900 Subject: [PATCH 01/17] =?UTF-8?q?test:=20=EC=84=B1=EB=8A=A5=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=ED=99=98=EA=B2=BD=20=EA=B5=AC=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/TradeExecuteLoadTest.java | 210 ++++++++++++++---- 1 file changed, 165 insertions(+), 45 deletions(-) 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()); } } From bd56c24e04ae6918f7ec0a1af19757cc2d08f105 Mon Sep 17 00:00:00 2001 From: caniro Date: Wed, 11 Jun 2025 15:16:34 +0900 Subject: [PATCH 02/17] =?UTF-8?q?feat:=20=EC=B2=B4=EA=B2=B0=20=ED=99=95?= =?UTF-8?q?=EC=A0=95=20=EC=88=98=EB=9F=89=EC=9D=B4=200=EC=9D=B8=20?= =?UTF-8?q?=EA=B2=BD=EC=9A=B0,=20=EB=AC=B8=EC=A0=9C=EA=B0=80=20=EB=90=98?= =?UTF-8?q?=EB=8A=94=20=EC=A3=BC=EB=AC=B8=EC=9D=80=20=ED=81=90=EC=97=90?= =?UTF-8?q?=EC=84=9C=20=EC=A0=9C=EA=B1=B0=EB=90=98=EB=8F=84=EB=A1=9D=20?= =?UTF-8?q?=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../coin/trade/application/TradeExecutor.java | 8 +++++--- .../coin/trade/application/TradeFlowService.java | 4 +++- .../application/TradeZeroOrderException.java | 16 ++++++++++++++++ 3 files changed, 24 insertions(+), 4 deletions(-) create mode 100644 src/main/java/com/cleanengine/coin/trade/application/TradeZeroOrderException.java 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..fdc96014 100644 --- a/src/main/java/com/cleanengine/coin/trade/application/TradeExecutor.java +++ b/src/main/java/com/cleanengine/coin/trade/application/TradeExecutor.java @@ -48,8 +48,10 @@ 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 ; + Order zeroOrder = approxEquals(buyOrder.getRemainingSize(), 0.0) ? buyOrder : sellOrder; + throw new TradeZeroOrderException(String.format("체결 중단! 체결 시도 수량 : %s, 매수단가 : %s, 매도단가 : %s", + tradedSize, buyOrder.getPrice(), sellOrder.getPrice()), + zeroOrder); } this.writeTradingLog(buyOrder, sellOrder); @@ -196,4 +198,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..e32fe8d1 100644 --- a/src/main/java/com/cleanengine/coin/trade/application/TradeFlowService.java +++ b/src/main/java/com/cleanengine/coin/trade/application/TradeFlowService.java @@ -29,8 +29,10 @@ 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); } catch (Exception e) { - // TODO : 회복 필요 log.error("Error processing trades for {}: {}", ticker, e.getMessage()); continueProcessing = false; } 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; + } + +} From 3626640d1fd140478cbba352f42673e91dcdb134 Mon Sep 17 00:00:00 2001 From: caniro Date: Wed, 11 Jun 2025 15:20:10 +0900 Subject: [PATCH 03/17] =?UTF-8?q?chore:=20=EB=B6=88=ED=95=84=EC=9A=94=20?= =?UTF-8?q?=ED=81=B4=EB=9E=98=EC=8A=A4=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../coin/trade/service/TradeService.java | 24 --------- .../coin/trade/service/TradeServiceImpl.java | 53 ------------------- 2 files changed, 77 deletions(-) delete mode 100644 src/main/java/com/cleanengine/coin/trade/service/TradeService.java delete mode 100644 src/main/java/com/cleanengine/coin/trade/service/TradeServiceImpl.java 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 From b9ce7291cd71921e3040a26c232253c323cad74f Mon Sep 17 00:00:00 2001 From: caniro Date: Wed, 11 Jun 2025 15:38:09 +0900 Subject: [PATCH 04/17] =?UTF-8?q?feat:=20=EC=A2=85=EB=AA=A9=EC=9D=84=20DB?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=EC=A1=B0=ED=9A=8C=ED=95=98=EB=8F=84?= =?UTF-8?q?=EB=A1=9D=20=EB=B3=80=EA=B2=BD(=EC=95=84=EC=A7=81=20=EC=82=AC?= =?UTF-8?q?=EC=9A=A9=EB=90=98=EB=8A=94=20=EB=8B=A8=EA=B3=84=EB=8A=94=20?= =?UTF-8?q?=EC=95=84=EB=8B=88=EC=A7=80=EB=A7=8C=20=EC=84=A0=EB=B0=98?= =?UTF-8?q?=EC=98=81)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../coin/order/application/AssetService.java | 4 ++++ .../coin/trade/application/TradeBatchProcessor.java | 13 +++++++------ 2 files changed, 11 insertions(+), 6 deletions(-) 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/trade/application/TradeBatchProcessor.java b/src/main/java/com/cleanengine/coin/trade/application/TradeBatchProcessor.java index 4dbffceb..14657d8e 100644 --- a/src/main/java/com/cleanengine/coin/trade/application/TradeBatchProcessor.java +++ b/src/main/java/com/cleanengine/coin/trade/application/TradeBatchProcessor.java @@ -1,11 +1,9 @@ package com.cleanengine.coin.trade.application; -import com.cleanengine.coin.order.domain.spi.WaitingOrdersManager; +import com.cleanengine.coin.order.application.AssetService; 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; @@ -21,18 +19,21 @@ @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; + private final List tickers; + + public TradeBatchProcessor(TradeFlowService tradeFlowService, AssetService assetService) { + this.tradeFlowService = tradeFlowService; + tickers = assetService.getAllAssetTickers(); + } @Override public void run(ApplicationArguments args) { From 6fd5caab2abb27947ff567738a00cf99e8d45384 Mon Sep 17 00:00:00 2001 From: caniro Date: Wed, 11 Jun 2025 16:40:42 +0900 Subject: [PATCH 05/17] =?UTF-8?q?fix:=20=EC=B2=B4=EA=B2=B0=20=EC=98=88?= =?UTF-8?q?=EC=99=B8=20=EC=8B=9C=20=EB=B3=B5=EA=B5=AC=20=ED=9B=84=20?= =?UTF-8?q?=EB=8B=A4=EC=9D=8C=20=EC=8C=8D=EC=9D=84=20=EB=A7=A4=EC=B9=AD?= =?UTF-8?q?=ED=95=98=EB=8F=84=EB=A1=9D=20=EB=A1=9C=EC=A7=81=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../coin/trade/application/TradeExecutor.java | 19 +++++++++++++++---- .../trade/application/TradeFlowService.java | 6 +++++- 2 files changed, 20 insertions(+), 5 deletions(-) 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 fdc96014..4bf1fd9f 100644 --- a/src/main/java/com/cleanengine/coin/trade/application/TradeExecutor.java +++ b/src/main/java/com/cleanengine/coin/trade/application/TradeExecutor.java @@ -38,6 +38,8 @@ public class TradeExecutor { public void executeTrade(WaitingOrders waitingOrders, TradePair tradePair, String ticker) { BuyOrder buyOrder = tradePair.getBuyOrder(); SellOrder sellOrder = tradePair.getSellOrder(); + log.debug("{} - 체결 시작: 매수[{} {}원 {}개] / 매도[{} {}원 {}개]", ticker, buyOrder.getId(), buyOrder.getPrice(), buyOrder.getRemainingSize(), + sellOrder.getId(), sellOrder.getPrice(), sellOrder.getRemainingSize()); double tradedPrice; double tradedSize; @@ -48,10 +50,7 @@ public void executeTrade(WaitingOrders waitingOrders, TradePair tr tradedSize = tradeUnitPriceAndSize.tradedSize(); tradedPrice = tradeUnitPriceAndSize.tradedPrice(); if (approxEquals(tradedSize, 0.0)) { - Order zeroOrder = approxEquals(buyOrder.getRemainingSize(), 0.0) ? buyOrder : sellOrder; - throw new TradeZeroOrderException(String.format("체결 중단! 체결 시도 수량 : %s, 매수단가 : %s, 매도단가 : %s", - tradedSize, buyOrder.getPrice(), sellOrder.getPrice()), - zeroOrder); + this.checkZeroOrderAndThrowException(buyOrder, sellOrder); } this.writeTradingLog(buyOrder, sellOrder); @@ -91,6 +90,18 @@ public void executeTrade(WaitingOrders waitingOrders, TradePair tr tradeExecutedEventPublisher.publish(tradeExecutedEvent); } + private 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 Account increaseAccountCash(Order order, Double amount) { Account account = accountService.findAccountByUserId(order.getUserId()).orElseThrow(); return accountService.save(account.increaseCash(amount)); 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 e32fe8d1..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; @@ -32,8 +33,11 @@ public void execMatchAndTrade(String ticker) { } 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) { - log.error("Error processing trades for {}: {}", ticker, e.getMessage()); + log.error("{} - 체결 에러 발생: {}", ticker, e.getMessage()); continueProcessing = false; } } From a65d00199b8ec21e0589821b107d72aa62c9fcfe Mon Sep 17 00:00:00 2001 From: Junh-b Date: Sun, 8 Jun 2025 18:26:18 +0900 Subject: [PATCH 06/17] config: add gitignore MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit local용 gitignore추가 --- .gitignore | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 9b820d83..4e767dee 100644 --- a/.gitignore +++ b/.gitignore @@ -38,4 +38,7 @@ out/ ### 로컬 환경변수 ### local.properties -/logs \ No newline at end of file +/logs +docker-compose.override.yml + +dd-java-agent.jar \ No newline at end of file From e4d5773c974c69b612a411f1c6d7e1b5212c3c96 Mon Sep 17 00:00:00 2001 From: Junh-b Date: Mon, 9 Jun 2025 10:22:37 +0900 Subject: [PATCH 07/17] =?UTF-8?q?feat:=20=EC=B2=B4=EA=B2=B0=20=EB=8B=A8?= =?UTF-8?q?=EC=9D=BC=EC=8A=A4=EB=A0=88=EB=93=9C=20=EC=B2=98=EB=A6=AC?= =?UTF-8?q?=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 단일 종목에 대해 여러 주문들이 동시에 들어온다고 하더라도, 체결은 종목마다 단일 스레드에서 처리되도록 수정했습니다. --- .../trade/application/TradeQueueManager.java | 4 +- ...rderInsertedQueueStartMatchingHandler.java | 47 +++++++++++++++++++ 2 files changed, 50 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/cleanengine/coin/trade/event/OrderInsertedQueueStartMatchingHandler.java diff --git a/src/main/java/com/cleanengine/coin/trade/application/TradeQueueManager.java b/src/main/java/com/cleanengine/coin/trade/application/TradeQueueManager.java index 0b198e55..21d18922 100644 --- a/src/main/java/com/cleanengine/coin/trade/application/TradeQueueManager.java +++ b/src/main/java/com/cleanengine/coin/trade/application/TradeQueueManager.java @@ -3,12 +3,14 @@ import com.cleanengine.coin.order.application.event.OrderInsertedToQueue; import lombok.extern.slf4j.Slf4j; import org.springframework.context.event.EventListener; +import org.springframework.core.annotation.Order; import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Component; import org.springframework.transaction.event.TransactionalEventListener; @Slf4j -@Component +@Order(4) +//@Component public class TradeQueueManager { private final TradeFlowService tradeFlowService; 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..191ec2cf --- /dev/null +++ b/src/main/java/com/cleanengine/coin/trade/event/OrderInsertedQueueStartMatchingHandler.java @@ -0,0 +1,47 @@ +package com.cleanengine.coin.trade.event; + +import com.cleanengine.coin.order.application.event.OrderInsertedToQueue; +import com.cleanengine.coin.trade.application.TradeFlowService; +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); + } +} From 9dc0656a47a8a2da9b9e9618108345f6caaa1389 Mon Sep 17 00:00:00 2001 From: Junh-b Date: Wed, 11 Jun 2025 12:41:06 +0900 Subject: [PATCH 08/17] feat: handling concurrency MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 동시성 이슈를 비관적 락 + repeatable_read로 대응한 버전입니다. --- docker/docker-compose.yml | 5 +++-- .../out/persistentce/account/OrderAccountRepository.java | 6 ++++++ .../wallet/OrderWalletRepositoryCustomImpl.java | 3 +++ .../cleanengine/coin/order/application/OrderService.java | 5 +++-- .../cleanengine/coin/trade/application/TradeExecutor.java | 3 ++- .../cleanengine/coin/user/info/infra/AccountRepository.java | 6 ++++++ .../cleanengine/coin/user/info/infra/WalletRepository.java | 6 ++++++ 7 files changed, 29 insertions(+), 5 deletions(-) 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/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 index 0ad71a03..e48d70bb 100644 --- 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 @@ -1,10 +1,16 @@ package com.cleanengine.coin.order.adapter.out.persistentce.account; import com.cleanengine.coin.user.domain.Account; +import jakarta.persistence.LockModeType; +import jakarta.persistence.QueryHint; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.QueryHints; import org.springframework.data.repository.CrudRepository; import java.util.Optional; public interface OrderAccountRepository extends CrudRepository { + @Lock(LockModeType.PESSIMISTIC_WRITE) +// @QueryHints({@QueryHint(name = "jakarta.persistence.lock.timeout", value = "3000")}) Optional findByUserId(Integer userId); } 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 index 471adcc3..687f2166 100644 --- 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 @@ -2,6 +2,7 @@ import com.cleanengine.coin.user.domain.Wallet; import jakarta.persistence.EntityManager; +import jakarta.persistence.LockModeType; import jakarta.persistence.NoResultException; import jakarta.persistence.TypedQuery; import lombok.RequiredArgsConstructor; @@ -23,6 +24,8 @@ public Optional findWalletBy(Integer userId, String ticker) { Wallet.class); query.setParameter("userId", userId); query.setParameter("ticker", ticker); + query.setLockMode(LockModeType.PESSIMISTIC_WRITE); +// query.setHint("jakarta.persistence.lock.timeout", "3000"); try{ Wallet wallet = query.getSingleResult(); 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..63b6e8c7 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.REPEATABLE_READ) public OrderInfo createOrder(OrderCommand.CreateOrder createOrder){ validateCreateOrder(createOrder); CreateOrderStrategy createOrderStrategy = createOrderStrategies.stream() 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 4bf1fd9f..d42b8ea6 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,7 +35,7 @@ public class TradeExecutor { private final TradeExecutedEventPublisher tradeExecutedEventPublisher; private final TradeService tradeService; - @Transactional(propagation = Propagation.REQUIRES_NEW) + @Transactional(propagation = Propagation.REQUIRES_NEW, isolation = Isolation.REPEATABLE_READ) public void executeTrade(WaitingOrders waitingOrders, TradePair tradePair, String ticker) { BuyOrder buyOrder = tradePair.getBuyOrder(); SellOrder sellOrder = tradePair.getSellOrder(); 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..4a540184 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,18 @@ package com.cleanengine.coin.user.info.infra; import com.cleanengine.coin.user.domain.Account; +import jakarta.persistence.LockModeType; +import jakarta.persistence.QueryHint; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.QueryHints; import java.util.Optional; public interface AccountRepository extends JpaRepository { + @Lock(LockModeType.PESSIMISTIC_WRITE) +// @QueryHints({@QueryHint(name = "jakarta.persistence.lock.timeout", value = "3000")}) 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..a78b4dd9 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,12 +1,18 @@ package com.cleanengine.coin.user.info.infra; import com.cleanengine.coin.user.domain.Wallet; +import jakarta.persistence.LockModeType; +import jakarta.persistence.QueryHint; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.QueryHints; import java.util.List; import java.util.Optional; public interface WalletRepository extends JpaRepository { + @Lock(LockModeType.PESSIMISTIC_WRITE) +// @QueryHints({@QueryHint(name = "jakarta.persistence.lock.timeout", value = "3000")}) Optional findByAccountIdAndTicker(Integer accountId, String ticker); List findByAccountId(Integer accountId); From da06ed36c7f6e13cdea4ab62944b3db96c18a370 Mon Sep 17 00:00:00 2001 From: Junh-b Date: Wed, 11 Jun 2025 16:10:34 +0900 Subject: [PATCH 09/17] =?UTF-8?q?refactor:=20OrderService=EC=9D=98=20?= =?UTF-8?q?=EC=B0=B8=EC=A1=B0=20Repository=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 주문이 user 패키지에 정의된 repository를 참조하도록 변경했습니다. lock, timeout등의 공통로직들이 적용되어야 하기 때문에 통합시키는것이 좋다고 판단했습니다. --- .../coin/order/application/OrderService.java | 2 +- .../order/application/strategy/BuyOrderStrategy.java | 10 +++++----- .../application/strategy/CreateOrderStrategy.java | 10 +++++----- .../order/application/strategy/SellOrderStrategy.java | 10 ++++++---- 4 files changed, 17 insertions(+), 15 deletions(-) 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 63b6e8c7..eaa0f2aa 100644 --- a/src/main/java/com/cleanengine/coin/order/application/OrderService.java +++ b/src/main/java/com/cleanengine/coin/order/application/OrderService.java @@ -36,7 +36,7 @@ public OrderInfo createOrder(OrderCommand.CreateOrder createOrder){ return createOrderStrategy.processCreatingOrder(createOrder); } - @Transactional(propagation = Propagation.REQUIRES_NEW) + @Transactional(propagation = Propagation.REQUIRES_NEW, isolation = Isolation.REPEATABLE_READ) public OrderInfo createOrderWithBot(String ticker, Boolean isBuyOrder, Double orderSize, Double price){ Integer userId = isBuyOrder? BUY_ORDER_BOT_ID : SELL_ORDER_BOT_ID; 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..7857c729 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; @@ -12,6 +10,8 @@ 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,8 +21,8 @@ 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()); @@ -60,7 +60,7 @@ protected T createOrder(OrderCommand.CreateOrder createOrderCommand){ // TODO 책임이 너무 많은 protected void createWalletIfNeeded(Integer userId, String ticker){ - if(walletRepository.findWalletBy(userId, ticker).isEmpty()){ + if(walletRepository.findByAccountIdAndTicker(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..66a0ee6f 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 @@ -12,6 +12,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 +41,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 +64,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; } From 9c4ab868b9c35f50b01a038fe799e8d4b6bd82eb Mon Sep 17 00:00:00 2001 From: Junh-b Date: Wed, 11 Jun 2025 16:11:38 +0900 Subject: [PATCH 10/17] =?UTF-8?q?test:=20OrderService=20=EB=8F=99=EC=8B=9C?= =?UTF-8?q?=EC=84=B1=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 동시성 테스트를 추가하면서 테스트 내용을 더 잘 분석할 수 있도록 로그 설정과 base 테스트를 수정했습니다. --- build.gradle | 4 +- .../annotation/TransactionLoggingAspect.java | 28 +++++++++ src/main/resources/logback-spring.xml | 8 ++- .../coin/base/MariaDBAdapterTest.java | 5 +- .../coin/base/MariaDBIntegrationTest.java | 54 +++++++++++++++++ .../order/application/OrderServiceTest.java | 60 +++++++++++++++++++ .../order/application/initializeBotUser.sql | 10 ++++ 7 files changed, 164 insertions(+), 5 deletions(-) create mode 100644 src/main/java/com/cleanengine/coin/common/annotation/TransactionLoggingAspect.java create mode 100644 src/test/java/com/cleanengine/coin/base/MariaDBIntegrationTest.java create mode 100644 src/test/java/com/cleanengine/coin/order/application/OrderServiceTest.java create mode 100644 src/test/resources/com/cleanengine/coin/order/application/initializeBotUser.sql diff --git a/build.gradle b/build.gradle index 1e947b67..74ba2dd9 100644 --- a/build.gradle +++ b/build.gradle @@ -86,9 +86,7 @@ dependencies { } tasks.named('test') { - useJUnitPlatform{ - excludeTags 'testcontainers' - } + useJUnitPlatform() finalizedBy jacocoTestReport } 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/resources/logback-spring.xml b/src/main/resources/logback-spring.xml index acfdc5cc..70b0e7e1 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..8f5bac3b --- /dev/null +++ b/src/test/java/com/cleanengine/coin/order/application/OrderServiceTest.java @@ -0,0 +1,60 @@ +package com.cleanengine.coin.order.application; + +import com.cleanengine.coin.base.MariaDBIntegrationTest; +import com.cleanengine.coin.common.CommonValues; +import com.cleanengine.coin.order.application.dto.OrderCommand; +import com.cleanengine.coin.trade.repository.TradeRepository; +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 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; + + @Sql("initializeBotUser.sql") + @DisplayName("동시에 5개의 매도요청과 5개의 매수요청이 들어왔을 때 주문에 대한 체결이 정상적으로 처리된다.") + @Test + public void create10OrdersSimultaneously_orderShouldBeProcessedSuccessfully() throws InterruptedException { + + int numberOfThreads = 5; + + ExecutorService executorService = Executors.newFixedThreadPool(numberOfThreads); + CountDownLatch countDownLatch = new CountDownLatch(numberOfThreads); + + OrderCommand.CreateOrder buyOrderCommand = new OrderCommand.CreateOrder("BTC", CommonValues.BUY_ORDER_BOT_ID,true, false, 30.0, 30.0, LocalDateTime.now(),false); + OrderCommand.CreateOrder sellOrderCommand = new OrderCommand.CreateOrder("BTC", CommonValues.SELL_ORDER_BOT_ID,false, false, 30.0, 30.0, LocalDateTime.now(),false); + + for (int i = 0; i < numberOfThreads; i++) { + executorService.submit(() -> { + try{ + orderService.createOrder(buyOrderCommand); + orderService.createOrder(sellOrderCommand); + } catch (Exception e) { + System.out.println(e.getMessage()); + } + finally { + countDownLatch.countDown(); + } + }); + } + + countDownLatch.await(); + // 비동기적으로 처리되는 체결이 완료될때까지 대기 + Thread.sleep(2000); + + long resultCount = tradeRepository.count(); + assertEquals(5, resultCount); + } +} 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..e7257469 --- /dev/null +++ b/src/test/resources/com/cleanengine/coin/order/application/initializeBotUser.sql @@ -0,0 +1,10 @@ +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'); From b3b583c0eb1affd12b82e646578210246fdd3f7a Mon Sep 17 00:00:00 2001 From: Junh-b Date: Wed, 11 Jun 2025 19:32:39 +0900 Subject: [PATCH 11/17] =?UTF-8?q?test:=20orderservice=20=EB=8F=99=EC=8B=9C?= =?UTF-8?q?=EC=84=B1=20=EC=A0=81=EC=9A=A9=EB=B2=84=EC=A0=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit read_committed 로 변경 --- .../coin/order/application/OrderService.java | 2 +- .../user/info/infra/WalletRepository.java | 1 + src/main/resources/logback-spring.xml | 6 +-- .../order/application/OrderServiceTest.java | 53 ++++++++++++++++--- .../order/application/initializeBotUser.sql | 2 + 5 files changed, 53 insertions(+), 11 deletions(-) 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 eaa0f2aa..70e5cd13 100644 --- a/src/main/java/com/cleanengine/coin/order/application/OrderService.java +++ b/src/main/java/com/cleanengine/coin/order/application/OrderService.java @@ -27,7 +27,7 @@ public class OrderService { private final List> createOrderStrategies; private final Validator validator; - @Transactional(isolation = Isolation.REPEATABLE_READ) + @Transactional(isolation = Isolation.READ_COMMITTED) public OrderInfo createOrder(OrderCommand.CreateOrder createOrder){ validateCreateOrder(createOrder); CreateOrderStrategy createOrderStrategy = createOrderStrategies.stream() 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 a78b4dd9..9e1beb3a 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 @@ -15,5 +15,6 @@ public interface WalletRepository extends JpaRepository { // @QueryHints({@QueryHint(name = "jakarta.persistence.lock.timeout", value = "3000")}) Optional findByAccountIdAndTicker(Integer accountId, String ticker); + @Lock(LockModeType.PESSIMISTIC_WRITE) List findByAccountId(Integer accountId); } diff --git a/src/main/resources/logback-spring.xml b/src/main/resources/logback-spring.xml index 70b0e7e1..6a2d62d5 100644 --- a/src/main/resources/logback-spring.xml +++ b/src/main/resources/logback-spring.xml @@ -57,9 +57,9 @@ - - - + + + \ No newline at end of file diff --git a/src/test/java/com/cleanengine/coin/order/application/OrderServiceTest.java b/src/test/java/com/cleanengine/coin/order/application/OrderServiceTest.java index 8f5bac3b..6223c10c 100644 --- a/src/test/java/com/cleanengine/coin/order/application/OrderServiceTest.java +++ b/src/test/java/com/cleanengine/coin/order/application/OrderServiceTest.java @@ -2,12 +2,18 @@ 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; @@ -23,38 +29,71 @@ public class OrderServiceTest extends MariaDBIntegrationTest { @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 = 5; + int numberOfThreads = 100; ExecutorService executorService = Executors.newFixedThreadPool(numberOfThreads); - CountDownLatch countDownLatch = new CountDownLatch(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, 30.0, 30.0, LocalDateTime.now(),false); - OrderCommand.CreateOrder sellOrderCommand = new OrderCommand.CreateOrder("BTC", CommonValues.SELL_ORDER_BOT_ID,false, false, 30.0, 30.0, LocalDateTime.now(),false); + 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(sellOrderCommand); + orderService.createOrder(buyOrderCommand); + orderService.createOrder(buyOrderCommand); + orderService.createOrder(buyOrderCommand); + orderService.createOrder(buyOrderCommand); +// orderService.createOrder(sellOrderCommand); } catch (Exception e) { System.out.println(e.getMessage()); } finally { - countDownLatch.countDown(); + endLatch.countDown(); } }); } - countDownLatch.await(); + 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/resources/com/cleanengine/coin/order/application/initializeBotUser.sql b/src/test/resources/com/cleanengine/coin/order/application/initializeBotUser.sql index e7257469..420258ea 100644 --- a/src/test/resources/com/cleanengine/coin/order/application/initializeBotUser.sql +++ b/src/test/resources/com/cleanengine/coin/order/application/initializeBotUser.sql @@ -8,3 +8,5 @@ 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'); From 2af056065a01ab84f52ef8563a40466e511630ec Mon Sep 17 00:00:00 2001 From: caniro Date: Wed, 11 Jun 2025 21:43:45 +0900 Subject: [PATCH 12/17] =?UTF-8?q?fix:=20READ=5FCOMMITTED=20=EB=B0=8F=20?= =?UTF-8?q?=EB=B9=84=EA=B4=80=EC=A0=81=20=EB=9D=BD=20=EC=82=AC=EC=9A=A9?= =?UTF-8?q?=EC=9D=84=20=ED=86=B5=ED=95=9C=20=EB=8F=99=EC=8B=9C=EC=84=B1=20?= =?UTF-8?q?=EB=AC=B8=EC=A0=9C=20=ED=95=B4=EC=86=8C=20=20=20-=20=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=84=B0=20=EC=A0=95=ED=95=A9=EC=84=B1(=EB=AC=B4?= =?UTF-8?q?=EA=B2=B0=EC=84=B1)=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=ED=95=84?= =?UTF-8?q?=EC=9A=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../coin/order/application/OrderService.java | 6 +- .../strategy/CreateOrderStrategy.java | 16 +--- .../strategy/SellOrderStrategy.java | 2 - .../service/OrderGenerateService.java | 40 ++-------- .../application/TradeBatchProcessor.java | 79 ------------------- .../coin/trade/application/TradeExecutor.java | 36 ++++----- .../trade/application/TradeQueueManager.java | 28 ------- ...rderInsertedQueueStartMatchingHandler.java | 6 ++ .../user/info/application/AccountService.java | 30 ++++++- .../user/info/application/WalletService.java | 14 +++- .../user/info/infra/AccountRepository.java | 3 - .../user/info/infra/WalletRepository.java | 3 - .../application/CustomOAuth2UserService.java | 43 ++++++---- src/main/resources/logback-spring.xml | 6 +- .../TradeFlowServiceIntegrationTest.java | 12 --- .../CustomOAuth2UserServiceTest.java | 5 +- 16 files changed, 108 insertions(+), 221 deletions(-) delete mode 100644 src/main/java/com/cleanengine/coin/trade/application/TradeBatchProcessor.java delete mode 100644 src/main/java/com/cleanengine/coin/trade/application/TradeQueueManager.java 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 70e5cd13..1a8ff5aa 100644 --- a/src/main/java/com/cleanengine/coin/order/application/OrderService.java +++ b/src/main/java/com/cleanengine/coin/order/application/OrderService.java @@ -36,14 +36,14 @@ public OrderInfo createOrder(OrderCommand.CreateOrder createOrder){ return createOrderStrategy.processCreatingOrder(createOrder); } - @Transactional(propagation = Propagation.REQUIRES_NEW, isolation = Isolation.REPEATABLE_READ) - 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/CreateOrderStrategy.java b/src/main/java/com/cleanengine/coin/order/application/strategy/CreateOrderStrategy.java index 7857c729..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 @@ -8,8 +8,6 @@ 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; @@ -28,7 +26,6 @@ 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.findByAccountIdAndTicker(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 66a0ee6f..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; 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 14657d8e..00000000 --- a/src/main/java/com/cleanengine/coin/trade/application/TradeBatchProcessor.java +++ /dev/null @@ -1,79 +0,0 @@ -package com.cleanengine.coin.trade.application; - -import com.cleanengine.coin.order.application.AssetService; -import jakarta.annotation.PreDestroy; -import lombok.Getter; -import lombok.extern.slf4j.Slf4j; -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 -@Service -public class TradeBatchProcessor implements ApplicationRunner { - - private final TradeFlowService tradeFlowService; - private final List executors = new ArrayList<>(); - - @Getter - private final Map tradeQueueManagers = new HashMap<>(); - - private final List tickers; - - public TradeBatchProcessor(TradeFlowService tradeFlowService, AssetService assetService) { - this.tradeFlowService = tradeFlowService; - tickers = assetService.getAllAssetTickers(); - } - - @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 d42b8ea6..e9c266e0 100644 --- a/src/main/java/com/cleanengine/coin/trade/application/TradeExecutor.java +++ b/src/main/java/com/cleanengine/coin/trade/application/TradeExecutor.java @@ -35,11 +35,11 @@ public class TradeExecutor { private final TradeExecutedEventPublisher tradeExecutedEventPublisher; private final TradeService tradeService; - @Transactional(propagation = Propagation.REQUIRES_NEW, isolation = Isolation.REPEATABLE_READ) + @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.debug("{} - 체결 시작: 매수[{} {}원 {}개] / 매도[{} {}원 {}개]", ticker, buyOrder.getId(), buyOrder.getPrice(), buyOrder.getRemainingSize(), + log.trace("{} - 체결 시작: 매수[{} {}원 {}개] / 매도[{} {}원 {}개]", ticker, buyOrder.getId(), buyOrder.getPrice(), buyOrder.getRemainingSize(), sellOrder.getId(), sellOrder.getPrice(), sellOrder.getRemainingSize()); double tradedPrice; @@ -51,9 +51,9 @@ public void executeTrade(WaitingOrders waitingOrders, TradePair tr tradedSize = tradeUnitPriceAndSize.tradedSize(); tradedPrice = tradeUnitPriceAndSize.tradedPrice(); if (approxEquals(tradedSize, 0.0)) { - this.checkZeroOrderAndThrowException(buyOrder, sellOrder); + TradeExecutor.checkZeroOrderAndThrowException(buyOrder, sellOrder); } - this.writeTradingLog(buyOrder, sellOrder); + TradeExecutor.writeTradingLog(buyOrder, sellOrder); totalTradedPrice = tradedPrice * tradedSize; // 주문 잔여수량, 잔여금액 감소 @@ -64,8 +64,8 @@ public void executeTrade(WaitingOrders waitingOrders, TradePair tr sellOrder.decreaseRemainingSize(tradedSize); // 주문 완전체결 처리(잔여금액 or 잔여수량이 0) - this.removeCompletedBuyOrder(waitingOrders, buyOrder); - this.removeCompletedSellOrder(waitingOrders, sellOrder); + TradeExecutor.removeCompletedBuyOrder(waitingOrders, buyOrder); + TradeExecutor.removeCompletedSellOrder(waitingOrders, sellOrder); tradeService.updateOrder(buyOrder); tradeService.updateOrder(sellOrder); @@ -91,7 +91,7 @@ public void executeTrade(WaitingOrders waitingOrders, TradePair tr tradeExecutedEventPublisher.publish(tradeExecutedEvent); } - private void checkZeroOrderAndThrowException(BuyOrder buyOrder, SellOrder sellOrder) { + private static void checkZeroOrderAndThrowException(BuyOrder buyOrder, SellOrder sellOrder) { Order zeroOrder = null; if (approxEquals(buyOrder.getRemainingDeposit(), 0.0)) zeroOrder = buyOrder; @@ -103,12 +103,12 @@ else if (approxEquals(sellOrder.getRemainingSize(), 0.0)) buyOrder.getPrice(), sellOrder.getPrice()), zeroOrder); } - private Account increaseAccountCash(Order order, Double amount) { + 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; @@ -117,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); } @@ -165,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(), @@ -179,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); + TradeExecutor.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); + TradeExecutor.updateCompletedOrderStatus(order); } } - private void updateCompletedOrderStatus(Order order) { + private static void updateCompletedOrderStatus(Order order) { order.setState(OrderStatus.DONE); } 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 21d18922..00000000 --- a/src/main/java/com/cleanengine/coin/trade/application/TradeQueueManager.java +++ /dev/null @@ -1,28 +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.core.annotation.Order; -import org.springframework.scheduling.annotation.Async; -import org.springframework.stereotype.Component; -import org.springframework.transaction.event.TransactionalEventListener; - -@Slf4j -@Order(4) -//@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/event/OrderInsertedQueueStartMatchingHandler.java b/src/main/java/com/cleanengine/coin/trade/event/OrderInsertedQueueStartMatchingHandler.java index 191ec2cf..197862be 100644 --- a/src/main/java/com/cleanengine/coin/trade/event/OrderInsertedQueueStartMatchingHandler.java +++ b/src/main/java/com/cleanengine/coin/trade/event/OrderInsertedQueueStartMatchingHandler.java @@ -2,6 +2,7 @@ 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; @@ -44,4 +45,9 @@ protected synchronized void addThreadExecutor(String ticker) { tickerExecutorServices.put(ticker, executorService); } + + @PreDestroy + public void shutdown() { + tickerExecutorServices.values().forEach(ExecutorService::shutdown); + } } 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 4a540184..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 @@ -2,17 +2,14 @@ import com.cleanengine.coin.user.domain.Account; import jakarta.persistence.LockModeType; -import jakarta.persistence.QueryHint; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Lock; -import org.springframework.data.jpa.repository.QueryHints; import java.util.Optional; public interface AccountRepository extends JpaRepository { @Lock(LockModeType.PESSIMISTIC_WRITE) -// @QueryHints({@QueryHint(name = "jakarta.persistence.lock.timeout", value = "3000")}) 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 9e1beb3a..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 @@ -2,17 +2,14 @@ import com.cleanengine.coin.user.domain.Wallet; import jakarta.persistence.LockModeType; -import jakarta.persistence.QueryHint; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Lock; -import org.springframework.data.jpa.repository.QueryHints; import java.util.List; import java.util.Optional; public interface WalletRepository extends JpaRepository { @Lock(LockModeType.PESSIMISTIC_WRITE) -// @QueryHints({@QueryHint(name = "jakarta.persistence.lock.timeout", value = "3000")}) Optional findByAccountIdAndTicker(Integer accountId, String ticker); @Lock(LockModeType.PESSIMISTIC_WRITE) 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 6a2d62d5..974b3fe7 100644 --- a/src/main/resources/logback-spring.xml +++ b/src/main/resources/logback-spring.xml @@ -54,9 +54,9 @@ - - - + + + 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/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( From 5a7c2f6500434b6bc92d9ff2285cbfc8d337576b Mon Sep 17 00:00:00 2001 From: caniro Date: Wed, 11 Jun 2025 21:53:08 +0900 Subject: [PATCH 13/17] =?UTF-8?q?chore:=20=EB=B6=88=ED=95=84=EC=9A=94=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/TradeQueueManagerTest.java | 80 ------------------- 1 file changed, 80 deletions(-) delete mode 100644 src/test/java/com/cleanengine/coin/trade/application/TradeQueueManagerTest.java 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 From f1ca7a60d91907c584e2146594ad633e76b9a59a Mon Sep 17 00:00:00 2001 From: caniro Date: Thu, 12 Jun 2025 10:03:47 +0900 Subject: [PATCH 14/17] =?UTF-8?q?add:=20account,=20wallet=20=EC=9D=B8?= =?UTF-8?q?=EB=8D=B1=EC=8A=A4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docker/mariadb/init.sql | 6 ++++++ 1 file changed, 6 insertions(+) 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'); From dadc814d174c6c1936db4a8b63a8bb9f7cb125d1 Mon Sep 17 00:00:00 2001 From: Junh-b Date: Thu, 12 Jun 2025 13:18:43 +0900 Subject: [PATCH 15/17] =?UTF-8?q?config:=20=ED=95=84=EC=88=98=20config=20?= =?UTF-8?q?=EC=9E=AC=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 테스트 편의를 위해 잠시 추가했었던 설정 수정내용을 원래대로 복원했습니다. --- .gitignore | 4 +--- build.gradle | 4 +++- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.gitignore b/.gitignore index 4e767dee..661ab51d 100644 --- a/.gitignore +++ b/.gitignore @@ -39,6 +39,4 @@ out/ ### 로컬 환경변수 ### local.properties /logs -docker-compose.override.yml - -dd-java-agent.jar \ No newline at end of file +docker-compose.override.yml \ No newline at end of file diff --git a/build.gradle b/build.gradle index 74ba2dd9..1e947b67 100644 --- a/build.gradle +++ b/build.gradle @@ -86,7 +86,9 @@ dependencies { } tasks.named('test') { - useJUnitPlatform() + useJUnitPlatform{ + excludeTags 'testcontainers' + } finalizedBy jacocoTestReport } From cbe002a8d22f28bc50754db282088c480dd1339e Mon Sep 17 00:00:00 2001 From: Junh-b Date: Thu, 12 Jun 2025 13:20:32 +0900 Subject: [PATCH 16/17] =?UTF-8?q?refactor:=20Account,=20Wallet=20=EB=A0=88?= =?UTF-8?q?=ED=8F=AC=EC=A7=80=ED=84=B0=EB=A6=AC=20=ED=86=B5=EC=9D=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 각 영역에서 별도의 목적으로 사용중이던 Repository 클래스를 제거하고, 통일된 Repository를 사용하도록 변경했습니다. --- .../configuration/bootstrap/DBInitRunner.java | 10 ++--- .../account/OrderAccountRepository.java | 16 -------- .../wallet/OrderWalletRepository.java | 7 ---- .../wallet/OrderWalletRepositoryCustom.java | 9 ----- .../OrderWalletRepositoryCustomImpl.java | 37 ------------------- .../buyorder/BuyOrderIntegrationTest.java | 27 +++----------- .../sellorder/SellOrderIntegrationTest.java | 26 ++----------- 7 files changed, 15 insertions(+), 117 deletions(-) delete mode 100644 src/main/java/com/cleanengine/coin/order/adapter/out/persistentce/account/OrderAccountRepository.java delete mode 100644 src/main/java/com/cleanengine/coin/order/adapter/out/persistentce/wallet/OrderWalletRepository.java delete mode 100644 src/main/java/com/cleanengine/coin/order/adapter/out/persistentce/wallet/OrderWalletRepositoryCustom.java delete mode 100644 src/main/java/com/cleanengine/coin/order/adapter/out/persistentce/wallet/OrderWalletRepositoryCustomImpl.java 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 e48d70bb..00000000 --- a/src/main/java/com/cleanengine/coin/order/adapter/out/persistentce/account/OrderAccountRepository.java +++ /dev/null @@ -1,16 +0,0 @@ -package com.cleanengine.coin.order.adapter.out.persistentce.account; - -import com.cleanengine.coin.user.domain.Account; -import jakarta.persistence.LockModeType; -import jakarta.persistence.QueryHint; -import org.springframework.data.jpa.repository.Lock; -import org.springframework.data.jpa.repository.QueryHints; -import org.springframework.data.repository.CrudRepository; - -import java.util.Optional; - -public interface OrderAccountRepository extends CrudRepository { - @Lock(LockModeType.PESSIMISTIC_WRITE) -// @QueryHints({@QueryHint(name = "jakarta.persistence.lock.timeout", value = "3000")}) - 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 687f2166..00000000 --- a/src/main/java/com/cleanengine/coin/order/adapter/out/persistentce/wallet/OrderWalletRepositoryCustomImpl.java +++ /dev/null @@ -1,37 +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.LockModeType; -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); - query.setLockMode(LockModeType.PESSIMISTIC_WRITE); -// query.setHint("jakarta.persistence.lock.timeout", "3000"); - - try{ - Wallet wallet = query.getSingleResult(); - return Optional.of(wallet); - } catch (NoResultException e) { - return Optional.empty(); - } - } -} 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()); - } } From 164c6b10bc5f9b096a8f55f6015c606b81c80346 Mon Sep 17 00:00:00 2001 From: caniro Date: Thu, 12 Jun 2025 16:06:02 +0900 Subject: [PATCH 17/17] =?UTF-8?q?chore:=20typo(static=20method=20=ED=98=B8?= =?UTF-8?q?=EC=B6=9C)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../coin/trade/application/TradeExecutor.java | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) 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 e9c266e0..cb7338f4 100644 --- a/src/main/java/com/cleanengine/coin/trade/application/TradeExecutor.java +++ b/src/main/java/com/cleanengine/coin/trade/application/TradeExecutor.java @@ -51,9 +51,9 @@ public void executeTrade(WaitingOrders waitingOrders, TradePair tr tradedSize = tradeUnitPriceAndSize.tradedSize(); tradedPrice = tradeUnitPriceAndSize.tradedPrice(); if (approxEquals(tradedSize, 0.0)) { - TradeExecutor.checkZeroOrderAndThrowException(buyOrder, sellOrder); + checkZeroOrderAndThrowException(buyOrder, sellOrder); } - TradeExecutor.writeTradingLog(buyOrder, sellOrder); + writeTradingLog(buyOrder, sellOrder); totalTradedPrice = tradedPrice * tradedSize; // 주문 잔여수량, 잔여금액 감소 @@ -64,8 +64,8 @@ public void executeTrade(WaitingOrders waitingOrders, TradePair tr sellOrder.decreaseRemainingSize(tradedSize); // 주문 완전체결 처리(잔여금액 or 잔여수량이 0) - TradeExecutor.removeCompletedBuyOrder(waitingOrders, buyOrder); - TradeExecutor.removeCompletedSellOrder(waitingOrders, sellOrder); + removeCompletedBuyOrder(waitingOrders, buyOrder); + removeCompletedSellOrder(waitingOrders, sellOrder); tradeService.updateOrder(buyOrder); tradeService.updateOrder(sellOrder); @@ -185,7 +185,7 @@ private static void removeCompletedBuyOrder(WaitingOrders waitingOrders, BuyOrde if (isOrderCompleted) { waitingOrders.removeOrder(order); - TradeExecutor.updateCompletedOrderStatus(order); + updateCompletedOrderStatus(order); } } @@ -194,7 +194,7 @@ private static void removeCompletedSellOrder(WaitingOrders waitingOrders, SellOr if (isOrderCompleted) { waitingOrders.removeOrder(order); - TradeExecutor.updateCompletedOrderStatus(order); + updateCompletedOrderStatus(order); } }