이 문서는 CoinFlow MVP의 REST API 계약을 정의한다.
MVP API는 회원가입/로그인, 시장 조회, 주문 생성/취소, 오더북 조회, 체결/fill 조회, 지갑/원장 조회를 지원한다. 입출금, 수수료, 시장가 주문, refresh token, OAuth, 관리자 API는 제외한다.
/api/v1
인증이 필요한 API는 Authorization 헤더에 JWT access token을 전달한다.
Authorization: Bearer {accessToken}서버는 request body나 query string의 userId를 신뢰하지 않는다. 주문, 지갑, 원장, fill 조회는 JWT에서 추출한 현재 사용자 ID 기준으로 처리한다.
금액, 가격, 수량은 JSON number가 아니라 string으로 주고받는다.
{
"price": "10000",
"quantity": "0.5"
}서버 내부에서는 BigDecimal로 처리한다.
시간은 ISO-8601 문자열로 응답한다.
2026-04-30T12:30:45.123
{
"code": "INSUFFICIENT_BALANCE",
"message": "Insufficient balance"
}대표 에러 코드:
| Code | 의미 |
|---|---|
INVALID_REQUEST |
요청 형식 오류 |
UNAUTHORIZED |
인증 실패 |
USER_NOT_FOUND |
사용자를 찾을 수 없음 |
DUPLICATE_EMAIL |
이미 가입된 email |
INVALID_CREDENTIALS |
로그인 정보 불일치 |
MARKET_NOT_FOUND |
시장을 찾을 수 없음 |
MARKET_NOT_ACTIVE |
거래 불가능한 시장 |
MARKET_CANCEL_ONLY |
취소 전용 시장 (신규 주문 거절) |
INVALID_ORDER_TYPE |
지원하지 않는 주문 유형 |
INVALID_ORDER_SIDE |
지원하지 않는 주문 방향 |
INVALID_PRICE |
유효하지 않은 가격 |
INVALID_QUANTITY |
유효하지 않은 수량 |
INVALID_TICK_SIZE |
가격 단위 불일치 |
INVALID_STEP_SIZE |
수량 단위 불일치 |
MIN_ORDER_QUANTITY_NOT_MET |
최소 주문 수량 미달 |
MIN_ORDER_AMOUNT_NOT_MET |
최소 주문 금액 미달 |
INSUFFICIENT_BALANCE |
잔액 부족 |
ORDER_NOT_FOUND |
주문을 찾을 수 없음 |
ORDER_NOT_CANCELABLE |
취소 불가능한 주문 |
SELF_TRADE_NOT_ALLOWED |
자기 체결 거절 |
DUPLICATE_CLIENT_ORDER_ID |
중복 client order id |
| 상황 | 에러 코드 |
|---|---|
| request body 형식 오류, 필수 필드 누락 | INVALID_REQUEST |
| Authorization 헤더 없음 또는 JWT 유효하지 않음 | UNAUTHORIZED |
| JWT subject의 user id가 DB에 없음 | USER_NOT_FOUND |
| 회원가입 시 이미 사용 중인 email | DUPLICATE_EMAIL |
| 로그인 시 email 없음 또는 password 불일치 | INVALID_CREDENTIALS |
| 존재하지 않는 market symbol | MARKET_NOT_FOUND |
market status가 INACTIVE 또는 SUSPENDED인 시장에 주문 생성 |
MARKET_NOT_ACTIVE |
market cancel_only=true인 시장에 신규 주문 생성 |
MARKET_CANCEL_ONLY |
type이 LIMIT이 아닌 주문 |
INVALID_ORDER_TYPE |
side가 BUY 또는 SELL이 아닌 주문 |
INVALID_ORDER_SIDE |
price <= 0 또는 null |
INVALID_PRICE |
quantity <= 0 또는 null |
INVALID_QUANTITY |
price가 market tick_size의 배수가 아님 |
INVALID_TICK_SIZE |
quantity가 market step_size의 배수가 아님 |
INVALID_STEP_SIZE |
quantity < market min_order_quantity |
MIN_ORDER_QUANTITY_NOT_MET |
price × quantity < market min_order_amount |
MIN_ORDER_AMOUNT_NOT_MET |
| taker 잔액 재검증 실패 (wallet row lock 이후) | INSUFFICIENT_BALANCE |
| 존재하지 않는 orderId 조회/취소 또는 타인 주문 접근 | ORDER_NOT_FOUND |
FILLED 또는 CANCELED 주문 취소 시도 |
ORDER_NOT_CANCELABLE |
| 자기 체결 후보가 하나라도 있는 taker 주문 | SELF_TRADE_NOT_ALLOWED |
동일 사용자가 같은 clientOrderId로 두 번째 주문 |
DUPLICATE_CLIENT_ORDER_ID |
타인 주문을 조회/취소하려 할 때
ORDER_ACCESS_DENIED대신ORDER_NOT_FOUND를 반환한다. 주문 존재 여부를 노출하지 않기 위한 의도적인 정책이다.
요청 수신
-> 검증 성공
-> OPEN
-> 일부 체결
-> PARTIALLY_FILLED
-> 전량 체결
-> FILLED
OPEN 또는 PARTIALLY_FILLED
-> 사용자 취소
-> CANCELED
검증 실패 주문은 DB에 저장하지 않는다.
INVALID_REQUEST
MARKET_NOT_ACTIVE
INSUFFICIENT_BALANCE
SELF_TRADE_NOT_ALLOWED
...
-> 주문 저장 안 함
-> 에러 응답
오더북에는 OPEN, PARTIALLY_FILLED 주문만 존재할 수 있다. FILLED, CANCELED 주문은 오더북에서 제거되어야 한다.
lockedAmount는 주문에 현재 남아 있는 잠금 수량/금액을 의미한다.
| 주문 | lockedAsset | lockedAmount |
|---|---|---|
BUY |
quote asset | price * remainingQuantity를 market의 amountScale 기준으로 CEILING rounding |
SELL |
base asset | remainingQuantity |
예를 들어 BTC-KRW 시장에서 BUY price=10000, quantity=0.5 주문을 생성하면 최초 잠금은 KRW 5000이다.
이후 0.2 BTC가 체결되어 remainingQuantity=0.3이 되면 남은 잠금은 KRW 3000이다.
BUY 주문은 실제 필요 금액보다 적게 잠기지 않도록 CEILING rounding을 사용한다. 부분 체결 후에는 기존 lockedAmount를 단순 차감하지 않고 price * newRemainingQuantity 기준으로 다시 계산한다.
FILLED 또는 CANCELED 상태가 되면 lockedAmount = 0이다.
용어 구분:
wallet.locked(지갑 조회 응답)는 해당 사용자-자산의 전체 잠금 합산이다.order.lockedAmount(주문 응답)는 해당 주문 하나에 귀속된 잔여 잠금이다. 두 필드는 합산 관계이지 같은 값이 아니다. 예를 들어 BUY 주문 2건이 각각 KRW 5000, KRW 3000을 잠갔다면wallet.locked = 8000, 각 주문의lockedAmount = 5000,3000이다.
markets.cancel_only = true인 시장은 신규 주문 생성을 거절한다. 취소 요청은 허용된다.
| market.status | cancel_only | 신규 주문 | 취소 |
|---|---|---|---|
ACTIVE |
false |
허용 | 허용 |
ACTIVE |
true |
MARKET_CANCEL_ONLY 반환 |
허용 |
INACTIVE / SUSPENDED |
- | MARKET_NOT_ACTIVE 반환 |
허용 |
INACTIVE, SUSPENDED 시장에서도 기존 주문 취소는 허용한다. 취소를 막으면 locked 자산이 해제되지 않아 사용자 자산이 묶이기 때문이다.
체결 가격은 항상 maker 주문 가격이다.
BUY 주문이 체결되면:
trade_quote_amount:
trade price * fill quantity를 market의 amountScale 기준으로 DOWN rounding
buyer quote wallet:
new_locked = buyer order price * new remaining quantity를 amountScale 기준으로 CEILING rounding
released_amount = old_locked - new_locked
locked 감소 = released_amount
available 증가 = released_amount - trade_quote_amount
buyer base wallet:
available 증가 = fill quantity
SELL 주문이 체결되면:
seller base wallet:
locked 감소 = fill quantity
seller quote wallet:
available 증가 = trade_quote_amount
MVP에서는 수수료를 적용하지 않는다.
rounding 후 trade_quote_amount가 0이 되는 체결은 만들지 않는다.
주문 생성은 market lock 범위 안에서 DB 변경을 하나의 트랜잭션으로 수행한다. market 조회와 요청값 검증은 트랜잭션 전 사전 검증이고, 주문/체결/정산/원장/이벤트 저장은 같은 트랜잭션에서 처리한다.
1. JWT에서 currentUserId 추출
2. market 조회
3. market status 검증 / cancel_only=true이면 MARKET_CANCEL_ONLY 반환
4. side/type/timeInForce 검증
5. price tickSize 검증
6. quantity stepSize 검증
7. minOrderQuantity / minOrderAmount 검증
8. clientOrderId 중복 검증
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 범위 안에서 완료)
1. JWT에서 currentUserId 추출
2. order row lock
3. 주문 소유자 검증
4. 주문 상태 검증
5. wallet row lock (lockedAsset wallet을 (user_id, asset) 오름차순으로 잠금)
6. remainingQuantity 기준 잔여 locked 해제
7. order 상태 CANCELED 변경 및 lockedAmount = 0
8. wallet ledger 기록
9. domain event 기록
10. commit 이후 메모리 오더북에서 제거 (시장 lock 범위 안에서 완료)
메모리 오더북은 DB의 파생 조회 모델이다. DB가 source of truth이다.
서버 시작 시 DB에서 OPEN, PARTIALLY_FILLED 주문을 조회하여 메모리 오더북을 초기화한다.
초기화 대상:
orders.status IN ('OPEN', 'PARTIALLY_FILLED')
초기화 제외 대상:
FILLED
CANCELED
POST /api/v1/auth/signup인증: 불필요
Request:
{
"email": "user1@example.com",
"password": "password1234",
"nickname": "user1"
}Response:
{
"userId": 1,
"email": "user1@example.com",
"nickname": "user1",
"status": "ACTIVE",
"createdAt": "2026-04-30T12:30:45.123"
}POST /api/v1/auth/login인증: 불필요
Request:
{
"email": "user1@example.com",
"password": "password1234"
}Response:
{
"accessToken": "eyJhbGciOiJIUzI1NiJ9...",
"tokenType": "Bearer",
"expiresIn": 3600,
"user": {
"userId": 1,
"email": "user1@example.com",
"nickname": "user1",
"status": "ACTIVE"
}
}GET /api/v1/users/me인증: 필요
Response:
{
"userId": 1,
"email": "user1@example.com",
"nickname": "user1",
"status": "ACTIVE",
"createdAt": "2026-04-30T12:30:45.123"
}GET /api/v1/markets인증: 불필요
Response:
[
{
"market": "BTC-KRW",
"displayName": "BTC/KRW",
"baseAsset": "BTC",
"quoteAsset": "KRW",
"amountScale": 0,
"tickSize": "1",
"stepSize": "0.00000001",
"minOrderQuantity": "0.0001",
"minOrderAmount": "5000",
"status": "ACTIVE",
"cancelOnly": false
}
]GET /api/v1/wallets인증: 필요
Response:
[
{
"walletId": 1,
"asset": "KRW",
"availableBalance": "1000000",
"lockedBalance": "0"
},
{
"walletId": 2,
"asset": "BTC",
"availableBalance": "1.5",
"lockedBalance": "0"
}
]GET /api/v1/wallets/ledgers?asset=KRW&limit=50인증: 필요
Query:
| Name | Required | 설명 |
|---|---|---|
asset |
N | 자산 코드 |
limit |
N | 조회 개수, 기본값 50 |
Response:
[
{
"ledgerId": 1,
"asset": "KRW",
"type": "ORDER_LOCK",
"deltaAvailable": "-5000",
"deltaLocked": "5000",
"availableBalanceAfter": "995000",
"lockedBalanceAfter": "5000",
"orderId": 1001,
"tradeId": null,
"createdAt": "2026-04-30T12:31:10.123"
}
]referenceType, referenceId는 저장/응답하지 않는다. 참조 대상은 orderId, tradeId로 구분한다.
POST /api/v1/wallets/deposit인증: 필요
프로필: !prod
이 API는 로컬 개발과 테스트 데이터 준비를 위한 보조 기능이다. MVP의 운영 입금/출금 기능이 아니며, prod 프로필에서는 컨트롤러가 등록되지 않는다.
Request:
{
"asset": "KRW",
"amount": "1000000"
}Response:
{
"walletId": 1,
"asset": "KRW",
"availableBalance": "1000000",
"lockedBalance": "0"
}POST /api/v1/orders인증: 필요
Request:
{
"market": "BTC-KRW",
"side": "BUY",
"type": "LIMIT",
"timeInForce": "GTC",
"price": "10000",
"quantity": "0.5",
"clientOrderId": "user1-order-001"
}Response:
{
"orderId": 1001,
"clientOrderId": "user1-order-001",
"market": "BTC-KRW",
"side": "BUY",
"type": "LIMIT",
"timeInForce": "GTC",
"price": "10000",
"originalQuantity": "0.5",
"executedQuantity": "0.2",
"remainingQuantity": "0.3",
"executedQuoteAmount": "1980",
"lockedAsset": "KRW",
"lockedAmount": "3000",
"status": "PARTIALLY_FILLED",
"createdAt": "2026-04-30T12:31:10.123",
"trades": [
{
"tradeId": 501,
"price": "9900",
"quantity": "0.2",
"quoteAmount": "1980",
"liquidity": "TAKER",
"tradedAt": "2026-04-30T12:31:10.123"
}
]
}규칙:
userId는 요청에 포함하지 않는다.- 현재 사용자 ID는 JWT에서 추출한다.
- MVP에서는
type=LIMIT,timeInForce=GTC만 허용한다. lockedAmount는 현재 주문 잔량에 대해 남아 있는 잠금 수량/금액이다.- 주문 생성 성공은 matching/settlement 처리 후 확정된 상태를 반환한다.
- 체결이 발생하지 않으면
trades는 빈 배열이다. - 주문 생성 응답의
trades[].liquidity는 생성된 주문 기준으로MAKER또는TAKER를 반환한다. 일반적으로 새 주문은 taker로 매칭된다. clientOrderId는 optional이다. 값이 있으면 동일 사용자 내 중복을 거절한다(DUPLICATE_CLIENT_ORDER_ID). 값이 없으면 멱등성을 보장하지 않으며, null 주문 여러 건을 허용한다.
POST /api/v1/orders/{orderId}/cancel인증: 필요
Request body 없음.
Response:
{
"orderId": 1001,
"market": "BTC-KRW",
"status": "CANCELED",
"releasedAsset": "KRW",
"releasedAmount": "3000",
"canceledAt": "2026-04-30T12:35:00.123"
}규칙:
- 현재 로그인 사용자의 주문만 취소할 수 있다.
OPEN,PARTIALLY_FILLED상태만 취소 가능하다.- 취소 시 잔여 수량에 해당하는 locked asset을 available로 되돌린다.
releasedAmount는 취소로 해제된 잔여 lock 수량/금액이다.
releasedAmount 계산:
releasedAsset = order.lockedAsset
releasedAmount = order.lockedAmount
wallet.locked -= releasedAmount
wallet.available += releasedAmount
order.lockedAmount = 0
order.status = CANCELED
BUY 주문은 부분 체결 시마다 lockedAmount를 price * remainingQuantity CEILING으로 재계산해 유지하므로, 취소 시 price * remainingQuantity를 다시 계산하지 않고 저장된 order.lockedAmount를 그대로 사용한다. SELL 주문도 동일하게 lockedAmount == remainingQuantity이므로 같은 방식으로 처리한다.
GET /api/v1/orders/{orderId}인증: 필요
Response:
{
"orderId": 1001,
"clientOrderId": "user1-order-001",
"market": "BTC-KRW",
"side": "BUY",
"type": "LIMIT",
"timeInForce": "GTC",
"price": "10000",
"originalQuantity": "0.5",
"executedQuantity": "0.2",
"remainingQuantity": "0.3",
"executedQuoteAmount": "1980",
"lockedAsset": "KRW",
"lockedAmount": "3000",
"status": "PARTIALLY_FILLED",
"createdAt": "2026-04-30T12:31:10.123",
"closedAt": null
}GET /api/v1/orders?market=BTC-KRW&limit=50&offset=0인증: 필요
Query:
| Name | Required | 설명 |
|---|---|---|
market |
N | 시장 심볼 |
limit |
N | 조회 개수, 기본값 50 |
offset |
N | 건너뛸 개수, 기본값 0 |
Response:
[
{
"orderId": 1001,
"clientOrderId": "user1-order-001",
"market": "BTC-KRW",
"side": "BUY",
"price": "10000",
"originalQuantity": "0.5",
"executedQuantity": "0.2",
"remainingQuantity": "0.3",
"status": "PARTIALLY_FILLED",
"createdAt": "2026-04-30T12:31:10.123"
}
]GET /api/v1/markets/{market}/orderbook?depth=10인증: 불필요
Query:
| Name | Required | 설명 |
|---|---|---|
depth |
N | 가격 레벨 개수, 기본값 10 |
Response:
{
"market": "BTC-KRW",
"bids": [
{
"price": "10000",
"quantity": "1.2"
}
],
"asks": [
{
"price": "10100",
"quantity": "0.4"
}
]
}규칙:
- 오더북은 메모리 오더북 기반 조회 모델이다.
bids는 가격 내림차순이다.asks는 가격 오름차순이다.- 같은 가격 주문은 합산 수량으로 응답한다.
GET /api/v1/markets/{market}/trades?limit=50인증: 불필요
Response:
[
{
"tradeId": 501,
"market": "BTC-KRW",
"price": "9900",
"quantity": "0.2",
"quoteAmount": "1980",
"tradedAt": "2026-04-30T12:31:10.123"
}
]GET /api/v1/fills?market=BTC-KRW&orderId=1001&lastFillId=0&limit=50인증: 필요
Query:
| Name | Required | 설명 |
|---|---|---|
market |
N | 시장 심볼 |
orderId |
N | 특정 주문의 fill만 조회 |
lastFillId |
N | 이 ID보다 큰 fill만 조회, 기본값 0 |
limit |
N | 조회 개수, 기본값 50 |
Response:
[
{
"tradeId": 501,
"orderId": 1001,
"market": "BTC-KRW",
"side": "BUY",
"liquidity": "T",
"price": "9900",
"quantity": "0.2",
"quoteAmount": "1980",
"settled": true,
"tradedAt": "2026-04-30T12:31:10.123"
}
]규칙:
- fill은 별도 테이블에 저장하지 않고
trades에서 로그인 사용자 기준으로 파생 조회한다. - 요청 사용자의 주문이
maker_order_id와 같으면liquidity=M이다. - 요청 사용자의 주문이
taker_order_id와 같으면liquidity=T이다. - 주문/체결/정산이 같은 트랜잭션에서 완료되므로
settled=true로 응답한다. orderId필터 사용 시: 해당 주문이 존재하지 않거나 현재 사용자의 주문이 아니면ORDER_NOT_FOUND를 반환한다. 본인 주문이지만 fill이 없으면 빈 배열을 반환한다.