From d870786c316879847b3966ecdf278acc6f62905c Mon Sep 17 00:00:00 2001 From: ohhalim Date: Sat, 16 May 2026 18:13:15 +0900 Subject: [PATCH] =?UTF-8?q?docs:=20MVP=20=EB=AC=B8=EC=84=9C=EC=99=80=20?= =?UTF-8?q?=ED=98=84=EC=9E=AC=20=EA=B5=AC=ED=98=84=20=EB=B2=94=EC=9C=84=20?= =?UTF-8?q?=EC=A0=95=ED=95=A9=EC=84=B1=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .docs/API.md | 97 ++++++++++++++++++++++++++------------- .docs/ERD.md | 8 +--- .docs/ISSUES.md | 112 +++++++++++++++------------------------------ .docs/PRD.md | 12 +++-- .docs/Plan.md | 41 ++++++++++------- .docs/Reference.md | 15 +++--- .docs/TestPlan.md | 2 +- .gitignore | 4 ++ README.md | 2 +- 9 files changed, 147 insertions(+), 146 deletions(-) diff --git a/.docs/API.md b/.docs/API.md index 512c451..c564bfd 100644 --- a/.docs/API.md +++ b/.docs/API.md @@ -206,7 +206,7 @@ rounding 후 `trade_quote_amount`가 0이 되는 체결은 만들지 않는다. ### 2.4 주문 생성 트랜잭션 흐름 -주문 생성은 아래 처리를 하나의 트랜잭션 경계 안에서 수행한다. +주문 생성은 market lock 범위 안에서 DB 변경을 하나의 트랜잭션으로 수행한다. market 조회와 요청값 검증은 트랜잭션 전 사전 검증이고, 주문/체결/정산/원장/이벤트 저장은 같은 트랜잭션에서 처리한다. ```text 1. JWT에서 currentUserId 추출 @@ -217,21 +217,22 @@ rounding 후 `trade_quote_amount`가 0이 되는 체결은 만들지 않는다. 6. quantity stepSize 검증 7. minOrderQuantity / minOrderAmount 검증 8. clientOrderId 중복 검증 -9. order sequence 발급 -10. 메모리 오더북 후보 기준 매칭 후보 목록 생성 -11. 자기 체결 사전 검증 (교차 가능한 후보 중 userId == currentUserId인 주문이 하나라도 있으면 SELF_TRADE_NOT_ALLOWED 반환, 이후 단계 진행 없음) -12. maker order row lock 및 상태/수량 재검증 -13. 체결에 관련된 모든 wallet 확정 (taker wallet + 확정된 maker들의 wallet) -14. wallet row lock — (user_id, asset) 오름차순으로 정렬 후 일괄 SELECT FOR UPDATE -15. taker 잔액 재검증 (lock 후 확인, 부족 시 INSUFFICIENT_BALANCE 반환) -16. taker 자산 lock (available → locked) -17. order 저장 -18. trade 저장 -19. order 수량/상태 갱신 (완전 체결 시 lockedAmount = 0) -20. wallet 정산 -21. wallet ledger 기록 -22. domain event 기록 -23. commit 이후 메모리 오더북 변경 (시장 lock 범위 안에서 완료) +9. 자기 체결 사전 검증 (교차 가능한 후보 중 userId == currentUserId인 주문이 하나라도 있으면 SELF_TRADE_NOT_ALLOWED 반환, 이후 단계 진행 없음) +10. order sequence 발급 +11. taker wallet row lock 및 잔액 재검증 +12. taker 자산 lock (available → locked) +13. order 저장 +14. ORDER_ACCEPTED domain event 기록 +15. ORDER_LOCK wallet ledger 기록 +16. 메모리 오더북 후보 기준 매칭 계획 생성 (큐 미변경) +17. maker order row lock 및 상태/수량 재검증 +18. 체결에 관련된 buyer/seller wallet을 (user_id, asset) 오름차순으로 lock +19. trade 저장 +20. order 수량/상태 갱신 (완전 체결 시 lockedAmount = 0) +21. wallet 정산 +22. wallet ledger 기록 +23. domain event 기록 +24. commit 이후 메모리 오더북 변경 (시장 lock 범위 안에서 완료) ``` ### 2.5 주문 취소 트랜잭션 흐름 @@ -398,14 +399,16 @@ Response: ```json [ { + "walletId": 1, "asset": "KRW", - "available": "1000000", - "locked": "0" + "availableBalance": "1000000", + "lockedBalance": "0" }, { + "walletId": 2, "asset": "BTC", - "available": "1.5", - "locked": "0" + "availableBalance": "1.5", + "lockedBalance": "0" } ] ``` @@ -435,10 +438,8 @@ Response: "type": "ORDER_LOCK", "deltaAvailable": "-5000", "deltaLocked": "5000", - "availableAfter": "995000", - "lockedAfter": "5000", - "referenceType": "ORDER", - "referenceId": 1001, + "availableBalanceAfter": "995000", + "lockedBalanceAfter": "5000", "orderId": 1001, "tradeId": null, "createdAt": "2026-04-30T12:31:10.123" @@ -446,7 +447,39 @@ Response: ] ``` -`referenceType`, `referenceId`는 DB 저장 컬럼이 아니라 응답 파생 필드다. `tradeId`가 있으면 `TRADE`, `orderId`만 있으면 `ORDER`, 둘 다 없으면 `SYSTEM`으로 계산한다. +`referenceType`, `referenceId`는 저장/응답하지 않는다. 참조 대상은 `orderId`, `tradeId`로 구분한다. + +### 5.3 개발용 입금 보조 API + +```http +POST /api/v1/wallets/deposit +``` + +인증: 필요 + +프로필: `!prod` + +이 API는 로컬 개발과 테스트 데이터 준비를 위한 보조 기능이다. MVP의 운영 입금/출금 기능이 아니며, `prod` 프로필에서는 컨트롤러가 등록되지 않는다. + +Request: + +```json +{ + "asset": "KRW", + "amount": "1000000" +} +``` + +Response: + +```json +{ + "walletId": 1, + "asset": "KRW", + "availableBalance": "1000000", + "lockedBalance": "0" +} +``` ## 6. Orders @@ -497,7 +530,7 @@ Response: "price": "9900", "quantity": "0.2", "quoteAmount": "1980", - "liquidity": "T", + "liquidity": "TAKER", "tradedAt": "2026-04-30T12:31:10.123" } ] @@ -512,6 +545,7 @@ Response: - `lockedAmount`는 현재 주문 잔량에 대해 남아 있는 잠금 수량/금액이다. - 주문 생성 성공은 matching/settlement 처리 후 확정된 상태를 반환한다. - 체결이 발생하지 않으면 `trades`는 빈 배열이다. +- 주문 생성 응답의 `trades[].liquidity`는 생성된 주문 기준으로 `MAKER` 또는 `TAKER`를 반환한다. 일반적으로 새 주문은 taker로 매칭된다. - `clientOrderId`는 optional이다. 값이 있으면 동일 사용자 내 중복을 거절한다(`DUPLICATE_CLIENT_ORDER_ID`). 값이 없으면 멱등성을 보장하지 않으며, null 주문 여러 건을 허용한다. ### 6.2 주문 취소 @@ -592,7 +626,7 @@ Response: ### 6.4 주문 목록 조회 ```http -GET /api/v1/orders?market=BTC-KRW&status=OPEN&limit=50 +GET /api/v1/orders?market=BTC-KRW&limit=50&offset=0 ``` 인증: 필요 @@ -602,8 +636,8 @@ Query: | Name | Required | 설명 | |---|---|---| | `market` | N | 시장 심볼 | -| `status` | N | 주문 상태 | | `limit` | N | 조회 개수, 기본값 50 | +| `offset` | N | 건너뛸 개수, 기본값 0 | Response: @@ -687,10 +721,6 @@ Response: "price": "9900", "quantity": "0.2", "quoteAmount": "1980", - "buyOrderId": 1001, - "sellOrderId": 900, - "makerOrderId": 900, - "takerOrderId": 1001, "tradedAt": "2026-04-30T12:31:10.123" } ] @@ -699,7 +729,7 @@ Response: ### 8.2 사용자 fill 조회 ```http -GET /api/v1/fills?market=BTC-KRW&orderId=1001&limit=50 +GET /api/v1/fills?market=BTC-KRW&orderId=1001&lastFillId=0&limit=50 ``` 인증: 필요 @@ -710,6 +740,7 @@ Query: |---|---|---| | `market` | N | 시장 심볼 | | `orderId` | N | 특정 주문의 fill만 조회 | +| `lastFillId` | N | 이 ID보다 큰 fill만 조회, 기본값 0 | | `limit` | N | 조회 개수, 기본값 50 | Response: diff --git a/.docs/ERD.md b/.docs/ERD.md index e261121..c3612e3 100644 --- a/.docs/ERD.md +++ b/.docs/ERD.md @@ -2,7 +2,7 @@ 이 문서는 CoinFlow MVP 구현 기준의 MySQL DDL이다. -목표는 회원가입/로그인, 주문 생성, 취소, 매칭, 체결, 정산, 조회 흐름을 단일 인스턴스 환경에서 정합성 있게 구현하는 것이다. 입출금, 수수료, dust, Kafka, Redis, recovery/replay/redrive, 인증 고도화는 MVP 범위에서 제외한다. +목표는 회원가입/로그인, 주문 생성, 취소, 매칭, 체결, 정산, 조회 흐름을 단일 인스턴스 환경에서 정합성 있게 구현하는 것이다. 입출금, 수수료, 일반적인 dust 정책, Kafka, Redis, recovery/replay/redrive, 인증 고도화는 MVP 범위에서 제외한다. 단, zero-quote 체결 방지와 dust maker 잔량 자동 취소는 DB 제약과 정합성 보호를 위한 Phase 1 이후 보강으로 포함한다. ## 설계 기준 @@ -277,11 +277,7 @@ CREATE TABLE trades ( -- 8. wallet_ledgers -- append-only 원장. -- available/locked의 변화량과 변화 후 스냅샷을 같이 저장한다. --- reference_type/reference_id는 제거. order_id, trade_id FK로만 참조 무결성 보장. --- API에서 referenceType이 필요하면 아래 규칙으로 파생한다: --- trade_id IS NOT NULL → TRADE / trade_id --- order_id IS NOT NULL → ORDER / order_id --- 둘 다 NULL → SYSTEM / null +-- reference_type/reference_id는 제거. 현재 API 응답은 order_id, trade_id만 노출한다. CREATE TABLE wallet_ledgers ( id BIGINT NOT NULL AUTO_INCREMENT, diff --git a/.docs/ISSUES.md b/.docs/ISSUES.md index 91fd2f5..f8dc1d9 100644 --- a/.docs/ISSUES.md +++ b/.docs/ISSUES.md @@ -2,6 +2,7 @@ 이 문서는 Phase 1 완료 이후 코드 리뷰에서 발견된 이슈를 심각도 순으로 정리한다. Phase 1 BLOCKER 이슈(planMatch/applyMatchPlan, marketLock 재사용, findByIdWithLock, DepositRequest 검증, Pagination)는 [v2/ISSUES.md](./v2/ISSUES.md)에서 모두 수정 완료되었다. +각 항목의 "현상"은 리뷰 당시 상태이고, 하단 요약 표가 현재 반영 상태다. --- @@ -102,7 +103,7 @@ if (maker.getRemainingQuantity().signum() > 0) { ### 현상 -`POST /api/v1/wallets/deposit`이 운영 프로필에서도 열려 있다. 인증된 사용자라면 누구나 자신의 지갑 잔액을 직접 증가시킬 수 있다. +수정 전에는 `POST /api/v1/wallets/deposit`이 운영 프로필에서도 열려 있었다. 인증된 사용자라면 누구나 자신의 지갑 잔액을 직접 증가시킬 수 있는 상태였다. ### 원인 @@ -112,33 +113,27 @@ PRD는 입금을 명시적으로 MVP 제외 범위로 정의한다 (PRD.md line 입금, 출금 — 제외 범위 ``` -현재 구현은 seed balance 목적으로 열었지만, 운영 프로필 분리 없이 노출되어 있다. +초기 구현은 seed balance 목적으로 열었지만, 운영 프로필 분리 없이 노출되어 있었다. ### 수정 -**옵션 A — 완전 제거 (권장)**: 테스트는 Repository/Helper를 통한 seed로 처리한다. +입금 보조 API를 운영 지갑 조회 컨트롤러와 분리했다. ```java -// MatchingSettlementTest.setUp() 패턴으로 대체 -wallet.deposit(amount); -walletRepository.save(wallet); -``` - -**옵션 B — 프로필 가드**: - -```java -@Profile({"local", "dev", "test"}) +@Profile("!prod") @RestController -... -public class WalletController { +public class DevWalletController { @PostMapping("/deposit") public WalletResponse deposit(...) { ... } } ``` -**옵션 C — dev 전용 컨트롤러 분리**: `DevWalletController`를 별도 파일로 분리하고 `@Profile("dev")` 적용. +운영 기능인 `WalletController`는 조회 API만 담당하고, `DevWalletController`는 `prod` 프로필에서 등록되지 않는다. + +### 검증 -어느 방식이든 API 문서(API.md)에 "dev/test only"를 명시한다. +- `DevWalletController`에 `@Profile("!prod")`를 적용했다. +- [API.md](./API.md)에 개발용 입금 보조 API가 운영 입금/출금 기능이 아니라고 명시했다. --- @@ -146,65 +141,41 @@ public class WalletController { ### 4-1. MarketResponse 필드 불일치 -API.md 명세와 구현이 다르다. +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, // 추가 - ... -) -``` +| 시장 심볼 | `"symbol"` | `"market"` | +| `amountScale` | 없음 | 있음 | +| `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 (동일 트랜잭션 정산) - ... -) -``` +| `side` | 없음 | 있음 | +| `settled` | 없음 | 있음 | +| `liquidity` 값 | `"MAKER"` / `"TAKER"` | `"M"` / `"T"` | ### 4-3. GET /fills — orderId 필터 없음 -API.md는 `orderId` 쿼리 파라미터를 지원한다고 명시한다. +API.md는 `orderId` 쿼리 파라미터를 지원한다고 명시했지만 구현에 없었다. ``` -GET /api/v1/fills?market=BTC-KRW&orderId=1001&limit=50 +GET /api/v1/fills?market=BTC-KRW&orderId=1001&lastFillId=0&limit=50 ``` -`TradeController.getFills()`에 `orderId` 파라미터와 Repository 쿼리를 추가한다. +`TradeController.getFills()`에 `orderId`, `lastFillId`, `limit` 파라미터와 Repository 쿼리를 추가했다. ### 4-4. GET /wallets/ledgers — limit 파라미터 없음 -API.md는 `limit` 파라미터를 명시한다. +API.md는 `limit` 파라미터를 명시했지만 구현에 없었다. ``` GET /api/v1/wallets/ledgers?asset=KRW&limit=50 ``` -`WalletService.getLedgers()`에 `limit`/`offset` 또는 커서 기반 페이지네이션을 추가한다. +`WalletService.getLedgers()`에 `limit` 기반 페이지네이션을 추가했다. --- @@ -351,38 +322,27 @@ commit 이후 오더북 반영 중 예외가 발생하면 해당 market의 오 재빌드도 실패하면 해당 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): +`OrderBookRecoveryService`를 추가했다. -`ApplicationEventPublisher` + `@Transactional(propagation = REQUIRES_NEW)`으로 분리하여 재빌드 → 실패 시 `cancel_only = true` 업데이트를 별도 트랜잭션으로 처리한다. +1. `orderbook.apply.failure` metric을 증가시킨다. +2. `REQUIRES_NEW` 트랜잭션으로 DB의 `OPEN`, `PARTIALLY_FILLED` 주문을 읽어 오더북을 재빌드한다. +3. 재빌드도 실패하면 별도 트랜잭션에서 해당 market을 `cancel_only = true`로 전환한다. --- -## Priority 9 — docker-compose Kafka KRaft 불일치 [IMPROVE] +## Priority 9 — Kafka 로컬 인프라와 앱 미연동 범위 정리 [IMPROVE] ### 현상 -v2/PRD.md는 KRaft(Zookeeper 없음)를 선택 이유로 명시했으나, `docker-compose.yml`은 Zookeeper 기반 Kafka를 사용한다. - -또한 `build.gradle`에 Kafka/WebSocket 의존성이 없어 앱 자체는 Kafka 없이 기동한다. +`docker-compose.yml`에는 Kafka 컨테이너가 있지만, `build.gradle`에는 `spring-kafka`와 WebSocket 의존성이 없다. 애플리케이션 코드는 Kafka producer/consumer나 WebSocket broadcaster를 아직 사용하지 않는다. ### 수정 -v2 구현 시작 시: - -1. `docker-compose.yml`에서 Zookeeper 제거, KRaft 모드 Kafka로 교체 -2. `build.gradle`에 `spring-kafka`, `spring-boot-starter-websocket` 추가 -3. v2/PRD.md의 docker-compose 설명과 실제 파일 일치 확인 +1. `docker-compose.yml`의 Kafka는 KRaft 모드로 정리했다. +2. README와 API/PRD/Plan 문서에서 Kafka/WebSocket은 현재 앱 구현 완료 범위가 아니라 다음 단계임을 명시했다. +3. Phase 2에서 `spring-kafka`, `spring-boot-starter-websocket`, `OutboxPublisher`, WebSocket broadcaster를 별도 이슈로 추가한다. --- @@ -398,6 +358,6 @@ v2 구현 시작 시: | 6 | cancelOrder row lock | IMPROVE | 수정 완료 | | 7 | wallet lock 정렬 | IMPROVE | 수정 완료 | | 8 | afterCommit 복구 설계 보강 | IMPROVE | 수정 완료 | -| 9 | docker-compose KRaft 전환 | IMPROVE | 수정 완료 | +| 9 | Kafka 로컬 인프라와 앱 미연동 범위 정리 | IMPROVE | 문서 정리 완료 | 위 항목은 현재 리팩토링에서 모두 처리 완료했다. diff --git a/.docs/PRD.md b/.docs/PRD.md index 27e7af4..8172f08 100644 --- a/.docs/PRD.md +++ b/.docs/PRD.md @@ -56,7 +56,9 @@ MVP에서는 실시간 시세, 입출금, 복잡한 권한 모델, 분산 인프 - 일반 가입 사용자는 잔액 0으로 시작하며, 잔액 없이 주문할 수 없다. - MVP에서는 지정가 주문만 지원한다. - MVP에서는 `GTC` 주문만 지원한다. -- 수수료와 dust 처리는 제외한다. +- 수수료와 일반적인 dust 정책은 제외한다. +- 다만 DB 제약과 정합성 보호를 위해 zero-quote 체결 방지와 dust maker 잔량 자동 취소는 Phase 1 이후 보강 범위로 포함한다. +- 로컬 개발/테스트용 입금 보조 API는 운영 입금 기능이 아니며 `prod` 프로필에서 제외한다. ## 5. 범위 @@ -103,7 +105,7 @@ MVP에서는 실시간 시세, 입출금, 복잡한 권한 모델, 분산 인프 - iceberg 주문 - 세부 self-trade prevention 정책 - 수수료 -- dust 처리 +- 일반적인 dust 정책 - WebSocket 시세 송신 - 외부 거래소 시세 연동 - Kafka, Redis, MQ @@ -171,7 +173,7 @@ MVP에서는 실시간 시세, 입출금, 복잡한 권한 모델, 분산 인프 - SELL 오더북은 낮은 가격이 우선이다. - 같은 가격에서는 낮은 `sequence`가 우선이다. - 체결 가격은 항상 maker 주문 가격이다. -- 동일 시장의 주문 생성/취소 명령은 시장별 queue에서 순차 처리한다. +- 동일 시장의 주문 생성/취소 명령은 시장별 lock으로 순차 처리한다. ### Trade와 Fill 정책 @@ -226,12 +228,12 @@ MVP에서는 실시간 시세, 입출금, 복잡한 권한 모델, 분산 인프 | 사용자 / 계정 | 현재 사용자 조회 | 내 정보 조회 | 현재 사용자 조회 | USR-003 | 사용자는 JWT를 통해 자신의 사용자 정보를 조회할 수 있다. | O | 1차 | `/users/me` | | 사용자 / 계정 | 계정별 데이터 분리 | 로그인 사용자 기준 처리 | 계정별 데이터 분리 | USR-004 | 주문/지갑/체결/원장은 JWT의 현재 사용자 ID 기준으로 분리된다. | O | 1차 | request userId 신뢰 금지 | | 이벤트 / 로그 | 이벤트 로그 | 이벤트 기록 | 주요 이벤트 로그 저장 | EVT-001 | 주요 이벤트를 이벤트 로그 테이블에 저장한다. | O | 1차 | 주문, 체결, 정산, 취소 | -| 시스템 / 운영 | 시장별 순차 처리 | 순차 처리 | 시장별 주문 순차 처리 | SYS-001 | 동일 시장의 주문은 시장 단위로 순차 처리되어야 한다. | O | 1차 | 단일 인스턴스, in-memory queue | +| 시스템 / 운영 | 시장별 순차 처리 | 순차 처리 | 시장별 주문 순차 처리 | SYS-001 | 동일 시장의 주문은 시장 단위로 순차 처리되어야 한다. | O | 1차 | 단일 인스턴스, market별 `ReentrantLock` | | 시스템 / 운영 | 오더북 초기화 | 오더북 초기화 | 서버 시작 시 오더북 초기화 | SYS-002 | 서버 시작 시 DB의 미체결 주문을 메모리 오더북에 적재한다. | O | 1차 | 간단한 초기화만 지원 | | 시스템 / 운영 | 데이터 정합성 | 정합성 보장 | 핵심 정합성 보장 | SYS-003 | 자산, 주문, 체결, 원장의 정합성을 보장한다. | O | 1차 | 테스트로 검증 | | 인증 / 권한 | 인증 고도화 | refresh/OAuth/권한 | 인증 고도화 | EXC-001 | refresh token, 이메일 인증, 비밀번호 재설정, OAuth/social login, role/permission 기반 권한 관리는 포함하지 않는다. | X | - | 향후 고도화 | | 제외 범위 | 입출금 | 입출금 | 입금 / 출금 | EXC-002 | 입금 및 출금 기능은 포함하지 않는다. | X | - | 향후 고도화 | -| 제외 범위 | 수수료 / 정책 | 수수료 | 수수료, dust 처리 | EXC-003 | 수수료 및 dust 처리 정책은 포함하지 않는다. | X | - | 향후 고도화 | +| 제외 범위 | 수수료 / 정책 | 수수료 | 수수료, 일반 dust 정책 | EXC-003 | 수수료 및 일반적인 dust 정책은 포함하지 않는다. zero-quote 방지와 dust maker 잔량 취소는 정합성 보강으로 처리한다. | X | - | 향후 고도화 | | 제외 범위 | 인프라 / 분산 | 정합성 | Kafka/Redis/MQ/서버 분리 | EXC-004 | Kafka, Redis, MQ, 서버 분리는 포함하지 않는다. | X | - | 향후 고도화 | | 제외 범위 | 복구 / 재처리 | 복구 | replay/redrive/recovery | EXC-005 | replay, redrive, recovery 기능은 포함하지 않는다. | X | - | 향후 고도화 | | 제외 범위 | 시장가 주문 | 주문 타입 | 시장가 주문 | EXC-006 | 시장가 주문은 포함하지 않는다. | X | - | 향후 고도화 | diff --git a/.docs/Plan.md b/.docs/Plan.md index 1638f2b..439afee 100644 --- a/.docs/Plan.md +++ b/.docs/Plan.md @@ -68,7 +68,7 @@ - post-only 주문 - iceberg 주문 - 수수료 -- dust 처리 +- 일반적인 dust 정책 - refresh token - 이메일 인증 - 비밀번호 재설정 @@ -255,21 +255,22 @@ BUY 주문의 `locked_amount`는 `price * remaining_quantity`를 `markets.amount 6. quantity stepSize 검증 7. minOrderQuantity / minOrderAmount 검증 8. clientOrderId 중복 검증 -9. order sequence 발급 -10. 메모리 오더북 후보 기준 매칭 후보 목록 생성 -11. 자기 체결 사전 검증 (교차 가능한 후보 중 userId == currentUserId인 주문이 하나라도 있으면 SELF_TRADE_NOT_ALLOWED 반환, 이후 단계 진행 없음) -12. maker order row lock 및 상태/수량 재검증 -13. 체결에 관련된 모든 wallet 확정 (taker wallet + 확정된 maker들의 wallet) -14. wallet row lock — (user_id, asset) 오름차순으로 정렬 후 일괄 SELECT FOR UPDATE -15. taker 잔액 재검증 (lock 후 확인) -16. taker 자산 lock (available → locked) -17. order 저장 -18. trade 저장 -19. order 수량/상태 갱신 (FILLED 또는 CANCELED 시 closed_at = now(), lockedAmount = 0) -20. wallet 정산 -21. wallet ledger 기록 -22. domain event 기록 -23. commit 이후 메모리 오더북 변경 +9. 자기 체결 사전 검증 (교차 가능한 후보 중 userId == currentUserId인 주문이 하나라도 있으면 SELF_TRADE_NOT_ALLOWED 반환, 이후 단계 진행 없음) +10. order sequence 발급 +11. taker wallet row lock 및 잔액 재검증 +12. taker 자산 lock (available → locked) +13. order 저장 +14. ORDER_ACCEPTED domain event 기록 +15. ORDER_LOCK wallet ledger 기록 +16. 메모리 오더북 후보 기준 매칭 계획 생성 (큐 미변경) +17. maker order row lock 및 상태/수량 재검증 +18. 체결에 관련된 buyer/seller wallet을 (user_id, asset) 오름차순으로 lock +19. trade 저장 +20. order 수량/상태 갱신 (FILLED 또는 CANCELED 시 closed_at = now(), lockedAmount = 0) +21. wallet 정산 +22. wallet ledger 기록 +23. domain event 기록 +24. commit 이후 메모리 오더북 변경 ``` ### 7.2 주문 취소 @@ -451,6 +452,12 @@ BUY 주문의 `locked_amount`는 `price * remaining_quantity`를 `markets.amount 주문 취소 등 다른 command의 멱등성이 필요해지면 그때 별도 테이블을 추가한다. +### Dust / fee policy + +수수료와 일반적인 dust 정책은 MVP 이후로 둔다. + +다만 `quote_amount > 0` DB 제약과 정합성 보호를 위해 zero-quote 체결 방지와 dust maker 잔량 자동 취소는 Phase 1 이후 리뷰 보강으로 반영했다. + ### Event Outbox MVP에서는 `domain_events`를 내부 이벤트 로그로만 사용한다. @@ -513,7 +520,7 @@ com.coinflow --- -## 12. 이력서 문장 후보 +## 12. 구현 성과 요약 - 단일 인스턴스 환경에서 지정가 주문 생성, 자산 잠금, 가격-시간 우선 매칭, 부분/완전 체결, 취소를 포함한 거래소 코어 백엔드 MVP 구현 - JWT 기반 사용자 식별과 `available/locked` 지갑 모델을 통해 계정별 자산 분리와 주문 잠금을 구현 diff --git a/.docs/Reference.md b/.docs/Reference.md index 6dfba57..d3673b7 100644 --- a/.docs/Reference.md +++ b/.docs/Reference.md @@ -1,8 +1,8 @@ # CoinFlow MVP Reference -이 문서는 CoinFlow MVP 설계 의도를 설명하고 방어하기 위한 레퍼런스 노트다. +이 문서는 CoinFlow MVP의 설계 의도와 판단 근거를 정리한 레퍼런스 노트다. -구현 계약은 `PRD.md`, `ERD.md`, `API.md`, `TestPlan.md`가 우선한다. 이 문서는 미팅에서 "왜 이렇게 설계했는가"를 설명하기 위한 배경 자료로 사용한다. +구현 계약은 `PRD.md`, `ERD.md`, `API.md`, `TestPlan.md`가 우선한다. 이 문서는 주요 설계 선택의 배경과 근거를 보조 설명하기 위해 사용한다. --- @@ -10,7 +10,7 @@ CoinFlow MVP는 거래소의 모든 기능을 구현하는 프로젝트가 아니라, 지정가 주문 생성, 자산 잠금, 가격-시간 우선 매칭, 체결, 정산, append-only 원장 기록까지 이어지는 거래소 코어 백엔드 정합성을 검증하는 프로젝트다. -그래서 입금/출금, 시장가 주문, 수수료, WebSocket, Kafka, Redis, 서버 분리, replay/recovery는 MVP에서 제외한다. 초기 잔액은 seed wallet balance로 만들고, 그 잔액 안에서 주문-체결-정산 흐름이 깨지지 않는지 검증한다. +그래서 입금/출금, 시장가 주문, 수수료, WebSocket, Kafka, Redis, 서버 분리, replay/redrive 같은 운영 확장은 MVP에서 제외한다. 초기 잔액은 seed wallet balance와 로컬 개발용 입금 보조 API로 만들고, 그 잔액 안에서 주문-체결-정산 흐름이 깨지지 않는지 검증한다. 개발용 입금 보조 API는 `prod` 프로필에서 제외되며 운영 입금 기능이 아니다. --- @@ -41,17 +41,18 @@ CoinFlow MVP는 거래소의 모든 기능을 구현하는 프로젝트가 아 | DB가 source of truth | 메모리 오더북은 빠른 후보 조회와 호가 조회용 파생 상태다. 장애나 재시작 시 DB의 미체결 주문으로 복구할 수 있어야 한다. | | 트랜잭션 commit 이후 메모리 오더북 변경 | DB 저장 실패 후 메모리 오더북만 바뀌는 불일치를 막기 위한 기준이다. | | 동일 시장 명령 순차 처리 | 같은 market의 주문 생성/취소/매칭이 동시에 섞이면 체결 순서와 잔량 정합성이 깨질 수 있다. MVP에서는 성능보다 정합성을 우선한다. | +| zero-quote/dust maker 보강 | 수수료와 일반 dust 정책은 MVP 이후로 두지만, `quote_amount > 0` DB 제약을 지키기 위해 zero-quote 체결 방지와 dust maker 잔량 자동 취소는 정합성 보강으로 처리한다. | | `domain_events` 저장 | MVP에서는 내부 이벤트 로그로 사용하고, 이후 outbox/Kafka 확장 시 같은 경계를 사용할 수 있게 한다. | | `idempotency_requests` 제외 | 1차는 `client_order_id` unique constraint로 주문 중복을 막는다. 취소 같은 command 재시도까지 멱등하게 만들 때 별도 테이블을 추가한다. | | 입금/출금 패키지 제외 | 입출금은 외부 은행/블록체인 연동, 승인/실패/환불, tx id 중복, 보안 정책이 필요한 별도 도메인이다. MVP의 주문-체결-정산 검증 범위와 분리한다. | --- -## 4. 미팅에서 자주 받을 질문과 답변 +## 4. 주요 설계 질문과 답변 ### Q. 왜 입금/출금을 안 만들었나? -입출금은 단순히 잔액을 더하고 빼는 기능이 아니라 외부 시스템 연동, 승인 상태, 실패 보상, 중복 transaction 처리, 보안 정책까지 포함하는 별도 도메인이다. 이번 MVP는 거래소 코어인 주문-매칭-체결-정산 정합성을 먼저 증명하는 것이 목표라서 seed balance를 사용한다. +입출금은 단순히 잔액을 더하고 빼는 기능이 아니라 외부 시스템 연동, 승인 상태, 실패 보상, 중복 transaction 처리, 보안 정책까지 포함하는 별도 도메인이다. 이번 MVP는 거래소 코어인 주문-매칭-체결-정산 정합성을 먼저 증명하는 것이 목표라서 seed balance와 `prod` 제외 개발용 입금 보조 API만 사용한다. ### Q. 왜 지갑과 원장을 분리했나? @@ -117,7 +118,7 @@ FILLED, CANCELED 주문은 오더북에 없음 | Gate.io | [API v4 Spot Orders](https://www.gate.com/docs/developers/apiv4/en) | `text` client field, `time_in_force`, `open`/`closed`/`cancelled`, `left`, `filled_total`, STP | 주문 상태, 잔량, 체결 누적 금액, client id 설계 참고 | | Bitfinex | [New Order](https://docs.bitfinex.com/v1/reference/rest-auth-new-order), [Order Status](https://docs.bitfinex.com/v1/reference/rest-auth-order-status) | `limit`, `market`, `executed_amount`, `remaining_amount`, `is_live`, `is_cancelled` | 주문 조회 응답에서 원수량/체결수량/잔량을 분리하는 근거 | -### 6.2 레퍼런스로 방어할 MVP 설계 포인트 +### 6.2 레퍼런스로 설명할 MVP 설계 포인트 | CoinFlow 설계 포인트 | 외부 레퍼런스에서 반복적으로 확인되는 패턴 | |---|---| @@ -223,4 +224,4 @@ MVP에서는 내부 이벤트 로그이지만, 이후 outbox/Kafka 확장 시 pa | 시장가 주문 | 금액 기반 주문, 슬리피지, 잔량, 체결 실패 정책이 필요함 | | WebSocket | 주문/체결 정합성 검증 이후 실시간 전파 계층으로 추가 | | Kafka/outbox publisher | `domain_events` 기반으로 외부 이벤트 발행 확장 | -| replay/recovery | append-only ledger와 event log가 쌓인 뒤 별도 검증/복구 기능으로 추가 | +| replay/redrive | append-only ledger와 event log가 쌓인 뒤 별도 검증/재처리 기능으로 추가 | diff --git a/.docs/TestPlan.md b/.docs/TestPlan.md index 088a1ea..fdaa072 100644 --- a/.docs/TestPlan.md +++ b/.docs/TestPlan.md @@ -445,7 +445,7 @@ When: Then: - 로그인 사용자의 ledger만 반환된다. -- 각 ledger의 `availableAfter`, `lockedAfter`는 wallet 변경 후 값과 일치한다. +- 각 ledger의 `availableBalanceAfter`, `lockedBalanceAfter`는 wallet 변경 후 값과 일치한다. ## 8. Startup diff --git a/.gitignore b/.gitignore index 01d0f92..5623385 100644 --- a/.gitignore +++ b/.gitignore @@ -32,3 +32,7 @@ credentials.* # Testcontainers .testcontainers.properties + +# Local-only notes and request scratch files +.docs/interview/ +http/ diff --git a/README.md b/README.md index be7f81b..3378d70 100644 --- a/README.md +++ b/README.md @@ -109,7 +109,7 @@ http://localhost:8080/swagger-ui/index.html ./gradlew test ``` -통합 테스트는 Testcontainers 기반 MySQL을 사용해 decimal, foreign key, transaction lock 동작을 실제 MySQL에 가깝게 검증합니다. +통합 테스트는 Testcontainers 기반 MySQL을 사용해 decimal, foreign key, transaction 경계와 핵심 정합성 시나리오를 실제 MySQL에 가깝게 검증합니다. 동시성 테스트와 부하 테스트는 다음 단계로 분리합니다. 주요 검증 범위: