From 286b04c4a78244e328fece55e7a70de78907f598 Mon Sep 17 00:00:00 2001 From: ohhalim Date: Thu, 14 May 2026 16:56:04 +0900 Subject: [PATCH 01/11] =?UTF-8?q?fix:=20Phase=201=20BLOCKER=204=EA=B1=B4?= =?UTF-8?q?=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. 오더북 롤백 불일치 — planMatch/applyMatchPlan 분리 DB 롤백 시 in-memory 큐가 이미 변경된 상태로 남는 문제 해소. afterCommit() 훅으로 applyMatchPlan() 호출을 커밋 이후로 보장. 2. 오더북 동시 읽기 — marketLock 재사용 REST 오더북 조회 시 marketLock 없이 PriorityQueue를 stream()하면 ConcurrentModificationException 발생. MarketController에서 기존 OrderService.marketLock을 획득한 뒤 조회하도록 수정. 3. Maker order row lock 누락 — SELECT FOR UPDATE 적용 settle() 내 maker 조회를 findByIdWithLock()으로 교체, fill 이전 isCancelable() 상태 재검증 추가. 4. DepositRequest 입력 검증 누락 — @NotBlank / @Pattern 추가 null·빈 문자열 입력 시 NPE/NumberFormatException 방지. WalletController에 @Valid 추가. --- .../coinflow/market/api/MarketController.java | 24 ++++-- .../order/matching/MatchingEngine.java | 17 ++-- .../order/matching/MemoryOrderBook.java | 79 +++++++++++++++++++ .../order/repository/OrderRepository.java | 8 ++ .../coinflow/order/service/OrderService.java | 29 ++++++- .../coinflow/wallet/api/WalletController.java | 5 +- .../coinflow/wallet/dto/DepositRequest.java | 7 +- 7 files changed, 148 insertions(+), 21 deletions(-) diff --git a/src/main/java/com/coinflow/market/api/MarketController.java b/src/main/java/com/coinflow/market/api/MarketController.java index 7124050..85c8e93 100644 --- a/src/main/java/com/coinflow/market/api/MarketController.java +++ b/src/main/java/com/coinflow/market/api/MarketController.java @@ -1,10 +1,15 @@ package com.coinflow.market.api; +import com.coinflow.common.exception.ApiException; +import com.coinflow.common.exception.ErrorCode; +import com.coinflow.market.domain.Market; import com.coinflow.market.domain.MarketStatus; import com.coinflow.market.dto.MarketResponse; import com.coinflow.market.dto.OrderBookResponse; import com.coinflow.market.repository.MarketRepository; import com.coinflow.order.matching.MatchingEngine; +import com.coinflow.order.matching.OrderBookEntry; +import com.coinflow.order.service.OrderService; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; @@ -12,6 +17,7 @@ import org.springframework.web.bind.annotation.RestController; import java.util.List; +import java.util.concurrent.locks.ReentrantLock; @RestController @RequiredArgsConstructor @@ -20,6 +26,7 @@ public class MarketController { private final MarketRepository marketRepository; private final MatchingEngine matchingEngine; + private final OrderService orderService; @GetMapping public List getMarkets() { @@ -31,10 +38,17 @@ public List getMarkets() { @GetMapping("/{market}/orderbook") public OrderBookResponse getOrderBook(@PathVariable String market) { - return OrderBookResponse.of( - market, - matchingEngine.getBuySide(market), - matchingEngine.getSellSide(market) - ); + Market found = marketRepository.findBySymbol(market) + .orElseThrow(() -> new ApiException(ErrorCode.MARKET_NOT_FOUND)); + + ReentrantLock lock = orderService.getMarketLock(found.getId()); + lock.lock(); + try { + List buySide = matchingEngine.getBuySide(market); + List sellSide = matchingEngine.getSellSide(market); + return OrderBookResponse.of(market, buySide, sellSide); + } finally { + lock.unlock(); + } } } diff --git a/src/main/java/com/coinflow/order/matching/MatchingEngine.java b/src/main/java/com/coinflow/order/matching/MatchingEngine.java index d04cb55..22eb7f8 100644 --- a/src/main/java/com/coinflow/order/matching/MatchingEngine.java +++ b/src/main/java/com/coinflow/order/matching/MatchingEngine.java @@ -15,19 +15,20 @@ public class MatchingEngine { private final Map orderBooks = new ConcurrentHashMap<>(); - public List match(Market market, Order taker) { + public List planMatch(Market market, Order taker) { MemoryOrderBook book = orderBooks.computeIfAbsent( market.getSymbol(), k -> new MemoryOrderBook(market.getAmountScale()) ); + return book.planMatch(taker); + } - List results = book.match(taker); - - if (taker.getRemainingQuantity().compareTo(java.math.BigDecimal.ZERO) > 0) { - book.add(taker); - } - - return results; + public void applyMatchPlan(Market market, Order taker, List plan) { + MemoryOrderBook book = orderBooks.computeIfAbsent( + market.getSymbol(), + k -> new MemoryOrderBook(market.getAmountScale()) + ); + book.applyMatchPlan(taker, plan); } public void cancelOrder(String marketSymbol, Order order) { diff --git a/src/main/java/com/coinflow/order/matching/MemoryOrderBook.java b/src/main/java/com/coinflow/order/matching/MemoryOrderBook.java index 4ed4567..b9c06d0 100644 --- a/src/main/java/com/coinflow/order/matching/MemoryOrderBook.java +++ b/src/main/java/com/coinflow/order/matching/MemoryOrderBook.java @@ -85,6 +85,85 @@ public List match(Order taker) { return results; } + public List planMatch(Order taker) { + PriorityQueue makerQueue = (taker.getSide() == OrderSide.BUY) ? sellQueue : buyQueue; + PriorityQueue simulation = new PriorityQueue<>(makerQueue); + + List results = new ArrayList<>(); + BigDecimal takerRemaining = taker.getRemainingQuantity(); + + while (!simulation.isEmpty() && takerRemaining.compareTo(BigDecimal.ZERO) > 0) { + OrderBookEntry maker = simulation.peek(); + + boolean priceMatches = (taker.getSide() == OrderSide.BUY) + ? taker.getPrice().compareTo(maker.price()) >= 0 + : taker.getPrice().compareTo(maker.price()) <= 0; + + if (!priceMatches) break; + + if (maker.userId().equals(taker.getUserId())) { + break; + } + + simulation.poll(); + + BigDecimal matchedQuantity = takerRemaining.min(maker.remainingQuantity()); + BigDecimal matchedQuoteAmount = maker.price() + .multiply(matchedQuantity) + .setScale(amountScale, RoundingMode.DOWN); + + boolean isTakerBuy = taker.getSide() == OrderSide.BUY; + results.add(new MatchResult( + maker.orderId(), + taker.getId(), + isTakerBuy ? taker.getId() : maker.orderId(), + isTakerBuy ? maker.orderId() : taker.getId(), + maker.userId(), + taker.getUserId(), + isTakerBuy ? taker.getUserId() : maker.userId(), + isTakerBuy ? maker.userId() : taker.getUserId(), + maker.price(), + matchedQuantity, + matchedQuoteAmount + )); + + takerRemaining = takerRemaining.subtract(matchedQuantity); + + BigDecimal makerRemaining = maker.remainingQuantity().subtract(matchedQuantity); + if (makerRemaining.compareTo(BigDecimal.ZERO) > 0) { + simulation.add(new OrderBookEntry( + maker.orderId(), maker.userId(), maker.price(), makerRemaining, maker.sequence() + )); + } + } + + return results; + } + + public void applyMatchPlan(Order taker, List plan) { + PriorityQueue makerQueue = (taker.getSide() == OrderSide.BUY) ? sellQueue : buyQueue; + + for (MatchResult result : plan) { + OrderBookEntry matched = makerQueue.stream() + .filter(e -> e.orderId().equals(result.makerOrderId())) + .findFirst() + .orElse(null); + if (matched == null) continue; + + makerQueue.remove(matched); + BigDecimal remaining = matched.remainingQuantity().subtract(result.quantity()); + if (remaining.compareTo(BigDecimal.ZERO) > 0) { + makerQueue.add(new OrderBookEntry( + matched.orderId(), matched.userId(), matched.price(), remaining, matched.sequence() + )); + } + } + + if (taker.getRemainingQuantity().compareTo(BigDecimal.ZERO) > 0) { + add(taker); + } + } + public void add(Order order) { OrderBookEntry entry = OrderBookEntry.from(order); if (order.getSide() == OrderSide.BUY) { diff --git a/src/main/java/com/coinflow/order/repository/OrderRepository.java b/src/main/java/com/coinflow/order/repository/OrderRepository.java index 8b75e82..58c2866 100644 --- a/src/main/java/com/coinflow/order/repository/OrderRepository.java +++ b/src/main/java/com/coinflow/order/repository/OrderRepository.java @@ -2,7 +2,11 @@ import com.coinflow.order.domain.Order; import com.coinflow.order.domain.OrderStatus; +import jakarta.persistence.LockModeType; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import java.util.List; import java.util.Optional; @@ -12,6 +16,10 @@ public interface OrderRepository extends JpaRepository { Optional findByIdAndUserId(Long id, Long userId); + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("SELECT o FROM Order o WHERE o.id = :id") + Optional findByIdWithLock(@Param("id") Long id); + List findAllByUserIdOrderByCreatedAtDesc(Long userId); List findAllByUserIdAndMarketSymbolOrderByCreatedAtDesc(Long userId, String marketSymbol); diff --git a/src/main/java/com/coinflow/order/service/OrderService.java b/src/main/java/com/coinflow/order/service/OrderService.java index 3c075a1..154f204 100644 --- a/src/main/java/com/coinflow/order/service/OrderService.java +++ b/src/main/java/com/coinflow/order/service/OrderService.java @@ -30,6 +30,8 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.PlatformTransactionManager; import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; import org.springframework.transaction.support.TransactionTemplate; import java.math.BigDecimal; @@ -160,9 +162,21 @@ public CreateOrderResponse createOrder(Long currentUserId, CreateOrderRequest re order.getId(), null )); - // 매칭 및 정산 - List matchResults = matchingEngine.match(market, order); - List trades = settle(market, order, matchResults); + // 매칭 계획 수립 (큐 미변경), 정산 + List plan = matchingEngine.planMatch(market, order); + List trades = settle(market, order, plan); + + // 커밋 성공 후 오더북 반영 — DB 롤백 시 큐는 그대로 + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + @Override + public void afterCommit() { + try { + matchingEngine.applyMatchPlan(market, order, plan); + } catch (Exception e) { + log.error("오더북 applyMatchPlan 실패: orderId={}, 서버 재시작 또는 DB 체결 내역으로 오더북 재구성 필요", order.getId(), e); + } + } + }); return CreateOrderResponse.of(order, trades); }); @@ -209,6 +223,10 @@ public CancelOrderResponse cancelOrder(Long currentUserId, Long orderId) { } } + public ReentrantLock getMarketLock(Long marketId) { + return marketLocks.computeIfAbsent(marketId, k -> new ReentrantLock()); + } + @Transactional(readOnly = true) public OrderDetailResponse getOrder(Long currentUserId, Long orderId) { Order order = orderRepository.findByIdAndUserId(orderId, currentUserId) @@ -230,7 +248,10 @@ private List settle(Market market, Order taker, List matchRe List trades = new ArrayList<>(); for (MatchResult result : matchResults) { - Order maker = orderRepository.findById(result.makerOrderId()).orElseThrow(); + Order maker = orderRepository.findByIdWithLock(result.makerOrderId()).orElseThrow(); + if (!maker.isCancelable()) { + throw new ApiException(ErrorCode.ORDER_NOT_FOUND); + } boolean takerIsBuy = taker.getSide() == OrderSide.BUY; Order buyOrder = takerIsBuy ? taker : maker; diff --git a/src/main/java/com/coinflow/wallet/api/WalletController.java b/src/main/java/com/coinflow/wallet/api/WalletController.java index b3e3ca4..0b24d93 100644 --- a/src/main/java/com/coinflow/wallet/api/WalletController.java +++ b/src/main/java/com/coinflow/wallet/api/WalletController.java @@ -4,6 +4,7 @@ import com.coinflow.wallet.dto.WalletLedgerResponse; import com.coinflow.wallet.dto.WalletResponse; import com.coinflow.wallet.service.WalletService; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; import org.springframework.security.core.annotation.AuthenticationPrincipal; @@ -12,8 +13,6 @@ import java.util.List; -import java.util.List; - @RestController @RequiredArgsConstructor @RequestMapping("/api/v1/wallets") @@ -25,7 +24,7 @@ public class WalletController { @ResponseStatus(HttpStatus.OK) public WalletResponse deposit( @AuthenticationPrincipal Jwt jwt, - @RequestBody DepositRequest request + @Valid @RequestBody DepositRequest request ) { Long userId = Long.parseLong(jwt.getSubject()); return walletService.deposit(userId, request); diff --git a/src/main/java/com/coinflow/wallet/dto/DepositRequest.java b/src/main/java/com/coinflow/wallet/dto/DepositRequest.java index c8caadc..cd25712 100644 --- a/src/main/java/com/coinflow/wallet/dto/DepositRequest.java +++ b/src/main/java/com/coinflow/wallet/dto/DepositRequest.java @@ -1,6 +1,11 @@ package com.coinflow.wallet.dto; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; + public record DepositRequest( - String asset, + @NotBlank String asset, + @NotBlank + @Pattern(regexp = "^\\d+(\\.\\d+)?$", message = "amount must be a positive number") String amount ) {} From 90e3730a5d7343dab9b52edc093fe0e3269954f8 Mon Sep 17 00:00:00 2001 From: ohhalim Date: Thu, 14 May 2026 17:22:31 +0900 Subject: [PATCH 02/11] =?UTF-8?q?feat(outbox):=20=EC=9D=B4=EB=B2=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=8A=A4=ED=82=A4=EB=A7=88/=EB=B0=9C=ED=96=89?= =?UTF-8?q?=EC=88=9C=EC=84=9C/dedup=20=EB=B3=B4=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DomainEventRecorder.save(): payload를 schemaVersion+occurredAt 엔벨로프로 래핑 - DomainEventRepository: findAllByPublishedFalseOrderByIdAsc() 추가 (발행순서 보장) - V5__create_processed_events.sql: 컨슈머별 중복 처리 방지용 테이블 생성 --- .../coinflow/event/repository/DomainEventRepository.java | 1 + .../com/coinflow/event/service/DomainEventRecorder.java | 8 +++++++- .../db/migration/V5__create_processed_events.sql | 6 ++++++ 3 files changed, 14 insertions(+), 1 deletion(-) create mode 100644 src/main/resources/db/migration/V5__create_processed_events.sql diff --git a/src/main/java/com/coinflow/event/repository/DomainEventRepository.java b/src/main/java/com/coinflow/event/repository/DomainEventRepository.java index 4183ef6..eed510b 100644 --- a/src/main/java/com/coinflow/event/repository/DomainEventRepository.java +++ b/src/main/java/com/coinflow/event/repository/DomainEventRepository.java @@ -9,4 +9,5 @@ public interface DomainEventRepository extends JpaRepository { List findAllByAggregateTypeAndAggregateId(String aggregateType, Long aggregateId); List findAllByEventType(DomainEventType eventType); + List findAllByPublishedFalseOrderByIdAsc(); } diff --git a/src/main/java/com/coinflow/event/service/DomainEventRecorder.java b/src/main/java/com/coinflow/event/service/DomainEventRecorder.java index 2087b21..0a3f68f 100644 --- a/src/main/java/com/coinflow/event/service/DomainEventRecorder.java +++ b/src/main/java/com/coinflow/event/service/DomainEventRecorder.java @@ -10,6 +10,7 @@ import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; +import java.time.Instant; import java.util.Map; @Service @@ -91,7 +92,12 @@ public void recordSettlementCompleted(Trade trade) { private void save(DomainEventType type, String aggregateType, Long aggregateId, Long marketId, String marketSymbol, Map payload) { try { - String json = objectMapper.writeValueAsString(payload); + Map envelope = Map.of( + "schemaVersion", "1.0", + "occurredAt", Instant.now().toString(), + "payload", payload + ); + String json = objectMapper.writeValueAsString(envelope); domainEventRepository.save( DomainEvent.create(type, aggregateType, aggregateId, marketId, marketSymbol, json) ); diff --git a/src/main/resources/db/migration/V5__create_processed_events.sql b/src/main/resources/db/migration/V5__create_processed_events.sql new file mode 100644 index 0000000..95cff01 --- /dev/null +++ b/src/main/resources/db/migration/V5__create_processed_events.sql @@ -0,0 +1,6 @@ +CREATE TABLE processed_events ( + event_id BIGINT NOT NULL, + consumer VARCHAR(60) NOT NULL, + processed_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + PRIMARY KEY (event_id, consumer) +); From 904b872efad3f432718af7ca2771a892adddaa98 Mon Sep 17 00:00:00 2001 From: ohhalim Date: Thu, 14 May 2026 17:22:41 +0900 Subject: [PATCH 03/11] =?UTF-8?q?feat(api):=20=EC=A3=BC=EB=AC=B8/=EC=B2=B4?= =?UTF-8?q?=EA=B2=B0=20=EB=82=B4=EC=97=AD=20=ED=8E=98=EC=9D=B4=EC=A7=80?= =?UTF-8?q?=EB=84=A4=EC=9D=B4=EC=85=98=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - OrderController: @Validated + limit(@Min(1)@Max(200))/offset(@Min(0)) 파라미터 추가 - OrderService.getOrders(): PageRequest.of(offset/limit, limit) 적용 - OrderRepository: 목록 조회 메서드 Pageable 파라미터 추가 - TradeRepository: userId 조회를 keyset 페이지네이션으로 변경 (lastFillId > :lastFillId ORDER BY id ASC) - TradeController.getFills(): lastFillId/limit 파라미터 추가 --- .../java/com/coinflow/order/api/OrderController.java | 10 ++++++++-- .../com/coinflow/order/repository/OrderRepository.java | 6 ++++-- .../java/com/coinflow/order/service/OrderService.java | 8 +++++--- .../java/com/coinflow/trade/api/TradeController.java | 9 ++++++--- .../com/coinflow/trade/repository/TradeRepository.java | 8 ++++---- 5 files changed, 27 insertions(+), 14 deletions(-) diff --git a/src/main/java/com/coinflow/order/api/OrderController.java b/src/main/java/com/coinflow/order/api/OrderController.java index 711ad5e..b11397b 100644 --- a/src/main/java/com/coinflow/order/api/OrderController.java +++ b/src/main/java/com/coinflow/order/api/OrderController.java @@ -7,10 +7,13 @@ import com.coinflow.order.dto.OrderSummaryResponse; import com.coinflow.order.service.OrderService; import jakarta.validation.Valid; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; @@ -25,6 +28,7 @@ @RestController @RequiredArgsConstructor @RequestMapping("/api/v1/orders") +@Validated public class OrderController { private final OrderService orderService; @@ -51,10 +55,12 @@ public CancelOrderResponse cancelOrder( @GetMapping public List getOrders( @AuthenticationPrincipal Jwt jwt, - @RequestParam(required = false) String market + @RequestParam(required = false) String market, + @RequestParam(defaultValue = "50") @Min(1) @Max(200) int limit, + @RequestParam(defaultValue = "0") @Min(0) int offset ) { Long userId = Long.parseLong(jwt.getSubject()); - return orderService.getOrders(userId, market); + return orderService.getOrders(userId, market, limit, offset); } @GetMapping("/{id}") diff --git a/src/main/java/com/coinflow/order/repository/OrderRepository.java b/src/main/java/com/coinflow/order/repository/OrderRepository.java index 58c2866..f370887 100644 --- a/src/main/java/com/coinflow/order/repository/OrderRepository.java +++ b/src/main/java/com/coinflow/order/repository/OrderRepository.java @@ -8,6 +8,8 @@ import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; +import org.springframework.data.domain.Pageable; + import java.util.List; import java.util.Optional; @@ -20,9 +22,9 @@ public interface OrderRepository extends JpaRepository { @Query("SELECT o FROM Order o WHERE o.id = :id") Optional findByIdWithLock(@Param("id") Long id); - List findAllByUserIdOrderByCreatedAtDesc(Long userId); + List findAllByUserIdOrderByCreatedAtDesc(Long userId, Pageable pageable); - List findAllByUserIdAndMarketSymbolOrderByCreatedAtDesc(Long userId, String marketSymbol); + List findAllByUserIdAndMarketSymbolOrderByCreatedAtDesc(Long userId, String marketSymbol, Pageable pageable); List findAllByStatusInOrderBySequenceAsc(List statuses); } diff --git a/src/main/java/com/coinflow/order/service/OrderService.java b/src/main/java/com/coinflow/order/service/OrderService.java index 154f204..9d9abf8 100644 --- a/src/main/java/com/coinflow/order/service/OrderService.java +++ b/src/main/java/com/coinflow/order/service/OrderService.java @@ -27,6 +27,7 @@ import com.coinflow.wallet.repository.WalletLedgerRepository; import com.coinflow.wallet.repository.WalletRepository; import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.PageRequest; import org.springframework.stereotype.Service; import org.springframework.transaction.PlatformTransactionManager; import org.springframework.transaction.annotation.Transactional; @@ -235,10 +236,11 @@ public OrderDetailResponse getOrder(Long currentUserId, Long orderId) { } @Transactional(readOnly = true) - public List getOrders(Long currentUserId, String market) { + public List getOrders(Long currentUserId, String market, int limit, int offset) { + var pageable = PageRequest.of(offset / limit, limit); List orders = (market != null) - ? orderRepository.findAllByUserIdAndMarketSymbolOrderByCreatedAtDesc(currentUserId, market) - : orderRepository.findAllByUserIdOrderByCreatedAtDesc(currentUserId); + ? orderRepository.findAllByUserIdAndMarketSymbolOrderByCreatedAtDesc(currentUserId, market, pageable) + : orderRepository.findAllByUserIdOrderByCreatedAtDesc(currentUserId, pageable); return orders.stream().map(OrderSummaryResponse::from).toList(); } diff --git a/src/main/java/com/coinflow/trade/api/TradeController.java b/src/main/java/com/coinflow/trade/api/TradeController.java index 5df7992..231630b 100644 --- a/src/main/java/com/coinflow/trade/api/TradeController.java +++ b/src/main/java/com/coinflow/trade/api/TradeController.java @@ -36,12 +36,15 @@ public List getTrades( @GetMapping("/fills") public List getFills( @AuthenticationPrincipal Jwt jwt, - @RequestParam(required = false) String market + @RequestParam(required = false) String market, + @RequestParam(defaultValue = "0") long lastFillId, + @RequestParam(defaultValue = "50") int limit ) { Long userId = Long.parseLong(jwt.getSubject()); + var pageable = PageRequest.of(0, limit); var trades = (market != null) - ? tradeRepository.findAllByUserIdAndMarket(userId, market) - : tradeRepository.findAllByUserId(userId); + ? tradeRepository.findAllByUserIdAndMarket(userId, market, lastFillId, pageable) + : tradeRepository.findAllByUserId(userId, lastFillId, pageable); return trades.stream().map(t -> FillResponse.of(t, userId)).toList(); } } diff --git a/src/main/java/com/coinflow/trade/repository/TradeRepository.java b/src/main/java/com/coinflow/trade/repository/TradeRepository.java index f2de6fd..0d47a50 100644 --- a/src/main/java/com/coinflow/trade/repository/TradeRepository.java +++ b/src/main/java/com/coinflow/trade/repository/TradeRepository.java @@ -12,9 +12,9 @@ public interface TradeRepository extends JpaRepository { List findAllByMarketSymbolOrderByTradedAtDesc(String marketSymbol, Pageable pageable); - @Query("SELECT t FROM Trade t WHERE (t.buyUserId = :userId OR t.sellUserId = :userId) ORDER BY t.tradedAt DESC") - List findAllByUserId(@Param("userId") Long userId); + @Query("SELECT t FROM Trade t WHERE (t.buyUserId = :userId OR t.sellUserId = :userId) AND t.id > :lastFillId ORDER BY t.id ASC") + List findAllByUserId(@Param("userId") Long userId, @Param("lastFillId") Long lastFillId, Pageable pageable); - @Query("SELECT t FROM Trade t WHERE (t.buyUserId = :userId OR t.sellUserId = :userId) AND t.marketSymbol = :market ORDER BY t.tradedAt DESC") - List findAllByUserIdAndMarket(@Param("userId") Long userId, @Param("market") String market); + @Query("SELECT t FROM Trade t WHERE (t.buyUserId = :userId OR t.sellUserId = :userId) AND t.marketSymbol = :market AND t.id > :lastFillId ORDER BY t.id ASC") + List findAllByUserIdAndMarket(@Param("userId") Long userId, @Param("market") String market, @Param("lastFillId") Long lastFillId, Pageable pageable); } From 4b2b906bd705ba59b05b3633a3d0d4cb32c5f49d Mon Sep 17 00:00:00 2001 From: ohhalim Date: Thu, 14 May 2026 17:22:48 +0900 Subject: [PATCH 04/11] =?UTF-8?q?test(integration):=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EA=B2=A9=EB=A6=AC=20=EB=B3=B4=EA=B0=95=20=E2=80=94?= =?UTF-8?q?=20FK=20=EC=88=9C=EC=84=9C=20=EC=A0=95=EB=A6=AC=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - MatchingSettlementTest/DomainEventTest @BeforeEach: domain_events → wallet_ledgers → trades → orders → wallets → users 순서로 deleteAllInBatch() 호출 - 테스트 간 데이터 간섭 제거, 외래 키 제약 위반 방지 --- .../com/coinflow/integration/DomainEventTest.java | 12 ++++++++++++ .../integration/MatchingSettlementTest.java | 14 ++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/src/test/java/com/coinflow/integration/DomainEventTest.java b/src/test/java/com/coinflow/integration/DomainEventTest.java index f2d5b2d..566bbca 100644 --- a/src/test/java/com/coinflow/integration/DomainEventTest.java +++ b/src/test/java/com/coinflow/integration/DomainEventTest.java @@ -4,8 +4,11 @@ import com.coinflow.event.domain.DomainEventType; import com.coinflow.event.repository.DomainEventRepository; import com.coinflow.order.matching.MatchingEngine; +import com.coinflow.order.repository.OrderRepository; import com.coinflow.support.TestcontainersConfig; +import com.coinflow.trade.repository.TradeRepository; import com.coinflow.wallet.domain.Wallet; +import com.coinflow.wallet.repository.WalletLedgerRepository; import com.coinflow.wallet.repository.WalletRepository; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -29,11 +32,20 @@ class DomainEventTest { @Autowired private TestRestTemplate restTemplate; @Autowired private UserRepository userRepository; @Autowired private WalletRepository walletRepository; + @Autowired private WalletLedgerRepository walletLedgerRepository; + @Autowired private TradeRepository tradeRepository; + @Autowired private OrderRepository orderRepository; @Autowired private DomainEventRepository domainEventRepository; @Autowired private MatchingEngine matchingEngine; @BeforeEach void setUp() { + domainEventRepository.deleteAllInBatch(); + walletLedgerRepository.deleteAllInBatch(); + tradeRepository.deleteAllInBatch(); + orderRepository.deleteAllInBatch(); + walletRepository.deleteAllInBatch(); + userRepository.deleteAllInBatch(); matchingEngine.clearAll(); } diff --git a/src/test/java/com/coinflow/integration/MatchingSettlementTest.java b/src/test/java/com/coinflow/integration/MatchingSettlementTest.java index a70dc40..7691d05 100644 --- a/src/test/java/com/coinflow/integration/MatchingSettlementTest.java +++ b/src/test/java/com/coinflow/integration/MatchingSettlementTest.java @@ -1,9 +1,13 @@ package com.coinflow.integration; import com.coinflow.auth.repository.UserRepository; +import com.coinflow.event.repository.DomainEventRepository; import com.coinflow.order.matching.MatchingEngine; +import com.coinflow.order.repository.OrderRepository; import com.coinflow.support.TestcontainersConfig; +import com.coinflow.trade.repository.TradeRepository; import com.coinflow.wallet.domain.Wallet; +import com.coinflow.wallet.repository.WalletLedgerRepository; import com.coinflow.wallet.repository.WalletRepository; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -27,10 +31,20 @@ class MatchingSettlementTest { @Autowired private TestRestTemplate restTemplate; @Autowired private UserRepository userRepository; @Autowired private WalletRepository walletRepository; + @Autowired private WalletLedgerRepository walletLedgerRepository; + @Autowired private TradeRepository tradeRepository; + @Autowired private OrderRepository orderRepository; + @Autowired private DomainEventRepository domainEventRepository; @Autowired private MatchingEngine matchingEngine; @BeforeEach void setUp() { + domainEventRepository.deleteAllInBatch(); + walletLedgerRepository.deleteAllInBatch(); + tradeRepository.deleteAllInBatch(); + orderRepository.deleteAllInBatch(); + walletRepository.deleteAllInBatch(); + userRepository.deleteAllInBatch(); matchingEngine.clearAll(); } From 444c5ea36b6346c18e986d0fa413ddabdbfd48b2 Mon Sep 17 00:00:00 2001 From: ohhalim Date: Thu, 14 May 2026 17:22:55 +0900 Subject: [PATCH 05/11] =?UTF-8?q?chore(infra):=20=EB=A1=9C=EC=BB=AC=20?= =?UTF-8?q?=EA=B0=9C=EB=B0=9C=20=ED=99=98=EA=B2=BD=20docker-compose=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20=EB=B0=8F=20DB=20=EA=B8=B0=EB=B3=B8=20?= =?UTF-8?q?=EB=B9=84=EB=B0=80=EB=B2=88=ED=98=B8=20=ED=86=B5=EC=9D=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - docker-compose.yml: MySQL 8.0 + Zookeeper + Kafka(confluentinc/cp-kafka:7.6.0) 구성 추가 - application.properties: DB_PASSWORD 기본값을 빈 문자열에서 coinflow로 변경 (docker-compose 설정과 일치) --- docker-compose.yml | 32 +++++++++++++++++++++++ src/main/resources/application.properties | 2 +- 2 files changed, 33 insertions(+), 1 deletion(-) create mode 100644 docker-compose.yml diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..1c039ab --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,32 @@ +services: + mysql: + image: mysql:8.0 + environment: + MYSQL_DATABASE: coinflow + MYSQL_USER: coinflow + MYSQL_PASSWORD: ${MYSQL_PASSWORD:-coinflow} + MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:-root} + ports: + - "3306:3306" + volumes: + - mysql_data:/var/lib/mysql + + zookeeper: + image: confluentinc/cp-zookeeper:7.6.0 + environment: + ZOOKEEPER_CLIENT_PORT: 2181 + + kafka: + image: confluentinc/cp-kafka:7.6.0 + depends_on: + - zookeeper + ports: + - "9092:9092" + environment: + KAFKA_BROKER_ID: 1 + KAFKA_ZOOKEEPER_CONNECT: zookeeper:2181 + KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://localhost:9092 + KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1 + +volumes: + mysql_data: diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index eb9a663..d8bafec 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -3,7 +3,7 @@ spring.application.name=coinflow # ===== DataSource ===== spring.datasource.url=${DB_URL:jdbc:mysql://localhost:3306/coinflow?serverTimezone=Asia/Seoul&characterEncoding=UTF-8} spring.datasource.username=${DB_USERNAME:coinflow} -spring.datasource.password=${DB_PASSWORD:} +spring.datasource.password=${DB_PASSWORD:coinflow} spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver # ===== JPA ===== From 495d699ff1095c1207823ffa133f404840792341 Mon Sep 17 00:00:00 2001 From: ohhalim Date: Thu, 14 May 2026 17:38:50 +0900 Subject: [PATCH 06/11] =?UTF-8?q?fix:=20=EC=B7=A8=EC=86=8C=20=EA=B2=BD?= =?UTF-8?q?=EB=A1=9C=20rollback=20=EB=B6=88=EC=9D=BC=EC=B9=98=20=EB=B0=8F?= =?UTF-8?q?=20=ED=8E=98=EC=9D=B4=EC=A7=80=EB=84=A4=EC=9D=B4=EC=85=98/?= =?UTF-8?q?=EA=B2=80=EC=A6=9D=20=EB=B2=84=EA=B7=B8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - cancelOrder() 오더북 변경을 afterCommit()으로 이동 — DB 롤백 시 in-memory 불일치 방지 - OffsetBasedPageRequest 도입으로 주문 목록 offset 처리 수정 - TradeController limit/lastFillId 파라미터 유효성 검증 추가 (@Min/@Max) - GlobalExceptionHandler에 ConstraintViolationException 핸들러 추가 - 테스트 3종 @BeforeEach에 DB 전체 클린업 추가 (FK 순서 준수) - docker-compose MYSQL_PASSWORD → DB_PASSWORD 통일 --- docker-compose.yml | 2 +- .../exception/GlobalExceptionHandler.java | 8 ++++ .../pagination/OffsetBasedPageRequest.java | 37 +++++++++++++++++++ .../coinflow/order/service/OrderService.java | 18 +++++++-- .../coinflow/trade/api/TradeController.java | 10 +++-- .../java/com/coinflow/order/OrderApiTest.java | 14 +++++++ .../java/com/coinflow/query/QueryApiTest.java | 14 +++++++ .../com/coinflow/wallet/WalletApiTest.java | 12 ++++++ 8 files changed, 108 insertions(+), 7 deletions(-) create mode 100644 src/main/java/com/coinflow/common/pagination/OffsetBasedPageRequest.java diff --git a/docker-compose.yml b/docker-compose.yml index 1c039ab..caed1eb 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -4,7 +4,7 @@ services: environment: MYSQL_DATABASE: coinflow MYSQL_USER: coinflow - MYSQL_PASSWORD: ${MYSQL_PASSWORD:-coinflow} + MYSQL_PASSWORD: ${DB_PASSWORD:-coinflow} MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:-root} ports: - "3306:3306" diff --git a/src/main/java/com/coinflow/common/exception/GlobalExceptionHandler.java b/src/main/java/com/coinflow/common/exception/GlobalExceptionHandler.java index 61877e4..e4bb87a 100644 --- a/src/main/java/com/coinflow/common/exception/GlobalExceptionHandler.java +++ b/src/main/java/com/coinflow/common/exception/GlobalExceptionHandler.java @@ -1,5 +1,6 @@ package com.coinflow.common.exception; +import jakarta.validation.ConstraintViolationException; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.annotation.ExceptionHandler; @@ -22,4 +23,11 @@ public ResponseEntity handleValidation(MethodArgumentNotValidExce .status(ErrorCode.INVALID_REQUEST.getStatus()) .body(ErrorResponse.of(ErrorCode.INVALID_REQUEST)); } + + @ExceptionHandler(ConstraintViolationException.class) + public ResponseEntity handleConstraintViolation(ConstraintViolationException e) { + return ResponseEntity + .status(ErrorCode.INVALID_REQUEST.getStatus()) + .body(ErrorResponse.of(ErrorCode.INVALID_REQUEST)); + } } diff --git a/src/main/java/com/coinflow/common/pagination/OffsetBasedPageRequest.java b/src/main/java/com/coinflow/common/pagination/OffsetBasedPageRequest.java new file mode 100644 index 0000000..9a2747d --- /dev/null +++ b/src/main/java/com/coinflow/common/pagination/OffsetBasedPageRequest.java @@ -0,0 +1,37 @@ +package com.coinflow.common.pagination; + +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; + +public record OffsetBasedPageRequest(long offset, int limit) implements Pageable { + + @Override public int getPageNumber() { return (int) (offset / limit); } + @Override public int getPageSize() { return limit; } + @Override public long getOffset() { return offset; } + @Override public Sort getSort() { return Sort.unsorted(); } + + @Override + public Pageable next() { + return new OffsetBasedPageRequest(offset + limit, limit); + } + + @Override + public Pageable previousOrFirst() { + return offset <= 0 ? this : new OffsetBasedPageRequest(Math.max(0, offset - limit), limit); + } + + @Override + public Pageable first() { + return new OffsetBasedPageRequest(0, limit); + } + + @Override + public Pageable withPage(int pageNumber) { + return new OffsetBasedPageRequest((long) pageNumber * limit, limit); + } + + @Override + public boolean hasPrevious() { + return offset > 0; + } +} diff --git a/src/main/java/com/coinflow/order/service/OrderService.java b/src/main/java/com/coinflow/order/service/OrderService.java index 9d9abf8..0bf7a1b 100644 --- a/src/main/java/com/coinflow/order/service/OrderService.java +++ b/src/main/java/com/coinflow/order/service/OrderService.java @@ -27,7 +27,7 @@ import com.coinflow.wallet.repository.WalletLedgerRepository; import com.coinflow.wallet.repository.WalletRepository; import lombok.extern.slf4j.Slf4j; -import org.springframework.data.domain.PageRequest; +import com.coinflow.common.pagination.OffsetBasedPageRequest; import org.springframework.stereotype.Service; import org.springframework.transaction.PlatformTransactionManager; import org.springframework.transaction.annotation.Transactional; @@ -214,9 +214,21 @@ public CancelOrderResponse cancelOrder(Long currentUserId, Long orderId) { )); lockedOrder.cancel(); - matchingEngine.cancelOrder(lockedOrder.getMarketSymbol(), lockedOrder); eventRecorder.recordOrderCanceled(lockedOrder, lockedOrder.getLockedAsset(), releaseAmount.toPlainString()); + String marketSymbol = lockedOrder.getMarketSymbol(); + Order canceledOrder = lockedOrder; + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + @Override + public void afterCommit() { + try { + matchingEngine.cancelOrder(marketSymbol, canceledOrder); + } catch (Exception e) { + log.error("오더북 cancelOrder 실패: orderId={}, DB 취소 완료 but 오더북에 잔존", canceledOrder.getId(), e); + } + } + }); + return CancelOrderResponse.of(lockedOrder, lockedOrder.getLockedAsset(), releaseAmount.toPlainString()); }); } finally { @@ -237,7 +249,7 @@ public OrderDetailResponse getOrder(Long currentUserId, Long orderId) { @Transactional(readOnly = true) public List getOrders(Long currentUserId, String market, int limit, int offset) { - var pageable = PageRequest.of(offset / limit, limit); + var pageable = new OffsetBasedPageRequest(offset, limit); List orders = (market != null) ? orderRepository.findAllByUserIdAndMarketSymbolOrderByCreatedAtDesc(currentUserId, market, pageable) : orderRepository.findAllByUserIdOrderByCreatedAtDesc(currentUserId, pageable); diff --git a/src/main/java/com/coinflow/trade/api/TradeController.java b/src/main/java/com/coinflow/trade/api/TradeController.java index 231630b..9326f9c 100644 --- a/src/main/java/com/coinflow/trade/api/TradeController.java +++ b/src/main/java/com/coinflow/trade/api/TradeController.java @@ -3,10 +3,13 @@ import com.coinflow.trade.dto.FillResponse; import com.coinflow.trade.dto.TradeResponse; import com.coinflow.trade.repository.TradeRepository; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.PageRequest; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestMapping; @@ -18,6 +21,7 @@ @RestController @RequiredArgsConstructor @RequestMapping("/api/v1") +@Validated public class TradeController { private final TradeRepository tradeRepository; @@ -25,7 +29,7 @@ public class TradeController { @GetMapping("/markets/{market}/trades") public List getTrades( @PathVariable String market, - @RequestParam(defaultValue = "20") int limit + @RequestParam(defaultValue = "20") @Min(1) @Max(100) int limit ) { return tradeRepository.findAllByMarketSymbolOrderByTradedAtDesc(market, PageRequest.of(0, limit)) .stream() @@ -37,8 +41,8 @@ public List getTrades( public List getFills( @AuthenticationPrincipal Jwt jwt, @RequestParam(required = false) String market, - @RequestParam(defaultValue = "0") long lastFillId, - @RequestParam(defaultValue = "50") int limit + @RequestParam(defaultValue = "0") @Min(0) long lastFillId, + @RequestParam(defaultValue = "50") @Min(1) @Max(200) int limit ) { Long userId = Long.parseLong(jwt.getSubject()); var pageable = PageRequest.of(0, limit); diff --git a/src/test/java/com/coinflow/order/OrderApiTest.java b/src/test/java/com/coinflow/order/OrderApiTest.java index 44499a8..c0f7280 100644 --- a/src/test/java/com/coinflow/order/OrderApiTest.java +++ b/src/test/java/com/coinflow/order/OrderApiTest.java @@ -1,9 +1,13 @@ package com.coinflow.order; import com.coinflow.auth.repository.UserRepository; +import com.coinflow.event.repository.DomainEventRepository; import com.coinflow.order.matching.MatchingEngine; +import com.coinflow.order.repository.OrderRepository; import com.coinflow.support.TestcontainersConfig; +import com.coinflow.trade.repository.TradeRepository; import com.coinflow.wallet.domain.Wallet; +import com.coinflow.wallet.repository.WalletLedgerRepository; import com.coinflow.wallet.repository.WalletRepository; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -26,11 +30,21 @@ class OrderApiTest { @Autowired private TestRestTemplate restTemplate; @Autowired private UserRepository userRepository; @Autowired private WalletRepository walletRepository; + @Autowired private WalletLedgerRepository walletLedgerRepository; + @Autowired private OrderRepository orderRepository; + @Autowired private TradeRepository tradeRepository; + @Autowired private DomainEventRepository domainEventRepository; @Autowired private MatchingEngine matchingEngine; @BeforeEach void setUp() { matchingEngine.clearAll(); + walletLedgerRepository.deleteAll(); + domainEventRepository.deleteAll(); + tradeRepository.deleteAll(); + orderRepository.deleteAll(); + walletRepository.deleteAll(); + userRepository.deleteAll(); } // ── ORDER-001 주문 생성 ─────────────────────────────────────────── diff --git a/src/test/java/com/coinflow/query/QueryApiTest.java b/src/test/java/com/coinflow/query/QueryApiTest.java index 3e15085..eb95bea 100644 --- a/src/test/java/com/coinflow/query/QueryApiTest.java +++ b/src/test/java/com/coinflow/query/QueryApiTest.java @@ -1,9 +1,13 @@ package com.coinflow.query; import com.coinflow.auth.repository.UserRepository; +import com.coinflow.event.repository.DomainEventRepository; import com.coinflow.order.matching.MatchingEngine; +import com.coinflow.order.repository.OrderRepository; import com.coinflow.support.TestcontainersConfig; +import com.coinflow.trade.repository.TradeRepository; import com.coinflow.wallet.domain.Wallet; +import com.coinflow.wallet.repository.WalletLedgerRepository; import com.coinflow.wallet.repository.WalletRepository; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -27,11 +31,21 @@ class QueryApiTest { @Autowired private TestRestTemplate restTemplate; @Autowired private UserRepository userRepository; @Autowired private WalletRepository walletRepository; + @Autowired private WalletLedgerRepository walletLedgerRepository; + @Autowired private OrderRepository orderRepository; + @Autowired private TradeRepository tradeRepository; + @Autowired private DomainEventRepository domainEventRepository; @Autowired private MatchingEngine matchingEngine; @BeforeEach void setUp() { matchingEngine.clearAll(); + walletLedgerRepository.deleteAll(); + domainEventRepository.deleteAll(); + tradeRepository.deleteAll(); + orderRepository.deleteAll(); + walletRepository.deleteAll(); + userRepository.deleteAll(); } // ── QRY-001 시장 목록 조회 ──────────────────────────────────────── diff --git a/src/test/java/com/coinflow/wallet/WalletApiTest.java b/src/test/java/com/coinflow/wallet/WalletApiTest.java index 3c08048..10dc74a 100644 --- a/src/test/java/com/coinflow/wallet/WalletApiTest.java +++ b/src/test/java/com/coinflow/wallet/WalletApiTest.java @@ -1,8 +1,11 @@ package com.coinflow.wallet; import com.coinflow.auth.repository.UserRepository; +import com.coinflow.event.repository.DomainEventRepository; import com.coinflow.order.matching.MatchingEngine; +import com.coinflow.order.repository.OrderRepository; import com.coinflow.support.TestcontainersConfig; +import com.coinflow.trade.repository.TradeRepository; import com.coinflow.wallet.domain.LedgerType; import com.coinflow.wallet.domain.Wallet; import com.coinflow.wallet.domain.WalletLedger; @@ -31,11 +34,20 @@ class WalletApiTest { @Autowired private UserRepository userRepository; @Autowired private WalletRepository walletRepository; @Autowired private WalletLedgerRepository walletLedgerRepository; + @Autowired private OrderRepository orderRepository; + @Autowired private TradeRepository tradeRepository; + @Autowired private DomainEventRepository domainEventRepository; @Autowired private MatchingEngine matchingEngine; @BeforeEach void setUp() { matchingEngine.clearAll(); + walletLedgerRepository.deleteAll(); + domainEventRepository.deleteAll(); + tradeRepository.deleteAll(); + orderRepository.deleteAll(); + walletRepository.deleteAll(); + userRepository.deleteAll(); } // ── WAL-001 지갑 조회 ───────────────────────────────────────────── From 9d5b0d9dc6ce60ffa8b1fafe87fa9f6c04cc5017 Mon Sep 17 00:00:00 2001 From: ohhalim Date: Fri, 15 May 2026 13:34:54 +0900 Subject: [PATCH 07/11] =?UTF-8?q?docs:=20=EC=BD=94=EB=93=9C=20=EB=A6=AC?= =?UTF-8?q?=EB=B7=B0=20=EC=9D=B4=EC=8A=88=20=EB=AA=A9=EB=A1=9D=20=EC=A0=95?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Zero-quote 체결 — DB 제약 위반 가능성 정리 rounding 후 quoteAmount가 0인 체결이 생성되면 trades.quote_amount > 0 제약을 위반하는 문제를 문서화. Dust maker 잔존 — 체결 불가능 주문이 오더북에 남는 문제 정리 체결 후 maker 잔량의 quote value가 0이 되는 경우 자동 취소가 필요함을 명시. Deposit API 운영 노출 — MVP 범위와 보안 위험 정리 입출금은 MVP 제외 범위인데 운영 프로필에서 deposit endpoint가 열려 있는 문제를 BLOCKER로 분류. API/락/복구/인프라 불일치 — 후속 정리 항목 문서화 Market/Fills/OrderBook 응답 명세, cancel row lock, wallet lock ordering, afterCommit 복구, Kafka KRaft compose 불일치를 우선순위별로 정리. --- .docs/ISSUES.md | 403 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 403 insertions(+) create mode 100644 .docs/ISSUES.md diff --git a/.docs/ISSUES.md b/.docs/ISSUES.md new file mode 100644 index 0000000..91fd2f5 --- /dev/null +++ b/.docs/ISSUES.md @@ -0,0 +1,403 @@ +# CoinFlow 현재 이슈 목록 + +이 문서는 Phase 1 완료 이후 코드 리뷰에서 발견된 이슈를 심각도 순으로 정리한다. +Phase 1 BLOCKER 이슈(planMatch/applyMatchPlan, marketLock 재사용, findByIdWithLock, DepositRequest 검증, Pagination)는 [v2/ISSUES.md](./v2/ISSUES.md)에서 모두 수정 완료되었다. + +--- + +## Priority 1 — Zero-quote 체결 생성 방지 [BLOCKER] + +### 현상 + +`planMatch()`는 `matchedQuoteAmount`가 0이어도 `MatchResult`를 생성한다. + +```java +// MemoryOrderBook.java +BigDecimal matchedQuoteAmount = maker.price() + .multiply(matchedQuantity) + .setScale(amountScale, RoundingMode.DOWN); +// matchedQuoteAmount == 0 체크 없음 +results.add(new MatchResult(..., matchedQuoteAmount)); +``` + +DB에는 `chk_trades_amounts CHECK (quote_amount > 0)` 제약이 있으므로, 커밋 시 `DataIntegrityViolationException`이 발생하고 트랜잭션 전체가 500으로 롤백된다. + +### 원인 + +PRD 9절에 명시된 정책이 구현되지 않았다. + +``` +rounding 후 trade.quote_amount가 0이 되는 체결은 만들지 않는다. +매칭 중 특정 후보와의 체결 결과가 zero-quote가 되면 해당 시점에서 매칭을 중단한다. +``` + +### 수정 + +```java +// planMatch() 루프 내부 +BigDecimal matchedQuoteAmount = maker.price() + .multiply(matchedQuantity) + .setScale(amountScale, RoundingMode.DOWN); + +if (matchedQuoteAmount.signum() == 0) break; // 추가 +``` + +매칭 중단 시 이미 체결된 부분이 있으면 taker를 `PARTIALLY_FILLED`로, 없으면 `OPEN`으로 오더북에 등록한다. zero-quote를 유발한 maker는 오더북에서 제거하지 않는다. + +### 검증 + +- `MemoryOrderBookTest`에 zero-quote 시 `MatchResult`를 생성하지 않고 기존 maker를 유지하는 단위 테스트를 추가했다. + +--- + +## Priority 2 — Dust maker 자동 취소 미구현 [BLOCKER] + +### 현상 + +체결 후 maker의 잔여 수량이 dust 상태가 되어도 취소 처리가 없다. + +### 원인 + +PRD 9절 정책이 구현되지 않았다. + +``` +체결 후 maker의 남은 수량이 dust가 되어 +DOWN(maker_price × maker_remaining, amount_scale) == 0이면 +해당 maker를 CANCELED 처리하고 잔여 locked를 해제한다. +원장에 ORDER_CANCEL_RELEASE를 기록한다. +``` + +### 수정 + +`OrderService.settle()` 내부, 각 체결의 wallet ledger 기록 직후: + +```java +// 각 trade 처리 후 dust 체크 +if (maker.getRemainingQuantity().signum() > 0) { + BigDecimal dustCheck = maker.getPrice() + .multiply(maker.getRemainingQuantity()) + .setScale(amountScale, RoundingMode.DOWN); + if (dustCheck.signum() == 0) { + Wallet makerLockedWallet = takerIsBuy ? sellerBaseWallet : buyerQuoteWallet; + BigDecimal dustRelease = maker.releasableAmount(); + makerLockedWallet.unlock(dustRelease); + maker.cancel(); + walletLedgerRepository.save(WalletLedger.create( + makerLockedWallet, LedgerType.ORDER_CANCEL_RELEASE, + dustRelease, dustRelease.negate(), + maker.getId(), tradeId + )); + eventRecorder.recordOrderCanceled(maker, maker.getLockedAsset(), dustRelease.toPlainString()); + } +} +``` + +### 검증 + +- `MatchingSettlementTest`에 dust maker 자동 취소, locked balance 해제, 오더북 제거를 검증하는 통합 테스트를 추가했다. + +--- + +## Priority 3 — Deposit API 프로필 미분리 [BLOCKER] + +### 현상 + +`POST /api/v1/wallets/deposit`이 운영 프로필에서도 열려 있다. 인증된 사용자라면 누구나 자신의 지갑 잔액을 직접 증가시킬 수 있다. + +### 원인 + +PRD는 입금을 명시적으로 MVP 제외 범위로 정의한다 (PRD.md line 97). + +``` +입금, 출금 — 제외 범위 +``` + +현재 구현은 seed balance 목적으로 열었지만, 운영 프로필 분리 없이 노출되어 있다. + +### 수정 + +**옵션 A — 완전 제거 (권장)**: 테스트는 Repository/Helper를 통한 seed로 처리한다. + +```java +// MatchingSettlementTest.setUp() 패턴으로 대체 +wallet.deposit(amount); +walletRepository.save(wallet); +``` + +**옵션 B — 프로필 가드**: + +```java +@Profile({"local", "dev", "test"}) +@RestController +... +public class WalletController { + @PostMapping("/deposit") + public WalletResponse deposit(...) { ... } +} +``` + +**옵션 C — dev 전용 컨트롤러 분리**: `DevWalletController`를 별도 파일로 분리하고 `@Profile("dev")` 적용. + +어느 방식이든 API 문서(API.md)에 "dev/test only"를 명시한다. + +--- + +## Priority 4 — API 응답/파라미터 명세 불일치 [IMPROVE] + +### 4-1. MarketResponse 필드 불일치 + +API.md 명세와 구현이 다르다. + +| 필드 | API.md | 구현 | +|------|--------|------| +| 시장 심볼 | `"market"` | `"symbol"` | +| `amountScale` | 있음 | **없음** | +| `cancelOnly` | 있음 | **없음** | + +```java +// 수정 전 +public record MarketResponse(String symbol, ...) + +// 수정 후 +public record MarketResponse( + String market, // symbol → market + String amountScale, // 추가 + boolean cancelOnly, // 추가 + ... +) +``` + +### 4-2. FillResponse 필드 불일치 + +| 필드 | API.md | 구현 | +|------|--------|------| +| `side` | 있음 | **없음** | +| `settled` | 있음 | **없음** | +| `liquidity` 값 | `"M"` / `"T"` | `"MAKER"` / `"TAKER"` | + +```java +// 수정 후 +public record FillResponse( + ... + String side, // "BUY" / "SELL" 추가 + String liquidity, // "MAKER" → "M", "TAKER" → "T" + boolean settled, // 항상 true (동일 트랜잭션 정산) + ... +) +``` + +### 4-3. GET /fills — orderId 필터 없음 + +API.md는 `orderId` 쿼리 파라미터를 지원한다고 명시한다. + +``` +GET /api/v1/fills?market=BTC-KRW&orderId=1001&limit=50 +``` + +`TradeController.getFills()`에 `orderId` 파라미터와 Repository 쿼리를 추가한다. + +### 4-4. GET /wallets/ledgers — limit 파라미터 없음 + +API.md는 `limit` 파라미터를 명시한다. + +``` +GET /api/v1/wallets/ledgers?asset=KRW&limit=50 +``` + +`WalletService.getLedgers()`에 `limit`/`offset` 또는 커서 기반 페이지네이션을 추가한다. + +--- + +## Priority 5 — OrderBook 가격 합산 및 depth 파라미터 미구현 [IMPROVE] + +### 현상 + +`OrderBookResponse.of()`가 같은 가격의 `OrderBookEntry`를 합산하지 않고 1:1로 노출한다. `depth` 파라미터도 없다. + +### 원인 + +API.md line 668 규칙이 구현되지 않았다. + +``` +같은 가격 주문은 합산 수량으로 응답한다. +depth: 가격 레벨 개수, 기본값 10 +``` + +### 수정 + +```java +// OrderBookResponse.of() +private static List aggregate(List entries, int depth) { + return entries.stream() + .collect(Collectors.groupingBy( + e -> e.price().toPlainString(), + Collectors.reducing(BigDecimal.ZERO, + OrderBookEntry::remainingQuantity, BigDecimal::add) + )) + .entrySet().stream() + .sorted(...) // 정렬 유지 + .limit(depth) + .map(e -> new PriceLevel(e.getKey(), e.getValue().toPlainString())) + .toList(); +} +``` + +```java +// MarketController +@GetMapping("/{market}/orderbook") +public OrderBookResponse getOrderBook( + @PathVariable String market, + @RequestParam(defaultValue = "10") @Min(1) @Max(100) int depth +) { ... } +``` + +--- + +## Priority 6 — cancelOrder() order row lock 없음 [IMPROVE] + +### 현상 + +취소 트랜잭션 내부에서 `findByIdAndUserId()`를 사용한다 (`SELECT FOR UPDATE` 없음). + +```java +// OrderService.java +Order lockedOrder = orderRepository.findByIdAndUserId(orderId, currentUserId) + .orElseThrow(...); // FOR UPDATE 없음 +``` + +### 원인 + +Plan.md가 명시한 계약이다. + +``` +4. order row lock (SELECT FOR UPDATE) +``` + +현재는 market lock이 직렬화를 보장하므로 실제 race는 없다. 하지만 설계 문서 계약과 다르고, market lock을 우회하는 경로가 추가될 경우 즉시 위험해진다. + +### 수정 + +```java +// OrderService.cancelOrder() 내부 +Order lockedOrder = orderRepository.findByIdWithLock(orderId) + .filter(o -> o.getUserId().equals(currentUserId)) + .orElseThrow(() -> new ApiException(ErrorCode.ORDER_NOT_FOUND)); +``` + +또는 `findByIdAndUserIdWithLock()` 쿼리를 Repository에 추가한다. + +--- + +## Priority 7 — wallet lock 순서 문서 계약 불일치 [IMPROVE] + +### 현상 + +`settle()`에서 wallet을 잠그는 순서가 문서 계약과 다르다. + +```java +// 현재 순서 (업무 순서 기준) +findByUserIdAndAssetWithLock(buyUserId, baseAsset) // buyerBase +findByUserIdAndAssetWithLock(sellUserId, quoteAsset) // sellerQuote +findByUserIdAndAssetWithLock(sellUserId, baseAsset) // sellerBase +findByUserIdAndAssetWithLock(buyUserId, quoteAsset) // buyerQuote +``` + +### 원인 + +ERD.md 계약이다. + +``` +여러 wallet을 동시에 잠글 때는 (user_id, asset) 오름차순으로 잠근다. +``` + +현재는 market lock 덕분에 데드락이 발생하지 않는다. 향후 cross-market 작업이 추가되면 위험해진다. + +### 수정 + +wallet ID 목록을 `(userId, asset)` 기준으로 정렬 후 일괄 잠금하는 헬퍼를 추가한다. + +```java +// WalletLockHelper 또는 WalletRepository 유틸 +List lockWalletsInOrder(List keys) { + return keys.stream() + .sorted(Comparator.comparingLong(WalletKey::userId) + .thenComparing(WalletKey::asset)) + .map(k -> walletRepository.findByUserIdAndAssetWithLock(k.userId(), k.asset())) + .toList(); +} +``` + +--- + +## Priority 8 — afterCommit 복구 설계 보강 [IMPROVE] + +### 현상 + +`afterCommit()`에서 오더북 반영 실패 시 로그만 남긴다. + +```java +// OrderService.java +} catch (Exception e) { + log.error("오더북 applyMatchPlan 실패: ...", e); +} +``` + +### 원인 + +Plan.md 계약이다. + +``` +commit 이후 오더북 반영 중 예외가 발생하면 해당 market의 오더북을 DB에서 재빌드한다. +재빌드도 실패하면 해당 market을 cancel_only = true로 전환하고 수동 복구를 기다린다. +``` + +### 수정 방향 + +단기 MVP 수준: + +1. `log.error` + Actuator metric increment (`meterRegistry.counter("orderbook.apply.failure")`) +2. 수동 트리거용 관리 엔드포인트 추가: + +``` +POST /actuator/orderbook/rebuild?marketId=1 +``` + +중기 (Phase 2): + +`ApplicationEventPublisher` + `@Transactional(propagation = REQUIRES_NEW)`으로 분리하여 재빌드 → 실패 시 `cancel_only = true` 업데이트를 별도 트랜잭션으로 처리한다. + +--- + +## Priority 9 — docker-compose Kafka KRaft 불일치 [IMPROVE] + +### 현상 + +v2/PRD.md는 KRaft(Zookeeper 없음)를 선택 이유로 명시했으나, `docker-compose.yml`은 Zookeeper 기반 Kafka를 사용한다. + +또한 `build.gradle`에 Kafka/WebSocket 의존성이 없어 앱 자체는 Kafka 없이 기동한다. + +### 수정 + +v2 구현 시작 시: + +1. `docker-compose.yml`에서 Zookeeper 제거, KRaft 모드 Kafka로 교체 +2. `build.gradle`에 `spring-kafka`, `spring-boot-starter-websocket` 추가 +3. v2/PRD.md의 docker-compose 설명과 실제 파일 일치 확인 + +--- + +## 요약 표 + +| # | 항목 | 심각도 | 상태 | +|---|------|--------|------| +| 1 | Zero-quote break (planMatch) | BLOCKER | 수정 완료 | +| 2 | Dust maker 자동 취소 | BLOCKER | 수정 완료 | +| 3 | Deposit API 프로필 가드/제거 | BLOCKER | 수정 완료 | +| 4 | API 응답/파라미터 명세 정합 | IMPROVE | 수정 완료 | +| 5 | OrderBook 가격 합산 + depth | IMPROVE | 수정 완료 | +| 6 | cancelOrder row lock | IMPROVE | 수정 완료 | +| 7 | wallet lock 정렬 | IMPROVE | 수정 완료 | +| 8 | afterCommit 복구 설계 보강 | IMPROVE | 수정 완료 | +| 9 | docker-compose KRaft 전환 | IMPROVE | 수정 완료 | + +위 항목은 현재 리팩토링에서 모두 처리 완료했다. From 0fdfa28e0df2c1c86ded6634ea4ee4a1daaa3de2 Mon Sep 17 00:00:00 2001 From: ohhalim Date: Fri, 15 May 2026 13:35:08 +0900 Subject: [PATCH 08/11] =?UTF-8?q?fix(order):=20=EB=A7=A4=EC=B9=AD=20?= =?UTF-8?q?=EC=A0=95=EC=82=B0=20=EB=9D=BD=20=EB=B0=8F=20=EC=98=A4=EB=8D=94?= =?UTF-8?q?=EB=B6=81=20=EB=B3=B5=EA=B5=AC=20=EB=B3=B4=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Zero-quote 체결 — planMatch 단계에서 매칭 중단 DOWN(price * matchedQuantity, amountScale) 결과가 0이면 MatchResult를 생성하지 않고 매칭을 중단하도록 수정. Dust maker 잔존 — 자동 취소 및 locked balance 해제 체결 후 maker 잔량의 quote value가 0이면 maker를 CANCELED 처리하고 남은 locked balance를 ORDER_CANCEL_RELEASE로 반환. Cancel order row lock — SELECT FOR UPDATE 적용 cancelOrder() 트랜잭션 내부 주문 조회를 findByIdAndUserIdWithLock()으로 교체해 취소 대상 order row를 잠그도록 수정. Wallet lock ordering — (userId, asset) 정렬 적용 정산 중 여러 wallet을 잠글 때 (userId, asset) 오름차순으로 잠그도록 lockWalletsInOrder() 추가. afterCommit 실패 복구 — DB 기반 오더북 재빌드 추가 applyMatchPlan() 실패 시 failure metric을 증가시키고 OPEN/PARTIALLY_FILLED 주문으로 오더북을 재빌드. Dead code — MemoryOrderBook.match() 제거 planMatch/applyMatchPlan 흐름으로 대체된 stateful match() 메서드 제거. --- .../com/coinflow/market/domain/Market.java | 4 + .../order/matching/MatchingEngine.java | 6 ++ .../order/matching/MemoryOrderBook.java | 58 +------------ .../matching/OrderBookRecoveryService.java | 77 ++++++++++++++++++ .../order/repository/OrderRepository.java | 6 ++ .../coinflow/order/service/OrderService.java | 81 +++++++++++++++++-- .../integration/MatchingSettlementTest.java | 43 ++++++++++ .../order/matching/MemoryOrderBookTest.java | 78 ++++++++++++++++++ 8 files changed, 290 insertions(+), 63 deletions(-) create mode 100644 src/main/java/com/coinflow/order/matching/OrderBookRecoveryService.java create mode 100644 src/test/java/com/coinflow/order/matching/MemoryOrderBookTest.java diff --git a/src/main/java/com/coinflow/market/domain/Market.java b/src/main/java/com/coinflow/market/domain/Market.java index 7f70b33..438d86b 100644 --- a/src/main/java/com/coinflow/market/domain/Market.java +++ b/src/main/java/com/coinflow/market/domain/Market.java @@ -40,4 +40,8 @@ public class Market { public boolean isActive() { return status == MarketStatus.ACTIVE; } + + public void enableCancelOnly() { + this.cancelOnly = true; + } } diff --git a/src/main/java/com/coinflow/order/matching/MatchingEngine.java b/src/main/java/com/coinflow/order/matching/MatchingEngine.java index 22eb7f8..6ee83fc 100644 --- a/src/main/java/com/coinflow/order/matching/MatchingEngine.java +++ b/src/main/java/com/coinflow/order/matching/MatchingEngine.java @@ -46,6 +46,12 @@ public void addToBook(Market market, Order order) { book.add(order); } + public void rebuildBook(Market market, List orders) { + MemoryOrderBook book = new MemoryOrderBook(market.getAmountScale()); + orders.forEach(book::add); + orderBooks.put(market.getSymbol(), book); + } + public boolean hasSelfTrade(String marketSymbol, OrderSide side, BigDecimal price, Long userId) { MemoryOrderBook book = orderBooks.get(marketSymbol); return book != null && book.hasSelfTrade(side, price, userId); diff --git a/src/main/java/com/coinflow/order/matching/MemoryOrderBook.java b/src/main/java/com/coinflow/order/matching/MemoryOrderBook.java index b9c06d0..54be0f3 100644 --- a/src/main/java/com/coinflow/order/matching/MemoryOrderBook.java +++ b/src/main/java/com/coinflow/order/matching/MemoryOrderBook.java @@ -30,61 +30,6 @@ public MemoryOrderBook(int amountScale) { this.amountScale = amountScale; } - public List match(Order taker) { - List results = new ArrayList<>(); - PriorityQueue makerQueue = (taker.getSide() == OrderSide.BUY) ? sellQueue : buyQueue; - - BigDecimal takerRemaining = taker.getRemainingQuantity(); - - while (!makerQueue.isEmpty() && takerRemaining.compareTo(BigDecimal.ZERO) > 0) { - OrderBookEntry maker = makerQueue.peek(); - - boolean priceMatches = (taker.getSide() == OrderSide.BUY) - ? taker.getPrice().compareTo(maker.price()) >= 0 - : taker.getPrice().compareTo(maker.price()) <= 0; - - if (!priceMatches) break; - - // self trade 방지 - if (maker.userId().equals(taker.getUserId())) { - break; - } - - makerQueue.poll(); - - BigDecimal matchedQuantity = takerRemaining.min(maker.remainingQuantity()); - BigDecimal matchedQuoteAmount = maker.price() - .multiply(matchedQuantity) - .setScale(amountScale, RoundingMode.DOWN); - - boolean isTakerBuy = taker.getSide() == OrderSide.BUY; - results.add(new MatchResult( - maker.orderId(), - taker.getId(), - isTakerBuy ? taker.getId() : maker.orderId(), - isTakerBuy ? maker.orderId() : taker.getId(), - maker.userId(), - taker.getUserId(), - isTakerBuy ? taker.getUserId() : maker.userId(), - isTakerBuy ? maker.userId() : taker.getUserId(), - maker.price(), - matchedQuantity, - matchedQuoteAmount - )); - - takerRemaining = takerRemaining.subtract(matchedQuantity); - - BigDecimal makerRemaining = maker.remainingQuantity().subtract(matchedQuantity); - if (makerRemaining.compareTo(BigDecimal.ZERO) > 0) { - makerQueue.add(new OrderBookEntry( - maker.orderId(), maker.userId(), maker.price(), makerRemaining, maker.sequence() - )); - } - } - - return results; - } - public List planMatch(Order taker) { PriorityQueue makerQueue = (taker.getSide() == OrderSide.BUY) ? sellQueue : buyQueue; PriorityQueue simulation = new PriorityQueue<>(makerQueue); @@ -112,6 +57,9 @@ public List planMatch(Order taker) { .multiply(matchedQuantity) .setScale(amountScale, RoundingMode.DOWN); + // PRD 9절: zero-quote 체결은 만들지 않고 매칭 중단 + if (matchedQuoteAmount.signum() == 0) break; + boolean isTakerBuy = taker.getSide() == OrderSide.BUY; results.add(new MatchResult( maker.orderId(), diff --git a/src/main/java/com/coinflow/order/matching/OrderBookRecoveryService.java b/src/main/java/com/coinflow/order/matching/OrderBookRecoveryService.java new file mode 100644 index 0000000..836a0c3 --- /dev/null +++ b/src/main/java/com/coinflow/order/matching/OrderBookRecoveryService.java @@ -0,0 +1,77 @@ +package com.coinflow.order.matching; + +import com.coinflow.market.domain.Market; +import com.coinflow.market.repository.MarketRepository; +import com.coinflow.order.domain.Order; +import com.coinflow.order.domain.OrderStatus; +import com.coinflow.order.repository.OrderRepository; +import io.micrometer.core.instrument.MeterRegistry; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.TransactionDefinition; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.support.TransactionTemplate; + +import java.util.List; + +@Slf4j +@Service +public class OrderBookRecoveryService { + + private final MarketRepository marketRepository; + private final OrderRepository orderRepository; + private final MatchingEngine matchingEngine; + private final MeterRegistry meterRegistry; + private final PlatformTransactionManager transactionManager; + + public OrderBookRecoveryService( + MarketRepository marketRepository, + OrderRepository orderRepository, + MatchingEngine matchingEngine, + MeterRegistry meterRegistry, + PlatformTransactionManager transactionManager + ) { + this.marketRepository = marketRepository; + this.orderRepository = orderRepository; + this.matchingEngine = matchingEngine; + this.meterRegistry = meterRegistry; + this.transactionManager = transactionManager; + } + + public void rebuildAfterApplyFailure(Long marketId) { + meterRegistry.counter("orderbook.apply.failure", "marketId", marketId.toString()).increment(); + + try { + TransactionTemplate template = new TransactionTemplate(transactionManager); + template.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW); + template.executeWithoutResult(status -> rebuildMarketOrderBook(marketId)); + } catch (Exception rebuildFailure) { + log.error("오더북 재빌드 실패: marketId={}, cancelOnly 전환 시도", marketId, rebuildFailure); + enableCancelOnly(marketId); + } + } + + private void rebuildMarketOrderBook(Long marketId) { + Market market = marketRepository.findById(marketId).orElseThrow(); + List openOrders = orderRepository.findAllByMarketIdAndStatusInOrderBySequenceAsc( + marketId, + List.of(OrderStatus.OPEN, OrderStatus.PARTIALLY_FILLED) + ); + matchingEngine.rebuildBook(market, openOrders); + log.info("OrderBook rebuilt after apply failure: market={}, openOrders={}", market.getSymbol(), openOrders.size()); + } + + private void enableCancelOnly(Long marketId) { + try { + TransactionTemplate template = new TransactionTemplate(transactionManager); + template.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW); + template.executeWithoutResult(status -> { + Market market = marketRepository.findById(marketId).orElseThrow(); + market.enableCancelOnly(); + log.error("Market switched to cancelOnly after orderbook recovery failure: market={}", market.getSymbol()); + }); + } catch (Exception cancelOnlyFailure) { + log.error("cancelOnly 전환 실패: marketId={}", marketId, cancelOnlyFailure); + } + } +} diff --git a/src/main/java/com/coinflow/order/repository/OrderRepository.java b/src/main/java/com/coinflow/order/repository/OrderRepository.java index f370887..500edf0 100644 --- a/src/main/java/com/coinflow/order/repository/OrderRepository.java +++ b/src/main/java/com/coinflow/order/repository/OrderRepository.java @@ -22,9 +22,15 @@ public interface OrderRepository extends JpaRepository { @Query("SELECT o FROM Order o WHERE o.id = :id") Optional findByIdWithLock(@Param("id") Long id); + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("SELECT o FROM Order o WHERE o.id = :id AND o.userId = :userId") + Optional findByIdAndUserIdWithLock(@Param("id") Long id, @Param("userId") Long userId); + List findAllByUserIdOrderByCreatedAtDesc(Long userId, Pageable pageable); List findAllByUserIdAndMarketSymbolOrderByCreatedAtDesc(Long userId, String marketSymbol, Pageable pageable); List findAllByStatusInOrderBySequenceAsc(List statuses); + + List findAllByMarketIdAndStatusInOrderBySequenceAsc(Long marketId, List statuses); } diff --git a/src/main/java/com/coinflow/order/service/OrderService.java b/src/main/java/com/coinflow/order/service/OrderService.java index 0bf7a1b..4cf7d78 100644 --- a/src/main/java/com/coinflow/order/service/OrderService.java +++ b/src/main/java/com/coinflow/order/service/OrderService.java @@ -17,6 +17,7 @@ import com.coinflow.event.service.DomainEventRecorder; import com.coinflow.order.matching.MatchResult; import com.coinflow.order.matching.MatchingEngine; +import com.coinflow.order.matching.OrderBookRecoveryService; import com.coinflow.order.repository.OrderRepository; import com.coinflow.order.repository.OrderSequenceRepository; import com.coinflow.trade.domain.Trade; @@ -38,8 +39,10 @@ import java.math.BigDecimal; import java.math.RoundingMode; import java.util.ArrayList; +import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.stream.Stream; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.locks.ReentrantLock; @@ -54,6 +57,7 @@ public class OrderService { private final TradeRepository tradeRepository; private final WalletLedgerRepository walletLedgerRepository; private final MatchingEngine matchingEngine; + private final OrderBookRecoveryService orderBookRecoveryService; private final DomainEventRecorder eventRecorder; private final TransactionTemplate transactionTemplate; @@ -67,6 +71,7 @@ public OrderService( TradeRepository tradeRepository, WalletLedgerRepository walletLedgerRepository, MatchingEngine matchingEngine, + OrderBookRecoveryService orderBookRecoveryService, DomainEventRecorder eventRecorder, PlatformTransactionManager transactionManager ) { @@ -77,6 +82,7 @@ public OrderService( this.tradeRepository = tradeRepository; this.walletLedgerRepository = walletLedgerRepository; this.matchingEngine = matchingEngine; + this.orderBookRecoveryService = orderBookRecoveryService; this.eventRecorder = eventRecorder; this.transactionTemplate = new TransactionTemplate(transactionManager); } @@ -165,7 +171,8 @@ public CreateOrderResponse createOrder(Long currentUserId, CreateOrderRequest re // 매칭 계획 수립 (큐 미변경), 정산 List plan = matchingEngine.planMatch(market, order); - List trades = settle(market, order, plan); + List autoCanceledMakers = new ArrayList<>(); + List trades = settle(market, order, plan, autoCanceledMakers); // 커밋 성공 후 오더북 반영 — DB 롤백 시 큐는 그대로 TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { @@ -173,8 +180,11 @@ public CreateOrderResponse createOrder(Long currentUserId, CreateOrderRequest re public void afterCommit() { try { matchingEngine.applyMatchPlan(market, order, plan); + autoCanceledMakers.forEach(canceledMaker -> + matchingEngine.cancelOrder(market.getSymbol(), canceledMaker)); } catch (Exception e) { - log.error("오더북 applyMatchPlan 실패: orderId={}, 서버 재시작 또는 DB 체결 내역으로 오더북 재구성 필요", order.getId(), e); + log.error("오더북 applyMatchPlan 실패: orderId={}, DB 체결 내역 기반 재빌드 시도", order.getId(), e); + orderBookRecoveryService.rebuildAfterApplyFailure(market.getId()); } } }); @@ -198,7 +208,7 @@ public CancelOrderResponse cancelOrder(Long currentUserId, Long orderId) { try { return transactionTemplate.execute(status -> { - Order lockedOrder = orderRepository.findByIdAndUserId(orderId, currentUserId) + Order lockedOrder = orderRepository.findByIdAndUserIdWithLock(orderId, currentUserId) .orElseThrow(() -> new ApiException(ErrorCode.ORDER_NOT_FOUND)); if (!lockedOrder.isCancelable()) throw new ApiException(ErrorCode.ORDER_NOT_CANCELABLE); @@ -256,7 +266,7 @@ public List getOrders(Long currentUserId, String market, i return orders.stream().map(OrderSummaryResponse::from).toList(); } - private List settle(Market market, Order taker, List matchResults) { + private List settle(Market market, Order taker, List matchResults, List autoCanceledMakers) { if (matchResults.isEmpty()) return List.of(); List trades = new ArrayList<>(); @@ -280,10 +290,21 @@ private List settle(Market market, Order taker, List matchRe BigDecimal buyerReleased = oldBuyLocked.subtract(buyOrder.getLockedAmount()); BigDecimal buyerRefund = buyerReleased.subtract(result.quoteAmount()); - Wallet buyerBaseWallet = walletRepository.findByUserIdAndAssetWithLock(result.buyUserId(), market.getBaseAsset()).orElseThrow(); - Wallet sellerQuoteWallet = walletRepository.findByUserIdAndAssetWithLock(result.sellUserId(), market.getQuoteAsset()).orElseThrow(); - Wallet sellerBaseWallet = walletRepository.findByUserIdAndAssetWithLock(result.sellUserId(), market.getBaseAsset()).orElseThrow(); - Wallet buyerQuoteWallet = walletRepository.findByUserIdAndAssetWithLock(result.buyUserId(), market.getQuoteAsset()).orElseThrow(); + WalletKey buyerBaseKey = new WalletKey(result.buyUserId(), market.getBaseAsset()); + WalletKey sellerQuoteKey = new WalletKey(result.sellUserId(), market.getQuoteAsset()); + WalletKey sellerBaseKey = new WalletKey(result.sellUserId(), market.getBaseAsset()); + WalletKey buyerQuoteKey = new WalletKey(result.buyUserId(), market.getQuoteAsset()); + Map wallets = lockWalletsInOrder( + buyerBaseKey, + sellerQuoteKey, + sellerBaseKey, + buyerQuoteKey + ); + + Wallet buyerBaseWallet = wallets.get(buyerBaseKey); + Wallet sellerQuoteWallet = wallets.get(sellerQuoteKey); + Wallet sellerBaseWallet = wallets.get(sellerBaseKey); + Wallet buyerQuoteWallet = wallets.get(buyerQuoteKey); buyerQuoteWallet.consumeLocked(buyerReleased); if (buyerRefund.compareTo(BigDecimal.ZERO) > 0) { @@ -331,6 +352,26 @@ private List settle(Market market, Order taker, List matchRe sellOrderId, tradeId )); + // Dust maker 자동 취소 (PRD 9절): 체결 후 남은 수량의 quote value가 0이면 잔여 lock 해제 후 CANCELED + if (maker.getRemainingQuantity().signum() > 0) { + BigDecimal dustCheck = maker.getPrice() + .multiply(maker.getRemainingQuantity()) + .setScale(market.getAmountScale(), RoundingMode.DOWN); + if (dustCheck.signum() == 0) { + Wallet makerLockedWallet = takerIsBuy ? sellerBaseWallet : buyerQuoteWallet; + BigDecimal dustRelease = maker.releasableAmount(); + makerLockedWallet.unlock(dustRelease); + maker.cancel(); + autoCanceledMakers.add(maker); + walletLedgerRepository.save(WalletLedger.create( + makerLockedWallet, LedgerType.ORDER_CANCEL_RELEASE, + dustRelease, dustRelease.negate(), + maker.getId(), tradeId + )); + eventRecorder.recordOrderCanceled(maker, maker.getLockedAsset(), dustRelease.toPlainString()); + } + } + eventRecorder.recordSettlementCompleted(trade); trades.add(trade); } @@ -338,6 +379,30 @@ private List settle(Market market, Order taker, List matchRe return trades; } + private Map lockWalletsInOrder(WalletKey... keys) { + List sortedKeys = Stream.of(keys) + .distinct() + .sorted() + .toList(); + + Map wallets = new HashMap<>(); + for (WalletKey key : sortedKeys) { + Wallet wallet = walletRepository.findByUserIdAndAssetWithLock(key.userId(), key.asset()) + .orElseThrow(() -> new ApiException(ErrorCode.WALLET_NOT_FOUND)); + wallets.put(key, wallet); + } + return wallets; + } + + private record WalletKey(Long userId, String asset) implements Comparable { + @Override + public int compareTo(WalletKey other) { + int userCompare = this.userId.compareTo(other.userId); + if (userCompare != 0) return userCompare; + return this.asset.compareTo(other.asset); + } + } + private OrderSide parseSide(String value) { try { return OrderSide.valueOf(value); } catch (IllegalArgumentException e) { throw new ApiException(ErrorCode.INVALID_ORDER_SIDE); } diff --git a/src/test/java/com/coinflow/integration/MatchingSettlementTest.java b/src/test/java/com/coinflow/integration/MatchingSettlementTest.java index 7691d05..9180e55 100644 --- a/src/test/java/com/coinflow/integration/MatchingSettlementTest.java +++ b/src/test/java/com/coinflow/integration/MatchingSettlementTest.java @@ -277,6 +277,49 @@ void setUp() { assertThat(findWallet(seller2.getId(), "KRW").getAvailableBalance()).isEqualByComparingTo("0"); } + // ── DUST_MAKER_001: 체결 후 zero-quote dust maker 자동 취소 ───────── + // SELL 1.0001 BTC at 9999 KRW → buyer1 BUY 1.0 체결 → seller 잔여 0.0001 BTC + // 9999 × 0.0001 = 0.9999 → DOWN(amountScale=0) = 0 → 잔여 주문 취소 + lock release + + @Test + void DUST_MAKER_001_체결_후_zero_quote_잔량은_자동_취소되고_lock이_해제됨() { + String sellerToken = signupAndLogin("zq001-seller@example.com"); + String buyer1Token = signupAndLogin("zq001-buyer1@example.com"); + String buyer2Token = signupAndLogin("zq001-buyer2@example.com"); + depositBtc("zq001-seller@example.com", new BigDecimal("2")); + depositKrw("zq001-buyer1@example.com", new BigDecimal("20000")); + depositKrw("zq001-buyer2@example.com", new BigDecimal("200000")); + + // maker: SELL 1.0001 BTC at 9999 (minOrderAmount: 9999*1.0001=9999.9999 ≥ 5000 ✓) + var sellResponse = createOrder(sellerToken, "BTC-KRW", "SELL", "LIMIT", "GTC", "9999", "1.0001", null); + Long sellOrderId = ((Number) sellResponse.getBody().get("orderId")).longValue(); + + // buyer1: BUY 1.0 BTC at 9999 → partial fill, seller 잔여 0.0001 BTC + var buyer1Response = createOrder(buyer1Token, "BTC-KRW", "BUY", "LIMIT", "GTC", "9999", "1.0000", null); + assertThat(buyer1Response.getStatusCode()).isEqualTo(HttpStatus.CREATED); + assertThat(buyer1Response.getBody().get("status")).isEqualTo("FILLED"); + + var sellerOrder = orderRepository.findById(sellOrderId).orElseThrow(); + assertThat(sellerOrder.getStatus().name()).isEqualTo("CANCELED"); + + var seller = userRepository.findByEmail("zq001-seller@example.com").orElseThrow(); + assertThat(findWallet(seller.getId(), "BTC").getAvailableBalance()) + .isEqualByComparingTo("1.0000"); + assertThat(findWallet(seller.getId(), "BTC").getLockedBalance()) + .isEqualByComparingTo("0"); + assertThat(matchingEngine.getSellSide("BTC-KRW")).isEmpty(); + + long tradeCountBefore = tradeRepository.count(); + + // buyer2: 남은 SELL이 없으므로 OPEN 등록, 신규 체결 없음 + var buyer2Response = createOrder(buyer2Token, "BTC-KRW", "BUY", "LIMIT", "GTC", "9999", "10", null); + assertThat(buyer2Response.getStatusCode()).isEqualTo(HttpStatus.CREATED); + assertThat(buyer2Response.getBody().get("status")).isEqualTo("OPEN"); + + // buyer2와 신규 체결 없음 + assertThat(tradeRepository.count()).isEqualTo(tradeCountBefore); + } + // ── 불변식: wallet 잔고 음수 불가 ──────────────────────────────── @Test diff --git a/src/test/java/com/coinflow/order/matching/MemoryOrderBookTest.java b/src/test/java/com/coinflow/order/matching/MemoryOrderBookTest.java new file mode 100644 index 0000000..2ac1859 --- /dev/null +++ b/src/test/java/com/coinflow/order/matching/MemoryOrderBookTest.java @@ -0,0 +1,78 @@ +package com.coinflow.order.matching; + +import com.coinflow.order.domain.Order; +import com.coinflow.order.domain.OrderSide; +import com.coinflow.order.domain.OrderType; +import com.coinflow.order.domain.TimeInForce; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; + +import java.math.BigDecimal; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class MemoryOrderBookTest { + + @Test + void planMatch_zero_quote_체결은_생성하지_않고_매칭을_중단한다() { + MemoryOrderBook orderBook = new MemoryOrderBook(0); + Order maker = order( + 1L, + 10L, + OrderSide.SELL, + "9999", + "0.0001", + "BTC", + "0.0001", + 1L + ); + Order taker = order( + 2L, + 20L, + OrderSide.BUY, + "9999", + "10", + "KRW", + "99990", + 2L + ); + + orderBook.add(maker); + + List plan = orderBook.planMatch(taker); + + assertThat(plan).isEmpty(); + assertThat(orderBook.getSellSide()) + .extracting(OrderBookEntry::orderId) + .containsExactly(10L); + } + + private Order order( + Long userId, + Long orderId, + OrderSide side, + String price, + String quantity, + String lockedAsset, + String lockedAmount, + Long sequence + ) { + Order order = Order.create( + userId, + 1L, + "BTC-KRW", + side, + OrderType.LIMIT, + TimeInForce.GTC, + new BigDecimal(price), + new BigDecimal(quantity), + lockedAsset, + new BigDecimal(lockedAmount), + sequence, + null + ); + ReflectionTestUtils.setField(order, "id", orderId); + return order; + } +} From 1af42a6d0b989dee6ffa915af459823e6d4d20b8 Mon Sep 17 00:00:00 2001 From: ohhalim Date: Fri, 15 May 2026 13:35:20 +0900 Subject: [PATCH 09/11] =?UTF-8?q?fix(wallet):=20=EC=9E=85=EA=B8=88=20API?= =?UTF-8?q?=20prod=20=EC=A0=9C=EC=99=B8=20=EB=B0=8F=20=EC=9B=90=EC=9E=A5?= =?UTF-8?q?=20limit=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deposit API 운영 노출 — prod 프로필 제외 POST /api/v1/wallets/deposit을 WalletController에서 분리하고 DevWalletController에 @Profile("!prod") 적용. MVP 범위 불일치 — seed API를 개발/테스트 용도로 제한 입출금은 MVP 제외 범위이므로 운영 환경에서 인증 사용자가 임의로 잔액을 증가시킬 수 없도록 수정. Ledger 조회 limit — Pageable 조회 적용 GET /api/v1/wallets/ledgers?limit=50 명세에 맞게 repository와 service에 Pageable 기반 조회 추가. 회귀 테스트 — ledger limit 검증 추가 원장 조회 시 limit 값만큼 결과가 제한되는지 테스트 추가. --- .../wallet/api/DevWalletController.java | 35 +++++++++++++++++++ .../coinflow/wallet/api/WalletController.java | 22 ++++-------- .../repository/WalletLedgerRepository.java | 5 +++ .../wallet/service/WalletService.java | 8 +++-- .../com/coinflow/wallet/WalletApiTest.java | 33 +++++++++++++++-- 5 files changed, 83 insertions(+), 20 deletions(-) create mode 100644 src/main/java/com/coinflow/wallet/api/DevWalletController.java diff --git a/src/main/java/com/coinflow/wallet/api/DevWalletController.java b/src/main/java/com/coinflow/wallet/api/DevWalletController.java new file mode 100644 index 0000000..aca217b --- /dev/null +++ b/src/main/java/com/coinflow/wallet/api/DevWalletController.java @@ -0,0 +1,35 @@ +package com.coinflow.wallet.api; + +import com.coinflow.wallet.dto.DepositRequest; +import com.coinflow.wallet.dto.WalletResponse; +import com.coinflow.wallet.service.WalletService; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Profile; +import org.springframework.http.HttpStatus; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +@Profile("!prod") +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1/wallets") +public class DevWalletController { + + private final WalletService walletService; + + @PostMapping("/deposit") + @ResponseStatus(HttpStatus.OK) + public WalletResponse deposit( + @AuthenticationPrincipal Jwt jwt, + @Valid @RequestBody DepositRequest request + ) { + Long userId = Long.parseLong(jwt.getSubject()); + return walletService.deposit(userId, request); + } +} diff --git a/src/main/java/com/coinflow/wallet/api/WalletController.java b/src/main/java/com/coinflow/wallet/api/WalletController.java index 0b24d93..eb55929 100644 --- a/src/main/java/com/coinflow/wallet/api/WalletController.java +++ b/src/main/java/com/coinflow/wallet/api/WalletController.java @@ -1,14 +1,14 @@ package com.coinflow.wallet.api; -import com.coinflow.wallet.dto.DepositRequest; import com.coinflow.wallet.dto.WalletLedgerResponse; import com.coinflow.wallet.dto.WalletResponse; import com.coinflow.wallet.service.WalletService; -import jakarta.validation.Valid; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; import lombok.RequiredArgsConstructor; -import org.springframework.http.HttpStatus; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.*; import java.util.List; @@ -16,20 +16,11 @@ @RestController @RequiredArgsConstructor @RequestMapping("/api/v1/wallets") +@Validated public class WalletController { private final WalletService walletService; - @PostMapping("/deposit") - @ResponseStatus(HttpStatus.OK) - public WalletResponse deposit( - @AuthenticationPrincipal Jwt jwt, - @Valid @RequestBody DepositRequest request - ) { - Long userId = Long.parseLong(jwt.getSubject()); - return walletService.deposit(userId, request); - } - @GetMapping public List getWallets(@AuthenticationPrincipal Jwt jwt) { Long userId = Long.parseLong(jwt.getSubject()); @@ -39,9 +30,10 @@ public List getWallets(@AuthenticationPrincipal Jwt jwt) { @GetMapping("/ledgers") public List getLedgers( @AuthenticationPrincipal Jwt jwt, - @RequestParam(required = false) String asset + @RequestParam(required = false) String asset, + @RequestParam(defaultValue = "50") @Min(1) @Max(200) int limit ) { Long userId = Long.parseLong(jwt.getSubject()); - return walletService.getLedgers(userId, asset); + return walletService.getLedgers(userId, asset, limit); } } diff --git a/src/main/java/com/coinflow/wallet/repository/WalletLedgerRepository.java b/src/main/java/com/coinflow/wallet/repository/WalletLedgerRepository.java index 1af3757..5508427 100644 --- a/src/main/java/com/coinflow/wallet/repository/WalletLedgerRepository.java +++ b/src/main/java/com/coinflow/wallet/repository/WalletLedgerRepository.java @@ -1,6 +1,7 @@ package com.coinflow.wallet.repository; import com.coinflow.wallet.domain.WalletLedger; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import java.util.List; @@ -10,4 +11,8 @@ public interface WalletLedgerRepository extends JpaRepository findAllByUserIdOrderByCreatedAtDesc(Long userId); List findAllByUserIdAndAssetOrderByCreatedAtDesc(Long userId, String asset); + + List findAllByUserIdOrderByCreatedAtDesc(Long userId, Pageable pageable); + + List findAllByUserIdAndAssetOrderByCreatedAtDesc(Long userId, String asset, Pageable pageable); } diff --git a/src/main/java/com/coinflow/wallet/service/WalletService.java b/src/main/java/com/coinflow/wallet/service/WalletService.java index df9a11f..a710139 100644 --- a/src/main/java/com/coinflow/wallet/service/WalletService.java +++ b/src/main/java/com/coinflow/wallet/service/WalletService.java @@ -10,6 +10,7 @@ import com.coinflow.wallet.repository.WalletLedgerRepository; import com.coinflow.wallet.repository.WalletRepository; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -56,10 +57,11 @@ public WalletResponse deposit(Long userId, DepositRequest request) { } @Transactional(readOnly = true) - public List getLedgers(Long userId, String asset) { + public List getLedgers(Long userId, String asset, int limit) { + var pageable = PageRequest.of(0, limit); var ledgers = (asset != null) - ? walletLedgerRepository.findAllByUserIdAndAssetOrderByCreatedAtDesc(userId, asset) - : walletLedgerRepository.findAllByUserIdOrderByCreatedAtDesc(userId); + ? walletLedgerRepository.findAllByUserIdAndAssetOrderByCreatedAtDesc(userId, asset, pageable) + : walletLedgerRepository.findAllByUserIdOrderByCreatedAtDesc(userId, pageable); return ledgers.stream().map(WalletLedgerResponse::from).toList(); } } diff --git a/src/test/java/com/coinflow/wallet/WalletApiTest.java b/src/test/java/com/coinflow/wallet/WalletApiTest.java index 10dc74a..ad1563f 100644 --- a/src/test/java/com/coinflow/wallet/WalletApiTest.java +++ b/src/test/java/com/coinflow/wallet/WalletApiTest.java @@ -110,6 +110,23 @@ void setUp() { assertThat(ledgers).allMatch(l -> "KRW".equals(l.get("asset"))); } + @Test + void 원장_조회_limit_적용() { + String token = signupAndLogin("wallet003b@example.com"); + depositKrw("wallet003b@example.com", new BigDecimal("10000000")); + + var createResponse = createOrder(token, "BTC-KRW", "BUY", "LIMIT", "GTC", "100000000", "0.0001", null); + Long orderId = ((Number) createResponse.getBody().get("orderId")).longValue(); + cancelOrder(token, orderId); + createOrder(token, "BTC-KRW", "BUY", "LIMIT", "GTC", "100000000", "0.0001", null); + + var response = getLedgers(token, "KRW", 2); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat((List) response.getBody()).hasSize(2); + } + + // ── LED-001 원장 기록 검증 ──────────────────────────────────────── @Test @@ -331,9 +348,21 @@ private ResponseEntity getWallets(String token) { } private ResponseEntity getLedgers(String token, String asset) { + return getLedgers(token, asset, null); + } + + private ResponseEntity getLedgers(String token, String asset, Integer limit) { HttpHeaders headers = new HttpHeaders(); headers.setBearerAuth(token); - String url = asset != null ? "/api/v1/wallets/ledgers?asset=" + asset : "/api/v1/wallets/ledgers"; - return restTemplate.exchange(url, HttpMethod.GET, new HttpEntity<>(headers), List.class); + StringBuilder url = new StringBuilder("/api/v1/wallets/ledgers"); + String separator = "?"; + if (asset != null) { + url.append(separator).append("asset=").append(asset); + separator = "&"; + } + if (limit != null) { + url.append(separator).append("limit=").append(limit); + } + return restTemplate.exchange(url.toString(), HttpMethod.GET, new HttpEntity<>(headers), List.class); } } From d1277d9875cc28f22441b48807fd92ac34b0bc7f Mon Sep 17 00:00:00 2001 From: ohhalim Date: Fri, 15 May 2026 13:35:34 +0900 Subject: [PATCH 10/11] =?UTF-8?q?feat(api):=20=EC=8B=9C=EC=9E=A5=20?= =?UTF-8?q?=EC=B2=B4=EA=B2=B0=20=EC=98=A4=EB=8D=94=EB=B6=81=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20=EB=AA=85=EC=84=B8=20=EC=A0=95=ED=95=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Market response 필드 불일치 — market/amountScale/cancelOnly 추가 API.md 명세에 맞게 symbol 응답을 market으로 변경하고 amountScale, cancelOnly 필드를 추가. OrderBook 가격 레벨 — 같은 가격 주문 수량 합산 개별 주문을 그대로 노출하던 응답을 가격별 합산 수량으로 변경. OrderBook depth — 가격 레벨 개수 제한 추가 GET /api/v1/markets/{market}/orderbook?depth=10 파라미터를 지원하도록 수정. Fill response 명세 불일치 — side/settled/M·T liquidity 적용 사용자 관점 체결 응답에 side와 settled=true를 추가하고 liquidity 값을 MAKER/TAKER에서 M/T로 변경. Fill orderId 필터 — 사용자 주문 검증 추가 GET /api/v1/fills?orderId=... 필터를 추가하고 본인 주문이 아니면 ORDER_NOT_FOUND를 반환하도록 수정. 회귀 테스트 — API 명세 정합 검증 추가 market 응답 필드, orderbook 합산/depth, fill 응답 필드, orderId 필터 테스트 추가. --- .../coinflow/market/api/MarketController.java | 12 +++- .../coinflow/market/dto/MarketResponse.java | 10 ++- .../market/dto/OrderBookResponse.java | 38 +++++++--- .../coinflow/trade/api/TradeController.java | 13 +++- .../com/coinflow/trade/dto/FillResponse.java | 6 +- .../trade/repository/TradeRepository.java | 23 ++++-- .../java/com/coinflow/query/QueryApiTest.java | 70 +++++++++++++++++-- 7 files changed, 142 insertions(+), 30 deletions(-) diff --git a/src/main/java/com/coinflow/market/api/MarketController.java b/src/main/java/com/coinflow/market/api/MarketController.java index 85c8e93..f660a5a 100644 --- a/src/main/java/com/coinflow/market/api/MarketController.java +++ b/src/main/java/com/coinflow/market/api/MarketController.java @@ -11,9 +11,13 @@ import com.coinflow.order.matching.OrderBookEntry; import com.coinflow.order.service.OrderService; import lombok.RequiredArgsConstructor; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import java.util.List; @@ -22,6 +26,7 @@ @RestController @RequiredArgsConstructor @RequestMapping("/api/v1/markets") +@Validated public class MarketController { private final MarketRepository marketRepository; @@ -37,7 +42,10 @@ public List getMarkets() { } @GetMapping("/{market}/orderbook") - public OrderBookResponse getOrderBook(@PathVariable String market) { + public OrderBookResponse getOrderBook( + @PathVariable String market, + @RequestParam(defaultValue = "10") @Min(1) @Max(100) int depth + ) { Market found = marketRepository.findBySymbol(market) .orElseThrow(() -> new ApiException(ErrorCode.MARKET_NOT_FOUND)); @@ -46,7 +54,7 @@ public OrderBookResponse getOrderBook(@PathVariable String market) { try { List buySide = matchingEngine.getBuySide(market); List sellSide = matchingEngine.getSellSide(market); - return OrderBookResponse.of(market, buySide, sellSide); + return OrderBookResponse.of(market, buySide, sellSide, depth); } finally { lock.unlock(); } diff --git a/src/main/java/com/coinflow/market/dto/MarketResponse.java b/src/main/java/com/coinflow/market/dto/MarketResponse.java index 664b506..e33db10 100644 --- a/src/main/java/com/coinflow/market/dto/MarketResponse.java +++ b/src/main/java/com/coinflow/market/dto/MarketResponse.java @@ -3,15 +3,17 @@ import com.coinflow.market.domain.Market; public record MarketResponse( - String symbol, + String market, String displayName, String baseAsset, String quoteAsset, + int amountScale, String tickSize, String stepSize, String minOrderQuantity, String minOrderAmount, - String status + String status, + boolean cancelOnly ) { public static MarketResponse from(Market market) { return new MarketResponse( @@ -19,11 +21,13 @@ public static MarketResponse from(Market market) { market.getDisplayName(), market.getBaseAsset(), market.getQuoteAsset(), + market.getAmountScale(), market.getTickSize().toPlainString(), market.getStepSize().toPlainString(), market.getMinOrderQuantity().toPlainString(), market.getMinOrderAmount().toPlainString(), - market.getStatus().name() + market.getStatus().name(), + market.isCancelOnly() ); } } diff --git a/src/main/java/com/coinflow/market/dto/OrderBookResponse.java b/src/main/java/com/coinflow/market/dto/OrderBookResponse.java index 0b47d48..ac16044 100644 --- a/src/main/java/com/coinflow/market/dto/OrderBookResponse.java +++ b/src/main/java/com/coinflow/market/dto/OrderBookResponse.java @@ -2,7 +2,12 @@ import com.coinflow.order.matching.OrderBookEntry; +import java.math.BigDecimal; +import java.util.Comparator; import java.util.List; +import java.util.Map; +import java.util.TreeMap; +import java.util.stream.Collectors; public record OrderBookResponse( String market, @@ -10,19 +15,34 @@ public record OrderBookResponse( List asks ) { public record PriceLevel(String price, String quantity) { - public static PriceLevel from(OrderBookEntry entry) { - return new PriceLevel( - entry.price().toPlainString(), - entry.remainingQuantity().toPlainString() - ); - } } - public static OrderBookResponse of(String market, List buySide, List sellSide) { + public static OrderBookResponse of(String market, List buySide, List sellSide, int depth) { return new OrderBookResponse( market, - buySide.stream().map(PriceLevel::from).toList(), - sellSide.stream().map(PriceLevel::from).toList() + aggregate(buySide, Comparator.reverseOrder(), depth), + aggregate(sellSide, Comparator.naturalOrder(), depth) ); } + + private static List aggregate( + List entries, + Comparator priceOrder, + int depth + ) { + Map quantitiesByPrice = entries.stream() + .collect(Collectors.groupingBy( + OrderBookEntry::price, + () -> new TreeMap<>(priceOrder), + Collectors.reducing(BigDecimal.ZERO, OrderBookEntry::remainingQuantity, BigDecimal::add) + )); + + return quantitiesByPrice.entrySet().stream() + .limit(depth) + .map(entry -> new PriceLevel( + entry.getKey().toPlainString(), + entry.getValue().toPlainString() + )) + .toList(); + } } diff --git a/src/main/java/com/coinflow/trade/api/TradeController.java b/src/main/java/com/coinflow/trade/api/TradeController.java index 9326f9c..c3a8a0b 100644 --- a/src/main/java/com/coinflow/trade/api/TradeController.java +++ b/src/main/java/com/coinflow/trade/api/TradeController.java @@ -1,5 +1,8 @@ package com.coinflow.trade.api; +import com.coinflow.common.exception.ApiException; +import com.coinflow.common.exception.ErrorCode; +import com.coinflow.order.repository.OrderRepository; import com.coinflow.trade.dto.FillResponse; import com.coinflow.trade.dto.TradeResponse; import com.coinflow.trade.repository.TradeRepository; @@ -25,6 +28,7 @@ public class TradeController { private final TradeRepository tradeRepository; + private final OrderRepository orderRepository; @GetMapping("/markets/{market}/trades") public List getTrades( @@ -41,14 +45,17 @@ public List getTrades( public List getFills( @AuthenticationPrincipal Jwt jwt, @RequestParam(required = false) String market, + @RequestParam(required = false) Long orderId, @RequestParam(defaultValue = "0") @Min(0) long lastFillId, @RequestParam(defaultValue = "50") @Min(1) @Max(200) int limit ) { Long userId = Long.parseLong(jwt.getSubject()); + if (orderId != null) { + orderRepository.findByIdAndUserId(orderId, userId) + .orElseThrow(() -> new ApiException(ErrorCode.ORDER_NOT_FOUND)); + } var pageable = PageRequest.of(0, limit); - var trades = (market != null) - ? tradeRepository.findAllByUserIdAndMarket(userId, market, lastFillId, pageable) - : tradeRepository.findAllByUserId(userId, lastFillId, pageable); + var trades = tradeRepository.findFills(userId, market, orderId, lastFillId, pageable); return trades.stream().map(t -> FillResponse.of(t, userId)).toList(); } } diff --git a/src/main/java/com/coinflow/trade/dto/FillResponse.java b/src/main/java/com/coinflow/trade/dto/FillResponse.java index f85405a..37f5654 100644 --- a/src/main/java/com/coinflow/trade/dto/FillResponse.java +++ b/src/main/java/com/coinflow/trade/dto/FillResponse.java @@ -8,24 +8,28 @@ public record FillResponse( Long tradeId, String market, Long orderId, + String side, String price, String quantity, String quoteAmount, String liquidity, + boolean settled, LocalDateTime tradedAt ) { public static FillResponse of(Trade trade, Long userId) { boolean isBuyer = trade.getBuyUserId().equals(userId); Long orderId = isBuyer ? trade.getBuyOrderId() : trade.getSellOrderId(); - String liquidity = trade.getMakerOrderId().equals(orderId) ? "MAKER" : "TAKER"; + String liquidity = trade.getMakerOrderId().equals(orderId) ? "M" : "T"; return new FillResponse( trade.getId(), trade.getMarketSymbol(), orderId, + isBuyer ? "BUY" : "SELL", trade.getPrice().toPlainString(), trade.getQuantity().toPlainString(), trade.getQuoteAmount().toPlainString(), liquidity, + true, trade.getTradedAt() ); } diff --git a/src/main/java/com/coinflow/trade/repository/TradeRepository.java b/src/main/java/com/coinflow/trade/repository/TradeRepository.java index 0d47a50..1024344 100644 --- a/src/main/java/com/coinflow/trade/repository/TradeRepository.java +++ b/src/main/java/com/coinflow/trade/repository/TradeRepository.java @@ -12,9 +12,22 @@ public interface TradeRepository extends JpaRepository { List findAllByMarketSymbolOrderByTradedAtDesc(String marketSymbol, Pageable pageable); - @Query("SELECT t FROM Trade t WHERE (t.buyUserId = :userId OR t.sellUserId = :userId) AND t.id > :lastFillId ORDER BY t.id ASC") - List findAllByUserId(@Param("userId") Long userId, @Param("lastFillId") Long lastFillId, Pageable pageable); - - @Query("SELECT t FROM Trade t WHERE (t.buyUserId = :userId OR t.sellUserId = :userId) AND t.marketSymbol = :market AND t.id > :lastFillId ORDER BY t.id ASC") - List findAllByUserIdAndMarket(@Param("userId") Long userId, @Param("market") String market, @Param("lastFillId") Long lastFillId, Pageable pageable); + @Query(""" + SELECT t FROM Trade t + WHERE (t.buyUserId = :userId OR t.sellUserId = :userId) + AND (:market IS NULL OR t.marketSymbol = :market) + AND (:orderId IS NULL OR ( + (t.buyUserId = :userId AND t.buyOrderId = :orderId) + OR (t.sellUserId = :userId AND t.sellOrderId = :orderId) + )) + AND t.id > :lastFillId + ORDER BY t.id ASC + """) + List findFills( + @Param("userId") Long userId, + @Param("market") String market, + @Param("orderId") Long orderId, + @Param("lastFillId") Long lastFillId, + Pageable pageable + ); } diff --git a/src/test/java/com/coinflow/query/QueryApiTest.java b/src/test/java/com/coinflow/query/QueryApiTest.java index eb95bea..0e80c63 100644 --- a/src/test/java/com/coinflow/query/QueryApiTest.java +++ b/src/test/java/com/coinflow/query/QueryApiTest.java @@ -58,10 +58,12 @@ void setUp() { List> markets = (List>) response.getBody(); assertThat(markets).isNotEmpty(); assertThat(markets).allMatch(m -> - m.containsKey("symbol") && + m.containsKey("market") && m.containsKey("baseAsset") && m.containsKey("quoteAsset") && + m.containsKey("amountScale") && m.containsKey("tickSize") && + m.containsKey("cancelOnly") && "ACTIVE".equals(m.get("status")) ); } @@ -119,6 +121,23 @@ void setUp() { assertThat(bids.get(2).get("price")).isEqualTo("80000000"); } + @Test + void 오더북_같은_가격은_합산하고_depth를_적용() { + String token = signupAndLogin("query002b@example.com"); + depositKrw("query002b@example.com", new BigDecimal("30000")); + + createOrder(token, "BTC-KRW", "BUY", "100000000", "0.0001"); + createOrder(token, "BTC-KRW", "BUY", "100000000", "0.0001"); + createOrder(token, "BTC-KRW", "BUY", "90000000", "0.0001"); + + var response = restTemplate.getForEntity("/api/v1/markets/BTC-KRW/orderbook?depth=1", Map.class); + List> bids = (List>) response.getBody().get("bids"); + + assertThat(bids).hasSize(1); + assertThat(bids.get(0).get("price")).isEqualTo("100000000"); + assertThat(bids.get(0).get("quantity")).isEqualTo("0.0002"); + } + // ── QRY-003 체결 내역 조회 ──────────────────────────────────────── @Test @@ -175,8 +194,12 @@ void setUp() { List> fills = (List>) buyerFills.getBody(); assertThat(fills).isNotEmpty(); Map fill = fills.get(0); - assertThat((Map) fill).containsKeys("tradeId", "market", "orderId", "price", "quantity", "liquidity", "tradedAt"); - assertThat(fill.get("liquidity")).isEqualTo("MAKER"); + assertThat((Map) fill).containsKeys( + "tradeId", "market", "orderId", "side", "price", "quantity", "quoteAmount", "liquidity", "settled", "tradedAt" + ); + assertThat(fill.get("side")).isEqualTo("BUY"); + assertThat(fill.get("liquidity")).isEqualTo("M"); + assertThat(fill.get("settled")).isEqualTo(true); } @Test @@ -195,6 +218,27 @@ void setUp() { assertThat(fills).allMatch(f -> "BTC-KRW".equals(f.get("market"))); } + @Test + void 사용자_fill_조회_orderId_필터() { + String buyerToken = signupAndLogin("query006c-buyer@example.com"); + String sellerToken = signupAndLogin("query006c-seller@example.com"); + depositKrw("query006c-buyer@example.com", new BigDecimal("20000")); + depositBtc("query006c-seller@example.com", new BigDecimal("0.001")); + + var buy1 = createOrder(buyerToken, "BTC-KRW", "BUY", "100000000", "0.0001"); + Long buyOrderId = ((Number) buy1.getBody().get("orderId")).longValue(); + createOrder(sellerToken, "BTC-KRW", "SELL", "100000000", "0.0001"); + + createOrder(buyerToken, "BTC-KRW", "BUY", "100000000", "0.0001"); + createOrder(sellerToken, "BTC-KRW", "SELL", "100000000", "0.0001"); + + var response = getFills(buyerToken, "BTC-KRW", buyOrderId); + List> fills = (List>) response.getBody(); + + assertThat(fills).hasSize(1); + assertThat(((Number) fills.get(0).get("orderId")).longValue()).isEqualTo(buyOrderId); + } + @Test void 체결_없는_사용자_fill_조회_빈_결과() { String token = signupAndLogin("query007@example.com"); @@ -242,20 +286,32 @@ private void deposit(String email, String asset, BigDecimal amount) { walletRepository.save(wallet); } - private void createOrder(String token, String market, String side, String price, String quantity) { + private ResponseEntity createOrder(String token, String market, String side, String price, String quantity) { HttpHeaders headers = new HttpHeaders(); headers.setBearerAuth(token); var body = Map.of( "market", market, "side", side, "type", "LIMIT", "timeInForce", "GTC", "price", price, "quantity", quantity ); - restTemplate.exchange("/api/v1/orders", HttpMethod.POST, new HttpEntity<>(body, headers), Map.class); + return restTemplate.exchange("/api/v1/orders", HttpMethod.POST, new HttpEntity<>(body, headers), Map.class); } private ResponseEntity getFills(String token, String market) { + return getFills(token, market, null); + } + + private ResponseEntity getFills(String token, String market, Long orderId) { HttpHeaders headers = new HttpHeaders(); headers.setBearerAuth(token); - String url = market != null ? "/api/v1/fills?market=" + market : "/api/v1/fills"; - return restTemplate.exchange(url, HttpMethod.GET, new HttpEntity<>(headers), List.class); + StringBuilder url = new StringBuilder("/api/v1/fills"); + String separator = "?"; + if (market != null) { + url.append(separator).append("market=").append(market); + separator = "&"; + } + if (orderId != null) { + url.append(separator).append("orderId=").append(orderId); + } + return restTemplate.exchange(url.toString(), HttpMethod.GET, new HttpEntity<>(headers), List.class); } } From b89585ee38226aaeb297779cca5f571c462df720 Mon Sep 17 00:00:00 2001 From: ohhalim Date: Fri, 15 May 2026 13:35:42 +0900 Subject: [PATCH 11/11] =?UTF-8?q?chore(infra):=20Kafka=20docker-compose?= =?UTF-8?q?=EB=A5=BC=20KRaft=20=EB=AA=A8=EB=93=9C=EB=A1=9C=20=EC=A0=84?= =?UTF-8?q?=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Kafka 구성 불일치 — Zookeeper 기반 compose 제거 v2 문서는 KRaft Kafka를 전제로 하지만 docker-compose는 Zookeeper 기반으로 구성되어 있던 문제 수정. KRaft 단일 노드 — broker/controller 통합 설정 추가 KAFKA_PROCESS_ROLES, KAFKA_CONTROLLER_QUORUM_VOTERS, KAFKA_CONTROLLER_LISTENER_NAMES 등 KRaft 필수 설정 추가. Local state — kafka_data volume 추가 Kafka 로컬 데이터 보존을 위한 kafka_data volume 추가. Compose 검증 — docker compose config 통과 변경된 compose 파일이 정상 파싱되는지 확인. --- docker-compose.yml | 23 ++++++++++++++--------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index caed1eb..92b14ce 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -11,22 +11,27 @@ services: volumes: - mysql_data:/var/lib/mysql - zookeeper: - image: confluentinc/cp-zookeeper:7.6.0 - environment: - ZOOKEEPER_CLIENT_PORT: 2181 - kafka: image: confluentinc/cp-kafka:7.6.0 - depends_on: - - zookeeper ports: - "9092:9092" environment: - KAFKA_BROKER_ID: 1 - KAFKA_ZOOKEEPER_CONNECT: zookeeper:2181 + CLUSTER_ID: MkU3OEVBNTcwNTJENDM2Qk + KAFKA_NODE_ID: 1 + KAFKA_PROCESS_ROLES: broker,controller + KAFKA_CONTROLLER_QUORUM_VOTERS: 1@kafka:9093 + KAFKA_LISTENERS: PLAINTEXT://0.0.0.0:9092,CONTROLLER://0.0.0.0:9093 KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://localhost:9092 + KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: PLAINTEXT:PLAINTEXT,CONTROLLER:PLAINTEXT + KAFKA_CONTROLLER_LISTENER_NAMES: CONTROLLER + KAFKA_INTER_BROKER_LISTENER_NAME: PLAINTEXT KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1 + KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR: 1 + KAFKA_TRANSACTION_STATE_LOG_MIN_ISR: 1 + KAFKA_GROUP_INITIAL_REBALANCE_DELAY_MS: 0 + volumes: + - kafka_data:/var/lib/kafka/data volumes: mysql_data: + kafka_data: