유저들의 지정가 주문은 어떻게 관리할까?
거래 화면에서 지정가 주문을 걸어두면 브라우저를 닫아도 가격에 도달했을 때 주문이 체결됩니다.
그렇다면 주문은 어디에 저장되고, 누가 가격을 계속 확인하고, 체결 순간에는 어떻게 중복 체결을 막을까요?
ORZA의 구조를 간단히 그리면 다음과 같습니다.
브라우저
↓ POST /api/orders
ORZA 서버
↓ Firestore 트랜잭션
사용자 주문 문서 + 워커 인덱스
↓
ORZA Worker의 가격 스트림
↓ 조건 충족
체결 트랜잭션
↓
포지션·거래 내역·잔고·주문 상태 갱신여기서 중요한 점은 ORZA가 유저의 지정가 주문끼리 매칭하는 전통적인 거래소 오더북을 운영하는 구조는 아니라는 것입니다.
각 유저의 주문을 저장해두고 외부 시장 가격을 기준으로 모의 체결합니다.
1. 지정가 주문을 넣으면 무엇이 저장될까
사용자가 지정가로 진입 주문을 제출하면 서버는 먼저 입력값과 계좌를 검증합니다.
orderType이 limit이면 limitPrice가 반드시 필요합니다.
// lib/trading/service.ts의 createOrder 흐름
const limitPrice = input.orderType === "limit"
? assertPositive("limitPrice", input.limitPrice)
: undefined
const orderStatus = input.orderType === "market"
? "filled"
: "pending"
const order = {
userId: uid,
accountId,
symbol,
direction: input.direction,
orderType: input.orderType,
status: orderStatus,
limitPrice,
quantity: normalizedQuantity,
notional,
leverage,
margin,
fee,
createdAt: ts,
updatedAt: ts,
}대기 중인 주문은 유저의 orders 컬렉션에 저장되고, 화면에서 빠르게 보여줄 수 있도록 현재 상태 문서에도 기록됩니다.
동시에 워커가 찾을 수 있는 별도의 인덱스 문서도 만듭니다.
users/{uid}/orders/{orderId}
users/{uid}/state/current.orders.{orderId}
workerPendingLimitOrders/{uid}_{accountId}_{orderId}지정가 주문이 대기 상태가 되면 증거금과 수수료를 먼저 가용 잔고에서 잠급니다.
주문이 체결되거나 취소될 때까지 다른 주문에 같은 금액을 사용할 수 없게 하는 방식입니다.
2. 이미 체결 가능한 가격이면 어떻게 할까
지정가 주문이라고 해서 항상 대기 상태로 남는 것은 아닙니다.
현재 가격이 이미 주문 조건을 만족한다면 서버는 주문을 즉시 체결합니다.
예를 들어 롱 진입은 가격이 내려와야 체결됩니다.
const marketable =
marketPrice != null &&
(input.direction === "up"
? marketPrice <= limitPrice
: marketPrice >= limitPrice)
if (marketable) {
fillPrice = await fetchExecutionPrice(symbol, input.direction)
orderStatus = "filled"
}즉, 롱 지정가 100달러를 넣었는데 현재 가격이 99달러라면 이미 조건을 만족하므로 대기 주문을 만들지 않고 바로 체결할 수 있습니다.
반대로 롱 지정가 100달러인데 현재 가격이 105달러라면 pending 상태로 저장합니다.
3. 프론트엔드는 어떤 일을 할까
지금까지의 설명만 보면 프론트엔드는 주문을 서버로 보내기만 하는 것처럼 보입니다.
실제 거래 화면에서 프론트엔드는 주문을 안전하게 입력하고, 사용자가 현재 주문 상태를 이해하도록 만들고, 취소·수정 요청을 보내는 역할을 합니다.
다만 체결 여부와 잔고 변경을 프론트에서 결정하지는 않습니다.
주문창에서는 입력값을 바로 계산해 보여줍니다.
수량과 가격으로 주문 금액을 계산하고, 레버리지·증거금·수수료를 반영해 주문 가능한지 미리 안내합니다.
// components/perp-terminal/advanced-terminal.tsx
const parsedAmount = parsePositiveNumber(amount)
const parsedLimitPrice = parsePositiveNumber(limitPrice)
const orderPrice = orderKind === "market"
? referenceMark
: parsedLimitPrice
const notional = parsedAmount * orderPrice
const marginRequired = reduceOnly
? 0
: notional / effectiveLeverage
if (orderKind === "limit" && parsedLimitPrice <= 0) {
setStatusMsg({
kind: "error",
text: "Enter a valid limit price.",
})
return
}사용자가 제출 버튼을 누르면 선택한 거래소에 맞는 어댑터를 호출합니다.
ORZA 모의거래 화면의 paper 거래소에서는 서버 API로 주문을 전달합니다.
// 프론트엔드 → ORZA paper API
await placePaperOrder({
leverage,
orderKind,
price: orderPrice,
quantity: orderAmount,
side,
symbol: marketSymbol,
})
async function placePaperOrder(input) {
await paperJson("order", {
direction: input.side === "long" ? "up" : "down",
limitPrice: input.orderKind === "limit"
? input.price
: undefined,
orderType: input.orderKind,
quantity: input.quantity,
leverage: input.leverage,
symbol: input.symbol,
})
}주문을 성공적으로 보낸 뒤 프론트엔드는 체결을 가정해 임의로 화면을 바꾸지 않습니다.
paper/state를 다시 조회해 서버가 확정한 주문·포지션·체결 내역을 받아 화면을 갱신합니다.
모의거래 화면은 이 상태를 주기적으로 조회하고, 주문 요청이 끝나면 새로고침 신호를 발생시킵니다.
주문창 입력
→ 금액·증거금·입력값 미리 계산
→ POST /api/dex/paper/order
→ 서버가 pending 또는 filled 확정
→ paper/state 재조회
→ 주문 목록·차트 주문선·포지션 화면 갱신대기 주문은 주문 목록뿐 아니라 차트에도 가격선으로 표시됩니다.
사용자는 가격선을 드래그해 새 가격을 입력할 수 있고, 프론트엔드는 기존 주문을 취소한 뒤 새로운 지정가 주문을 다시 등록합니다.
즉, 현재 구조에서 주문 수정은 원자적인 replace 한 번이 아니라 취소 후 재등록입니다.
async function replaceOrder(order, nextPrice) {
const remaining = Math.max(order.amount - order.filled, 0)
await cancelPaperOrder(order)
await placePaperOrder({
leverage,
orderKind: "limit",
price: nextPrice,
quantity: remaining,
side: order.side === "buy" ? "long" : "short",
symbol: order.symbol,
})
}취소 버튼도 같은 원리입니다.
프론트엔드는 취소 API를 호출하고, 성공한 뒤 서버 상태를 다시 읽습니다.
취소 전에 목록에서 먼저 제거하는 낙관적 업데이트를 하지 않기 때문에, 서버에서 이미 체결된 주문을 화면이 잘못 취소된 것처럼 보여주는 문제를 줄일 수 있습니다.
3. 워커는 모든 주문을 어떻게 찾을까
웹 서버 요청이 끝난 뒤에도 주문을 감시해야 하므로 이 역할은 별도의 상시 실행 프로세스인 orza-worker가 맡습니다.
워커는 Firestore의 workerPendingLimitOrders를 실시간으로 구독합니다.
// orza-worker/src/positionIndex.ts
db()
.collection("workerPendingLimitOrders")
.onSnapshot((snap) => {
for (const change of snap.docChanges()) {
const order = change.doc.data()
if (change.type === "removed") {
deleteOpenOrder(order.orderId)
} else {
addIndexedOpenOrder(change.doc.id, order)
}
}
})워커 내부에서는 주문을 종목별 Map에 보관합니다.
그래서 매 가격 이벤트마다 전체 유저의 모든 주문을 다시 조회하지 않고, 해당 종목의 대기 주문만 검사합니다.
BTCUSDT → [주문 A, 주문 B, 주문 C]
ETHUSDT → [주문 D, 주문 E]인덱스가 비어 있는 상태로 워커가 시작되면 collection group 조회로 기존 대기 주문을 다시 채우는 백필 과정도 있습니다.
따라서 워커가 재시작되어도 Firestore의 원본 주문을 기준으로 감시를 복구할 수 있습니다.
4. 실시간 가격이 조건을 만족하면
워커는 가격 스트림에서 새 가격을 받을 때마다 해당 종목의 후보 주문을 검사합니다.
// orza-worker/src/index.ts
function evaluateCandidates(symbol: string, price: number) {
for (const order of getOpenOrderCandidates(symbol)) {
if (!order.limitPrice) continue
// 롱 진입: 가격이 지정가 이하로 하락
// 숏 진입: 가격이 지정가 이상으로 상승
const hit = order.direction === "up"
? price <= order.limitPrice
: price >= order.limitPrice
if (hit) executeOpenOrder(order)
}
}가격 조건만 맞았다고 바로 화면 상태를 바꾸지는 않습니다.
executeOpenOrder가 Firestore 트랜잭션 안에서 주문 문서를 다시 읽어 현재도 pending인지 확인합니다.
5. 체결은 트랜잭션으로 한 번만
체결 함수는 같은 주문이 동시에 두 번 실행되어도 한 번만 성공하도록 설계되어 있습니다.
await db().runTransaction(async (tx) => {
const orderSnap = await tx.get(orderRef)
if (!orderSnap.exists) return
const freshOrder = orderSnap.data()
if (freshOrder.status !== "pending") return
if (freshOrder.orderType !== "limit") return
const fillPrice = freshOrder.limitPrice
tx.update(orderRef, {
status: "filled",
fillPrice,
updatedAt: ts,
})
tx.delete(workerPendingLimitOrderRef(uid, orderRef.id))
deleteStateOrder(tx, uid, orderRef.id, ts)
// 포지션·거래 내역·잔고·수수료를 같은 트랜잭션에서 반영
tx.set(positionRef, positionData)
tx.set(tradeRef, tradeData)
tx.set(accountRef, nextAccount)
})첫 번째 실행이 주문 상태를 filled로 바꾸면 두 번째 실행은 pending 검사를 통과하지 못합니다.
이 확인을 트랜잭션 안에서 수행하기 때문에 가격 이벤트가 겹치거나 워커가 잠시 중복 호출되어도 이중 포지션과 이중 거래 내역을 막을 수 있습니다.
체결 가격은 조건을 만족시킨 현재 가격이 아니라 주문의 limitPrice입니다.
롱 지정가가 100달러이고 시장 가격이 99달러까지 내려왔다면, ORZA의 모의체결 기록에는 지정가 100달러가 체결 가격으로 남습니다.
6. 취소하면 잠긴 증거금은 어떻게 될까
사용자가 취소를 요청하면 서버는 대기 중인 주문인지 확인한 뒤 주문 상태를 canceled로 바꾸고 워커 인덱스와 화면용 상태에서 제거합니다.
그리고 주문을 만들 때 잠갔던 증거금과 수수료를 잔고로 되돌립니다.
// cancelOrder의 핵심 흐름
if (order.status !== "pending") {
throw new ApiError(409, "conflict", "대기 중인 주문만 취소할 수 있어요.")
}
tx.update(orderRef, { status: "canceled", updatedAt: ts })
deleteWorkerPendingLimitOrderIndex(tx, uid, orderRef.id)
deleteStateOrder(tx, uid, orderRef.id, ts)
tx.set(accountRef, {
...account,
cash: cash0 + order.margin + order.fee,
updatedAt: ts,
})7. 정리하면
ORZA의 지정가 주문 관리는 브라우저 메모리에 주문을 두는 방식이 아니라, 영속 저장소와 상시 워커를 분리한 구조입니다.
주문 생성
→ Firestore에 pending 주문 저장
→ 증거금·수수료 잠금
→ workerPendingLimitOrders 인덱스 등록
→ 종목별 Map에서 감시
→ 가격 조건 충족
→ 트랜잭션으로 pending 재확인
→ filled + 포지션 + 거래 내역 반영
→ 워커 인덱스 삭제이 구조의 핵심은 세 가지입니다.
원본 주문은 Firestore에 남기고, 빠른 감시는 종목별 워커 인덱스로 처리하며, 실제 체결과 잔고 변경은 서버 트랜잭션으로 확정하는 것입니다.
그래서 사용자가 브라우저를 종료해도 주문은 살아 있고, 워커가 재시작되어도 저장된 주문을 기준으로 감시를 다시 시작할 수 있습니다.