던플 개발기 (13): 시세 총액 vs 매물 총액 — 가격 용어를 다시 설계하다
지난 편에서 대시보드를 세웠다면, 이번엔 그 위에 찍히는 숫자가 무엇을 뜻하는지를 다시 물어야 했습니다. 랭킹·무버스·실시간 채널까지 다 만들어 놓고 보니, 정작 화면에 "시세"라고 적힌 숫자가 실은 경매장에 올라온 호가에서 나온 값이었습니다. 팔린 적 없는 가격을 "시세"라고 부르고 있었던 겁니다.
이 편의 전제는 하나입니다. 숫자를 어떻게 보여줄지는 UI의 문제가 아니라 도메인 정의의 문제입니다. 라벨을 바꾸는 걸로는 끝나지 않았습니다. 잘못된 정의는 read model의 집계 SQL, gRPC 계약, BFF DTO, 그리고 화면 컬럼까지 한 줄로 꿰여 있었기 때문에, 정의를 바로잡으려면 그 줄 전체를 다시 짜야 했습니다.
한눈에 보면
- 경매장 평균가는 UI에 절대 노출하지 않습니다. 몇 개의 터무니없는 호가로 쉽게 조작되기 때문입니다. 계약에서
deprecated로 박아 놓고, 비교에는 경매장 최저가와 시세를 씁니다. - 매물 총액 = 경매장 최저가 × 매물 총량입니다. 지금 시장에 얼마어치가 걸려 있느냐를 답하는, listing-side 값입니다.
- 시세 총액 = 체결된(sold) 총 골드입니다. 실제로 얼마가 오갔느냐를 답하는,
auction_sales기반 값입니다. - 두 값은 서로 다른 데이터 소스에서 나오고, 계약·DTO·라벨 어디서도 같은 필드로 섞지 않습니다.
- 등락폭은 시세 평균을 기준으로만 계산합니다. 경매장 호가의 등락은 등락폭이 아닙니다.
- read model → gRPC proto → BFF REST DTO → 화면 위젯을 같은 용어로 한 번에 다시 짰습니다.
- 골드는 언제나 floor(내림) 처리합니다. 시장이 표현할 수 없는 가격을 화면에 만들지 않기 위해서입니다.
- 로컬 seed·통합 테스트용 synthetic 아이템은 총액과 랭킹에서 제외합니다.
평균가의 함정
가장 먼저 걷어낸 건 "경매장 평균가"였습니다. 직관적으로는 "지금 이 아이템 얼마쯤 해?"에 답하기 좋은 숫자처럼 보입니다. 그런데 경매장 평균가는 누구나 마음대로 흔들 수 있는 값입니다. 팔 생각 없이 말도 안 되는 가격으로 몇 건만 올려도 평균이 확 튑니다. 팔린 적 없는 호가라서, 평균을 낼수록 실제 거래와 멀어집니다.
그래서 계약 단계에서부터 이 값을 노출 금지로 못 박았습니다. 필드를 지우지는 않았습니다. 집계 파이프라인 내부 진단에는 쓸 수 있으니 남기되, 계약에 deprecated를 달아 "이건 UI로 나가면 안 되는 값"이라는 의도를 타입에 새겼습니다.
// market_query.proto — 경매장 매물("경매장 매물") 측 요약
message ListingPriceSummary {
// 경매장 평균가 — deprecated: 평균가는 조작 가능해 UI에 노출하지 않는다. 비교에는
// lowest_unit_price(경매장 최저가)와 시세(SoldPriceSummary)를 사용한다.
optional double average_unit_price = 1 [deprecated = true];
// 경매장 최저가.
optional int64 lowest_unit_price = 2;
// 관측된 매물 row 수 (총량 아님).
int64 listing_count = 3;
// 경매장 매물 총량 — Σ(매물 수량). "경매장 매물 총액"은 lowest_unit_price ×
// total_quantity. listing_count로 대체하지 않는다.
int64 total_quantity = 4;
}주석까지 계약에 남긴 이유가 있습니다. 몇 달 뒤 다른 화면을 만들다 이 필드를 보면, "평균가 있네, 이거 쓰면 되겠다"는 유혹이 다시 옵니다. deprecated 플래그와 한 줄 설명이 그 유혹을 막는 최전선입니다.
여기서 하나 더 짚을 게 있습니다. listing_count(관측된 매물 row 수)를 매물 총량으로 쓰면 안 됩니다. 한 row가 여러 개 묶음(quantity)일 수 있으니까요. **매물 총량은 total_quantity(Σ 수량)이고, 매물 총액은 lowest_unit_price × total_quantity**입니다. 이 구분을 흐리면 "10개 매물"과 "10만 개 물량"이 같은 값으로 뭉개집니다.
매물 총액과 시세 총액을 나누다
이 편의 핵심입니다. 화면에 찍히는 총액에는 성격이 전혀 다른 두 가지가 있었습니다.
- 매물 총액: 지금 경매장에 걸려 있는 물량의 가치. active listing에서 나옵니다.
- 시세 총액: 실제로 체결돼 손바뀜이 일어난 골드.
auction_sales에서 나옵니다.
하나는 "지금 시장에 얼마어치가 나와 있나"이고, 다른 하나는 "실제로 얼마가 거래됐나"입니다. 예전 대시보드는 이 둘을 하나의 price summary로 뭉쳐, active-listing 집계에서 나온 값에 "시세"라는 라벨을 붙여 놨습니다. 팔린 적 없는 호가를 "시세"라고 부른 셈입니다.
그래서 계약을 경매장 매물(listing) 측과 시세(sold) 측으로 엄격히 갈랐습니다. 두 값은 애초에 다른 파이프라인에서 흘러옵니다.
flowchart TD
subgraph L["경매장 매물 (listing-side)"]
LR["auction_listings_current<br/>현재 걸려 있는 호가"] --> LMIN["경매장 최저가<br/>lowest_unit_price"]
LR --> LQTY["매물 총량<br/>Σ quantity = total_quantity"]
LMIN --> LTOT["매물 총액<br/>= 최저가 × 매물 총량"]
LQTY --> LTOT
end
subgraph S["시세 (sold-side)"]
SR["auction_sales<br/>실제 체결 이력"] --> SAVG["시세 평균<br/>average_unit_price"]
SR --> STOT["시세 총액<br/>= Σ (unit_price × quantity)"]
SAVG --> SCHG["등락폭<br/>시세 평균 기준"]
end
LTOT -.->|절대 섞지 않음| X(( ))
STOT -.->|절대 섞지 않음| X이 분리를 계약에도 두 개의 message로 못 박았습니다. SoldPriceSummary는 오로지 체결 데이터에서만 파생되고, active listing은 절대 여기에 들어오지 않습니다.
// market_query.proto — 시세("시세") 측 요약. auction_sales 집계에서만 파생.
// latest_unit_price는 가장 최근 대표 체결가, average_unit_price는 시세 평균,
// change_rate(등락폭)는 window 내 부호 있는 변화율(시세 평균 기준),
// total_sold_gold(시세 총액)는 window 내 거래 총액, total_quantity(총판매량)는
// window 내 체결 수량이다.
message SoldPriceSummary {
optional int64 latest_unit_price = 1;
optional double average_unit_price = 2;
optional double change_rate = 3;
optional int64 total_sold_gold = 4;
int64 total_quantity = 5;
}용어를 정의로 고정해 두면, 이후 모든 계층이 이 표를 참조하게 됩니다.
| 구분 | 매물 총액 (listing total) | 시세 총액 (sold total) |
|---|---|---|
| 데이터 소스 | 현재 경매장 active listing | 체결된 auction_sales |
| 산식 | 경매장 최저가 × 매물 총량 | Σ (체결 단가 × 체결 수량) |
| 답하는 질문 | "지금 시장에 얼마어치가 나와 있나" | "실제로 얼마가 거래됐나" |
| 계약 필드 | ListingTotalRankingItem.listing_total_gold |
MarketTotalsChartPoint.sold_market_total_amount |
| 조작 가능성 | 최저가는 상대적으로 견고 | 체결 기록이라 견고 |
| 절대 규칙 | 최저가만 사용, 평균가 미노출 | 호가는 절대 포함 안 함 |
계약 단에서 갈라 두니, BFF의 REST DTO도 자연스럽게 두 요약으로 나뉩니다. 프론트가 실수로 호가를 시세로 렌더하는 길 자체가 막힙니다.
// market-bff · dashboard.dto.ts — REST 계약도 두 요약으로 분리
export class ListingPriceSummaryDto {
/** @deprecated 경매장 평균가 — 조작 우려로 UI 미노출. lowestUnitPrice/시세를 사용. */
averageUnitPrice?: number;
lowestUnitPrice?: number; // 경매장 최저가 (best ask)
listingCount!: number; // 관측된 row 수, 총량 아님
totalQuantity!: number; // 경매장 매물 총량 (Σ quantity)
}
export class SoldPriceSummaryDto {
latestUnitPrice?: number; // 시세
averageUnitPrice?: number; // 시세 평균
changeRate?: number; // 등락폭 (시세 평균 기준)
totalSoldGold?: number; // 시세 총액
totalQuantity!: number; // 총판매량
}화면의 인기 랭킹 표도 이 분리를 그대로 따릅니다. 컬럼은 순위 / 아이템 / 경매장 최저가 / 시세 평균 / 등락폭 / 순위 변동이고, "경매장 최저가" 컬럼은 listing 요약을, "시세 평균"과 "등락폭" 컬럼은 sold 요약을 읽습니다. 경매장 평균가 컬럼은 아예 존재하지 않습니다.
등락폭은 무엇을 기준으로
"이 아이템 올랐어, 내렸어?"에 답하는 등락폭도 기준을 정해야 했습니다. 후보는 두 가지였습니다. 경매장 호가의 변화냐, 체결가의 변화냐.
경매장 호가를 기준으로 삼으면, 앞서 말한 평균가의 함정이 등락폭에도 그대로 옮겨붙습니다. 누가 호가 하나 이상하게 올리면 "급등"으로 뜨는데, 정작 아무도 그 가격에 산 적이 없습니다. 그래서 등락폭은 시세 평균 기준으로만 계산합니다. 급등·급락(무버스) 목록의 정렬 기준도 경매장 매물이 아니라 sold 변화율입니다.
message MarketMover {
// ...
ListingPriceSummary listing_price_summary = 5; // context 용으로만 실림
SoldPriceSummary sold_price_summary = 6;
// window 내 부호 있는 변화율 (상승은 양수, 하락은 음수) — sold 기준.
double price_change_rate = 7;
}무버스 row에도 경매장 매물 요약이 함께 실리지만, 그건 참고용 맥락일 뿐 정렬이나 등락 판정에는 관여하지 않습니다. price_change_rate는 온전히 sold-side 값입니다. 이렇게 해두면 "왜 오른 걸로 표시됐지?"라는 질문에 언제나 "실제로 그 가격에 팔렸으니까"라고 답할 수 있습니다.
read model과 gRPC 계약을 다시
용어를 새로 정의했으니, 이제 그 정의가 데이터 소스부터 화면까지 한 줄로 흐르도록 계층 전체를 다시 짰습니다. 이 프로젝트에서 브라우저는 gRPC나 Prisma를 알면 안 됩니다. market-api가 gRPC로 계약을 내보내고, market-bff가 그걸 REST로 번역해 market-web에 내려줍니다. 용어가 어긋나면 이 줄 어딘가에서 의미가 새기 때문에, 같은 정의를 계층마다 반복해서 박아야 했습니다.
flowchart LR
A["PostgreSQL<br/>auction_sales · minute aggregates"] --> B["Prisma repository<br/>getMarketWideTotalsChart 등<br/>(raw SQL)"]
B --> C["read model port<br/>MarketDashboardReadPort"]
C --> D["application use case<br/>GetMarketTotalsChart /<br/>GetListingTotalRanking"]
D --> E["gRPC transport mapper<br/>domain → proto"]
E --> F["market_query.proto<br/>MarketTotalsChartPoint ·<br/>ListingTotalRankingItem"]
F --> G["market-bff<br/>REST DTO 매핑"]
G --> H["market-web widget<br/>totals chart · ranking table"]읽기 모델의 계약(port)은 도메인 언어 그대로입니다. "매물 총액"과 "시세 총액"이 포트 메서드와 주석에 그대로 적혀 있어서, 구현이 어느 테이블을 읽든 의미가 고정됩니다.
// market-application · market-dashboard-read.port.ts
export interface MarketDashboardReadPort {
/**
* Market-wide totals points … per bucket, the active-listing total value
* (경매장 매물 총액) and the completed-sale total value (시세 총액). Gaps are
* absent, never zero-filled.
*/
getMarketWideTotalsChart(query: MarketWideTotalsChartQuery): Promise<MarketWideTotalsPoint[]>;
/**
* Top items by current auction-listing total value (매물 총액), descending.
* Listing-side only; items with no listings observed since the query's
* recency floor are excluded.
*/
getListingTotalRanking(query: ListingTotalRankingQuery): Promise<ListingTotalRankingEntry[]>;
}홈 대시보드 응답도 두 시리즈를 명시적으로 담습니다. 홈 최상단 차트는 날짜축 위에 경매장 매물 총액(라인)과 시세 총액(막대)을 함께 그리고, 그 옆에 매물 총액 랭킹을 붙였습니다.
// 홈 hero 차트의 한 점: 두 시리즈를 같은 날짜 bucket에 정렬.
// auction_listing_total_amount(경매장 매물 총액) = Σ 최저가 × 매물 총량;
// sold_market_total_amount(시세 총액) = Σ total sold gold. gap bucket에서는
// 어느 쪽이든 absent — 0으로 채우지 않는다. 두 총액을 절대 섞지 않는다.
message MarketTotalsChartPoint {
int64 bucket_start_epoch_ms = 1;
optional int64 auction_listing_total_amount = 2;
optional int64 sold_market_total_amount = 3;
int64 contributing_item_count = 4;
}총액을 다시 계산하다 — de-inflation
계약을 갈아엎으면서 숨어 있던 버그 하나를 잡았습니다. 처음에는 분→시간→일 rollup 집계 테이블을 그냥 SUM해서 총액을 냈습니다. 그런데 수집기는 3분마다 현재 경매장 매물 전체와 최근 체결 ~100건을 다시 관측합니다. 그걸 rollup이 또 SUM하니, 같은 매물이 하루 ~1000번 중복 계산됐습니다. 그 결과 시세 총액이 ~31조, 매물 총액이 ~22조로 찍혔습니다. 던파 경제 규모를 아득히 넘는 숫자였습니다.
원인은 "관측 스냅샷의 합"과 "실제 총액"을 헷갈린 것이었습니다. 그래서 두 시리즈를 깨끗한 소스에서 다시 계산했습니다.
flowchart TD
BAD["folded aggregate를 그냥 SUM<br/>→ 3분마다 재관측이 중복 합산<br/>→ ~1000×/day 과다 계상"] -->|"시총 22조 · 실거래 31조 😵"| FIX
FIX["소스에서 다시 계산"]
FIX --> M["매물 총액 = 아이템별 bucket 내<br/>최신 minute 스냅샷의<br/>최저가 × 매물 총량<br/>(시점 시가총액, 합산 아님)"]
FIX --> SD["시세 총액 = bucket 내 실제<br/>auction_sales row의<br/>Σ 단가 × 수량<br/>(dedupe된 원본)"]
M -->|"시총 ~280억"| OK["현실적인 값"]
SD -->|"실거래 ~450~980억"| OK핵심은 매물 총액은 합산이 아니라 시점 스냅샷이라는 겁니다. 매 수집마다 더하는 게 아니라, bucket 안에서 아이템별 가장 최근 스냅샷 하나만 골라 그 시점의 시가총액을 냅니다. 반대로 시세 총액은 이미 dedupe된 auction_sales 원본 row를 직접 곱해 더합니다.
-- prisma-market-dashboard.repository.ts · getMarketWideTotalsChart (요약)
WITH listing AS (
SELECT
per_item.bucket_start AS bucket_start,
SUM(per_item.last_listing_min::double precision
* per_item.last_observed_qty::double precision)
FILTER (WHERE per_item.last_listing_min IS NOT NULL) AS listing_total
FROM (
SELECT
m.item_id,
-- 아이템별 bucket 내 "최신" 스냅샷의 최저가 · 매물 총량
(ARRAY_AGG(m.listing_min_unit_price ORDER BY m.bucket_start DESC)
FILTER (WHERE m.listing_min_unit_price IS NOT NULL))[1] AS last_listing_min,
(ARRAY_AGG(m.observed_total_quantity ORDER BY m.bucket_start DESC)
FILTER (WHERE m.listing_min_unit_price IS NOT NULL))[1] AS last_observed_qty
FROM item_market_minute_aggregates m
WHERE m.bucket_start >= ${'{since}'}
GROUP BY m.item_id, /* date_trunc(grain) */
) per_item
GROUP BY per_item.bucket_start
),
sold AS (
SELECT /* date_trunc(grain) */ AS bucket_start,
SUM(s.unit_price::double precision * s.quantity::double precision) AS sold_total
FROM auction_sales s
WHERE s.sold_at >= ${'{since}'}
GROUP BY /* date_trunc(grain) */
)
SELECT listing.bucket_start, listing.listing_total AS "auctionListingTotalAmount",
sold.sold_total AS "soldMarketTotalAmount"
FROM listing LEFT JOIN sold ON listing.bucket_start = sold.bucket_start
ORDER BY listing.bucket_start ASC이 재계산 뒤 값이 현실적인 규모(시세 총액 450980억, 매물 총액 ~280억)로 내려왔습니다. 계약을 다시 짜지 않았다면 이 버그는 "차트가 좀 크네" 정도로 넘어갔을 겁니다. 용어를 정의로 고정하는 작업이, 정의와 어긋난 계산을 드러내 준 셈입니다.
참고로 gap(점검·장애) bucket은 여전히 0으로 채우지 않습니다. (2)편에서 세운 원칙 그대로, 값이 없으면 absent로 두고 차트는 끊긴 구간으로 그립니다. 총액이 0으로 찍히면 급락으로 오해되기 때문입니다.
synthetic id를 총액에서 제외
로컬 개발과 통합 테스트는 catalog에 가짜 아이템을 seed합니다. seed-*(오프라인 fixture)와 integration-item-*(통합 테스트 fixture)가 그것입니다. 이 합성 데이터가 실서비스 총액이나 랭킹에 섞이면, 화면에 존재하지 않는 아이템이 1위로 뜨는 사고가 납니다.
purge 커맨드가 이 row들을 지우긴 하지만, 그것만 믿을 수는 없었습니다. 반쯤 정리된 로컬 DB가 화면으로 fixture를 흘리는 걸 막기 위해, 총액과 랭킹 read 쿼리 자체에 **방어선(defence-in-depth)**을 하나 더 뒀습니다. 실제 네오플 아이템 id는 32자리 hex라, 예약 prefix와 절대 충돌하지 않습니다.
// synthetic-item-filter.ts
export const SYNTHETIC_ITEM_ID_PREFIXES = ['seed-', 'integration-item-'] as const;
/** raw-SQL WHERE 절용 predicate. prefix는 사용자 입력이 아니라 하드코딩 리터럴이라
* 인젝션 표면이 없다. */
export function notSyntheticItemIdSql(itemIdColumn: Prisma.Sql): Prisma.Sql {
return Prisma.sql`${itemIdColumn} NOT LIKE 'seed-%' AND ${itemIdColumn} NOT LIKE 'integration-item-%'`;
}이 조건을 매물 총액 차트·시세 총액·랭킹 쿼리 모두에 WHERE ... AND notSyntheticItemIdSql(...)로 붙여, movers·차트와 동일하게 동작하도록 정렬했습니다. 데이터가 없을 때는 fake로 채우지 않고 "준비 중" placeholder를 보여줍니다. 없는 걸 있는 척하지 않는 원칙은 총액에도 똑같이 적용됩니다.
골드는 floor
마지막은 표기 규칙입니다. 골드는 정수 게임 화폐입니다. 평균이나 총액을 계산하다 보면 소수가 나오는데, 이걸 반올림하면 시장이 실제로 표현할 수 없는 가격이 화면에 생깁니다. 그래서 **모든 골드 표기는 floor(내림)**로 통일했습니다. 반올림해서 없는 1골드를 만들지 않기 위해서입니다.
// market-web · shared/lib/format/gold.ts
function truncOrNull(value: number | null | undefined): number | null {
if (value === null || value === undefined || !Number.isFinite(value)) {
return null;
}
return Math.trunc(value); // 절대 반올림하지 않는다
}
const TRILLION = 1_0000_0000_0000; // 조
const HUNDRED_MILLION = 1_0000_0000; // 억
const TEN_THOUSAND = 1_0000; // 만큰 수는 조/억/만 한국식 단위로 끊어 읽히게 하되, 절삭 방향은 언제나 내림입니다. 소수 한 자리로 축약하는 tooltip 표기(22.2조 같은)도 Math.floor(scaled * 10) / 10로 내림을 지킵니다. 이 단위 절삭과 포매팅 이야기는 뒤의 골드 표기 편에서 따로 자세히 다룰 예정입니다.
정리하면
이 편에서 배운 건, 화면의 숫자 하나가 그냥 숫자가 아니라는 것입니다.
- 경매장 평균가는 노출하지 않는다. 조작 가능한 값을 시세로 내보내면 사용자를 오도한다.
- 매물 총액(최저가 × 매물 총량)과 시세 총액(체결 총 골드)은 다른 소스에서 나오는 다른 질문이라, 계약·DTO·라벨 어디서도 섞지 않는다.
- 등락폭은 시세 평균 기준으로만 낸다. 호가의 등락은 등락이 아니다.
- 정의를 read model → gRPC proto → BFF DTO → 화면까지 한 줄로 꿰니, 정의와 어긋난 계산(총액 과다 계상)까지 드러났다.
- 골드는 floor, synthetic id는 총액·랭킹에서 제외, gap은 0으로 채우지 않는다.
라벨을 고치는 일처럼 보였지만, 실은 도메인 정의를 다시 세우는 일이었습니다. 잘못 정의한 숫자는 아무리 예쁘게 그려도 거짓말을 합니다.
다음 편에서는 이렇게 늘어난 화면 로직으로 뒤엉킨 market-web을 Feature-Sliced Design으로 다시 계층화한 리팩터링을 다루겠습니다.
