던플 개발기 (12): 대시보드 — 랭킹·무버스·실시간 채널·인기 점수
지난 편에서 단건 터미널을 만들었다면, 이번엔 전체 시장을 한눈에 보여 주는 대시보드 차례입니다. 터미널이 "이 아이템 하나가 지금 얼마인가"에 답한다면, 홈 대시보드는 "지금 시장에서 무엇이 뜨고, 무엇이 오르고 내렸는가"에 답해야 합니다.
처음에는 이걸 단순한 정렬 문제로 봤습니다. 거래량으로 ORDER BY 하면 인기 순위, 가격 차이로 정렬하면 급등·급락 아닌가. 그런데 만들다 보니 진짜 어려운 건 SQL이 아니었습니다. "무엇을 인기로 정의할 것인가", "무엇을 진짜 등락으로 볼 것인가" — 정의를 정하고 나면 쿼리는 그 정의를 옮겨 적는 일에 가까웠습니다. 이 글은 그 정의를 세우고, 대시보드를 read API·실시간 채널·인기 점수 스코어러로 나눠 구현한 기록입니다.
한눈에 보면
- 대시보드는 read 전용 화면 4종(홈·인기 목록·무버스·시장 차트)이고,
market-bffREST →market-apigRPC → 저장된 집계로만 흐릅니다. 공급자(네오플) 호출은 없습니다. - 인기 순위는 정렬이 아니라 점수입니다. 여러 신호를 모집단 대비로 정규화해 합산한 뒤 top-100 스냅샷을 3분마다 기록하고, 직전 스냅샷과 비교해 rank 등락을 계산합니다.
- 무버스(급등·급락)는 활성 매물이 아니라 체결 시세의 변화율로 뽑습니다. 표본이 얇거나 기준가가 너무 작은 아이템은 SQL 단계에서 걸러, 매물 하나짜리 blip이 급등으로 올라오지 않게 합니다.
- 실시간은 리스트 데이터를 밀지 않습니다. 게이트웨이는 "랭킹이 바뀌었다"는 작은 신호만 브로드캐스트하고, 클라이언트가 REST를 다시 조회합니다.
- rank가 바뀌면 행이 FLIP 애니메이션으로 새 위치로 미끄러집니다.
prefers-reduced-motion이면 모션 없이 숫자와 배지만 바뀝니다. - 즐겨찾기는 로그인 전까지 localStorage에 최대 20개만 둡니다. 서버 관심 목록은 아직 없습니다.
대시보드가 답해야 할 질문
던플마켓의 홈은 게임을 켜 두고 흘깃거리는 화면입니다. 그래서 답해야 할 질문이 분명했습니다.
- 지금 사람들이 많이 보는 아이템은 무엇인가 (인기 순위)
- 최근 창에서 오른 것과 내린 것은 무엇인가 (무버스)
- 시장 전체 규모는 어떻게 움직이는가 (시장 차트)
- 내가 찜한 것들은 지금 어떤가 (즐겨찾기)
이 넷을 market-bff의 REST 4종으로 노출했습니다. 중요한 건 경계입니다. 지난 편에서 세운 규칙 그대로, 브라우저와 market-web은 gRPC·Prisma·네오플 키를 모릅니다. 대시보드도 예외가 아닙니다.
flowchart LR
B["브라우저 / market-web SSR"] -->|"REST /api/v1"| BFF[market-bff]
BFF -->|"gRPC MarketQueryService"| API[market-api]
API -->|"집계 read model"| AGG[("hourly / daily<br/>aggregates")]
API -->|"랭킹 스냅샷"| SNAP[("popularity_ranking_snapshot")]
CRON["market-cron<br/>recalculate-popularity"] -->|"3분마다 top-100 기록"| SNAP
X["네오플 Open API"] -. "호출 없음" .- APImarket-bff의 컨트롤러는 이 흐름을 그대로 옮긴, 얇은 조합 계층입니다. gRPC로 받은 응답을 화면용 DTO로 매핑할 뿐 DB·Redis·Prisma·네오플을 직접 만지지 않습니다.
// apps/market-bff/src/app/dashboard/dashboard.controller.ts
@ApiTags('dashboard')
@Controller()
export class DashboardController implements OnModuleInit {
@Get('home')
async home(@Query('window') window?: string): Promise<HomeDashboardDto> {
const normalized = normalizeWindow(window);
const response = await firstValueFrom(
this.query.getHomeDashboard({
window: windowToProto(normalized),
popularLimit: 0, // 0 = 서버 기본값 사용
moverLimit: 0,
})
);
return mapHomeDashboard(response, normalized);
}
// popular-items(커서 페이지네이션), market-movers, market-chart …
}홈은 SSR이 인기·급등·급락·차트를 한 번에 조합해 내려받고, 인기 전체 목록(/populars)은 커서 기반 무한 스크롤을 씁니다. 데이터가 아직 없을 때는 빈 목록이 아니라 "준비 중" 상태를 내려보냅니다 — (2)편의 freshness 원칙을 대시보드에도 그대로 적용해, 빈 화면을 "고장"으로 읽히지 않게 했습니다.
랭킹·무버스 read API
이 read 경로의 규칙은 하나입니다. 화면 조회는 저장된 집계만 읽고, 공급자를 절대 호출하지 않는다. 사용자가 대시보드를 열 때마다 네오플을 때리면 rate limit도, 점검 내성도 무너집니다. 그래서 랭킹은 미리 계산해 스냅샷으로 저장해 두고, 무버스는 이미 굴려 둔 시간·일 단위 집계에서 뽑습니다.
창(window)은 24시간과 7일
랭킹과 무버스는 모두 24h 또는 7d 창에서 계산합니다. 여기에 데이터 보관 정책이 얽힙니다. 분 단위 상세 집계는 30일만 보관하므로, 창 비교의 기준 테이블은 영구 보관되는 시간·일 단위 집계로 잡았습니다.
// libs/market/data-access/.../prisma-market-dashboard.repository.ts
const WINDOW_AGG_TABLE: Record<RankingWindow, Prisma.Sql> = {
'24h': Prisma.raw('item_market_hourly_aggregates'), // 24h → 시간 버킷
'7d': Prisma.raw('item_market_daily_aggregates'), // 7d → 일 버킷
};무버스는 "매물"이 아니라 "시세"의 변화율
가장 오래 고민한 부분입니다. 급등·급락을 무엇으로 잴 것인가. 경매장에 올라온 활성 매물의 최저가로 재면, 누군가 비싼 매물 하나를 올리는 순간 "급등"이 됩니다. 조작에 취약하고, 실제 거래된 값도 아닙니다.
그래서 무버스는 체결 시세(auction_sales 기반 집계)의 변화율로만 뽑기로 했습니다. 창 안에서 가장 최근의 유효한 시세 평균을 현재값, 가장 오래된 유효값을 기준값으로 삼고, 표본은 창 내 체결 수로 둡니다. 그리고 얇은 데이터를 SQL 단계에서 걸러냅니다.
-- getMoverCandidates: 창 집계를 접어 시세 변화율 후보를 뽑는다
WITH w AS (
SELECT
a.item_id AS item_id,
(ARRAY_AGG(a.sold_average_unit_price ORDER BY a.bucket_start DESC)
FILTER (WHERE a.sold_average_unit_price IS NOT NULL))[1] AS current_price,
(ARRAY_AGG(a.sold_average_unit_price ORDER BY a.bucket_start ASC)
FILTER (WHERE a.sold_average_unit_price IS NOT NULL))[1] AS reference_price,
SUM(a.sold_count) AS sample_count
FROM item_market_hourly_aggregates a
WHERE a.bucket_start >= $since
AND <synthetic id 제외>
GROUP BY a.item_id
)
SELECT item_id, current_price, reference_price, sample_count
FROM w
WHERE current_price IS NOT NULL -- 시세가 있어야 하고
AND reference_price IS NOT NULL -- 비교 대상도 있어야 하고
AND sample_count >= $minSampleCount -- 표본이 충분해야 하고
AND reference_price >= $minReferencePrice -- 분모(기준가)가 너무 작지 않아야 한다
ORDER BY (current_price - reference_price) / NULLIF(reference_price, 0) $dir
LIMIT $limitSQL이 후보를 걸러 내려보내면, application 계층이 도메인 규칙으로 한 번 더 검증합니다. 순수 함수 computePriceChange는 데이터가 못 쓸 상태(가격 누락·표본 부족·분모 과소)면 null을 돌려주고, use case는 방향과 어긋난 행까지 떨궈냅니다.
// libs/market/domain/src/lib/market-mover.ts
export function computePriceChange(
input: PriceChangeInput,
thresholds: PriceChangeThresholds = DEFAULT_PRICE_CHANGE_THRESHOLDS
): PriceChange | null {
const { currentUnitPrice, referenceUnitPrice, sampleCount } = input;
if (currentUnitPrice === null || referenceUnitPrice === null) return null;
if (sampleCount < thresholds.minSampleCount) return null; // 표본 하한
if (referenceUnitPrice < thresholds.minReferencePrice) return null; // 분모 하한
const rate = (currentUnitPrice - referenceUnitPrice) / referenceUnitPrice;
return { rate, currentUnitPrice, referenceUnitPrice };
}임계값은 코드에 박은 마법 숫자가 아니라 "첫 운영값"으로 두고, 메트릭을 보며 조정할 대상으로 명시했습니다. (2)편에서 임계값을 환경 변수로 뺐던 것과 같은 이유입니다.
한 가지 더. 모든 쿼리에는 synthetic id 제외 조건이 붙습니다. 개발 baseline에 섞어 둔 합성 아이템이 랭킹·무버스·차트에 새어 나오지 않게, 데이터 접근 계층에서 아예 제외합니다.
실시간 랭킹 채널과 rank 애니메이션
대시보드를 실시간으로 만들 때 흔한 실수는 "바뀐 목록 전체를 소켓으로 밀어 넣는 것"입니다. 그러면 게이트웨이가 랭킹 정렬·페이지네이션·freshness 같은 read 로직을 다시 갖게 되고, 소켓과 REST가 서로 다른 계산을 하게 됩니다.
그래서 방향을 정반대로 잡았습니다. 실시간은 "무엇이 바뀌었다"는 신호만 보내고, 진짜 데이터는 REST가 계산한다. (8)편의 가격 게이트웨이가 세운 원칙 — realtime push는 새 공급자 호출이 아니라 저장된 스냅샷이 바뀌었다는 알림 — 을 대시보드에도 이어 붙였습니다.
프로토콜에 대시보드 브로드캐스트 채널을 추가했습니다. 아이템별 구독과 달리 subscribe-dashboard는 itemId가 없는 단일 그룹이고, 받는 메시지도 리스트가 아닌 작은 신호입니다.
// libs/market/realtime/src/lib/protocol.ts
export const clientMessageSchema = z.discriminatedUnion('action', [
z.object({ action: z.literal('subscribe-items'), itemIds }), // 보이는 행 일괄 구독
z.object({ action: z.literal('unsubscribe-items'), itemIds }),
z.object({ action: z.literal('subscribe-dashboard') }), // 랭킹/공급자 신호
z.object({ action: z.literal('unsubscribe-dashboard') }),
z.object({ action: z.literal('heartbeat') }),
/* … 단건 subscribe/unsubscribe … */
]);
/** 대시보드 read model이 바뀌었다는 신호. 리스트 데이터는 싣지 않는다. */
export interface DashboardUpdatedMessage {
readonly type: 'market.dashboard.updated';
readonly reason: 'popularity' | 'provider'; // 랭킹 재계산 / 공급자 상태 변경
readonly occurredAt: string;
}게이트웨이는 이제 Kafka에서 세 토픽을 소비합니다. 가격 이벤트(아이템 구독자용), 인기 재계산 이벤트, 공급자 상태 변경 이벤트. 토픽 이름은 market-events 계약에서 파생해, 이름이 바뀌어도 리터럴이 낡아 조용히 어긋나지 않게 했습니다. 인기·공급자 이벤트가 오면 대시보드 세션에 신호를 브로드캐스트합니다.
// apps/realtime-gateway/src/app/gateway.ts (핵심만)
} else if (event.eventType === 'market.popularity.recalculated') {
// 랭킹 재계산 → 대시보드 채널 새로고침 신호
hub.onDashboardSignal('popularity', new Date());
} else if (event.eventType === 'provider.status.changed') {
hub.onDashboardSignal('provider', new Date());
}전체 흐름은 이렇습니다.
sequenceDiagram
participant Cron as market-cron
participant Kafka
participant GW as realtime-gateway
participant Web as 브라우저
participant BFF as market-bff
Cron->>Kafka: market.popularity.recalculated
Kafka->>GW: 이벤트 소비
GW->>Web: market.dashboard.updated (리스트 없음, reason=popularity)
Web->>BFF: 대시보드 REST 재조회 (query invalidate)
BFF-->>Web: 새 랭킹 스냅샷
Note over Web: rank 변화 → FLIP 애니메이션클라이언트: 두 채널을 한 소켓으로
브라우저 훅 useDashboardRealtime은 한 소켓 위에 두 가지를 얹습니다. 대시보드 채널(랭킹/공급자 신호)과, 화면에 보이는 행들의 union(인기+급등+급락+즐겨찾기)에 대한 아이템 구독입니다. 여기서 두 신호를 다르게 다룹니다.
- 대시보드 신호는 4초 창으로 throttle합니다(leading+trailing). 재계산이 몰아쳐도 REST 재조회는 창당 한 번입니다.
- 아이템 가격 patch는 throttle하지 않습니다. 행 하나의 값만 갱신하는 값싼 업데이트라, 캐시에서 해당 행만 갈아 끼웁니다.
- 소켓이 끊기면
connected=false가 되고, 호출부는 그동안 3분 폴링으로 대체합니다. 재연결되면 전체 구독을 다시 걸고onSignal을 한 번 쏴 오프라인 동안 놓친 이벤트를 재조정합니다.
행을 in-place로 갱신하는 patch는 순수 함수로 뺐습니다. 여기에 두 가지 규칙을 넣었습니다. 하나는 버전 재조정(오래된 이벤트가 새 값을 덮지 못하게), 다른 하나는 매물/시세를 절대 섞지 않기입니다.
// apps/market-web/src/entities/dashboard/model/dashboard-patch.ts
export function applyPricePatch<T extends PricedRow>(row: T, patch: ItemPricePatch): T {
// 다른 아이템이거나, 행보다 오래된 patch면 같은 참조 반환(리렌더 skip)
if (row.itemId !== patch.itemId || isOlderThanRow(patch, row)) return row;
return {
...row,
// 매물(경매장): 최신 분 버킷이 권위 — 통째로 교체. 없으면 비워서 "—"
listingPriceSummary: {
...(patch.listingPriceSummary.lowestUnitPrice !== undefined
? { lowestUnitPrice: patch.listingPriceSummary.lowestUnitPrice }
: {}),
listingCount: patch.listingPriceSummary.listingCount,
totalQuantity: patch.listingPriceSummary.totalQuantity,
},
// 시세(체결): 최신 대표가만 merge. 창 집계(평균·등락폭·총액)는 REST 값 유지
soldPriceSummary: {
...row.soldPriceSummary,
...(patch.soldPriceSummary.latestUnitPrice !== undefined
? { latestUnitPrice: patch.soldPriceSummary.latestUnitPrice }
: {}),
},
lastUpdatedAt: patch.observedAt,
};
}patch는 값만 바꿀 뿐 순서를 다시 계산하지 않습니다. 순위 재정렬은 오직 대시보드 신호 → REST 재조회를 통해서만 일어납니다. 이렇게 나눠야 "값은 실시간, 순서는 스냅샷 기준"이라는 일관성이 지켜집니다.
rank가 바뀌면 미끄러진다
순위가 바뀌었을 때 행이 툭 하고 순간이동하면, 사용자는 무엇이 어디로 갔는지 못 따라갑니다. 그래서 각 행에 itemId를 안정적인 identity로 부여하고, 위치가 바뀌면 FLIP 기법으로 이전 자리에서 새 자리로 애니메이션합니다.
// apps/market-web/src/widgets/popular-ranking-table/lib/use-flip-rows.ts
rows.forEach((row) => {
const id = row.dataset['flipId']; // 안정적 itemId
const top = row.getBoundingClientRect().top; // 새 위치 측정
next.set(id, top);
if (prefersReducedMotion) return; // 모션 없으면 위치만 추적
const prevTop = previous.current.get(id);
if (prevTop === undefined) return;
const delta = prevTop - top; // 이동 거리
if (Math.abs(delta) < 1) return;
row.animate([{ transform: `translateY(${delta}px)` }, { transform: 'translateY(0)' }], {
duration: 240,
easing: 'ease-out',
});
});의존성 없이 Web Animations API만 씁니다. prefers-reduced-motion이 켜져 있으면 위치는 추적하되 애니메이션을 걸지 않아, 순위·텍스트·배지는 그대로 즉시 갱신됩니다. 등락 배지 자체는 도메인 순수 함수가 계산합니다.
// libs/market/domain/src/lib/ranking-movement.ts
export function computeRankMovement(currentRank, currentScore, previous) {
if (previous === null) return { rankDirection: 'new', rankDelta: null /* … */ }; // 직전 스냅샷에 없던 신규
const rankDelta = previous.rank - currentRank; // 양수면 위로 올라감(1위 방향)
const rankDirection = rankDelta > 0 ? 'up' : rankDelta < 0 ? 'down' : 'same';
return { previousRank: previous.rank, rankDelta, rankDirection /* … */ };
}인기 점수를 어떻게 정의했나
여기가 이 글의 핵심이자, 가장 오래 붙든 문제입니다. "인기"는 하나의 숫자로 존재하지 않습니다. 조회가 많은 것, 거래가 많은 것, 큰돈이 오간 것, 실시간으로 지켜보는 사람이 많은 것 — 다 다릅니다. 이 신호들을 어떻게 하나의 순위로 합치느냐가 곧 "무엇을 인기로 볼 것인가"의 답이었습니다.
이후 서비스화를 고려해, 신호와 정규화 방식은 설명하되 구체적인 가중치 상수는 공개하지 않습니다. 여기서 중요한 건 숫자가 아니라 왜 그렇게 정규화했는가입니다.
먼저 어떤 신호를 쓸지 정했습니다.
| 신호 | 의미 | 상대적 비중 |
|---|---|---|
| 상세 조회수 | 세션 단위로 중복 제거된 터미널 진입 | 높음 |
| 실시간 구독자 수 | 지금 실시간으로 지켜보는 사람 | 높음 |
| 체결 수량 | 창 내 판매 수량 | 높음 |
| 체결 총액 | 창 내 거래된 골드 | 중간 |
| 가격 변동성 | 최고/최저 스프레드 비율 | 낮음 |
| 매물 변경 횟수 | 창 내 매물 변경 이력 수 | 낮음 |
문제는 이 신호들의 자릿수가 제각각이라는 점입니다. 체결 총액은 수십억이고 조회수는 수십입니다. 그냥 더하면 골드가 순위를 삼켜 버립니다.
처음(0807)엔 급한 대로 각 신호에 log1p를 씌워 자릿수를 눌러 주고 고정 가중치로 합치는 임시 스코어러를 뒀습니다. 아이템 하나만 보고 점수를 내는 방식이라 빠르게 안정적 순위를 만들 수 있지만, 모집단을 보지 않는다는 한계가 분명했습니다.
그다음(0601)에 만든 본 스코어러는 정규화 방식을 바꿨습니다. 각 신호에 log1p를 씌운 뒤, 전체 후보 모집단 안에서의 백분위(percentile rank) 로 바꿉니다. 그러면 신호의 원래 스케일이 무엇이든 모든 값이 [0, 1]에 들어오고, 어떤 고자릿수 신호도 순위를 지배할 수 없습니다.
// libs/market/domain/src/lib/full-popularity-score.ts (정규화 핵심)
function percentileRanks(values: readonly number[]): number[] {
const n = values.length;
if (n === 0) return [];
return values.map((value) => {
let strictlyLess = 0;
for (const other of values) if (other < value) strictlyLess += 1;
return strictlyLess / (n - 1); // 최소=0, 최대=1, 활동 없는 신호는 0
});
}
// 각 신호를 log1p → percentileRank로 정규화한 뒤, 가중합으로 최종 점수.
// log1p는 순서를 보존하므로 순위를 바꾸지 않고 파이프라인만 일관되게 유지한다.백분위 방식은 편포와 이상치에 강합니다. 활동이 전혀 없는 신호(모두 0)는 모두 0으로 매핑돼 순위에 기여하지 않고, 특정 아이템의 폭발적인 거래액도 "1위"라는 정규화된 상한을 넘지 못합니다.
flowchart TB
S["윈도우 신호 읽기<br/>조회·구독·수량·금액·변동성·매물변경"] --> L["log1p 압축"]
L --> P["모집단 대비 백분위 정규화 (0~1)"]
P --> W["가중합"]
W --> SORT["점수 내림차순 정렬"]
SORT --> TOP["top-100 스냅샷 기록 (최근 3배치 유지)"]
TOP --> MOVE["직전 스냅샷과 비교 → rank 등락"]이 재계산은 market-cron의 recalculate-popularity 타깃이 3분 주기로 굴립니다. top-100을 PostgreSQL 스냅샷으로 쓰고, Redis 정렬 집합에도 미러링하며(Redis가 죽어도 PostgreSQL 경로는 안 끊깁니다), 24h 창에서는 수집 tier까지 다시 평가합니다. 스냅샷은 최근 배치만 남기고 정리해, 3분 cadence에서 멱등하게 돌아갑니다.
한 가지는 의도적으로 뺐습니다. 찜(watchlist) 수는 점수에 넣지 않았습니다. 서버 관심 목록이 아직 없고 즐겨찾기는 브라우저 localStorage에만 있어서, 서버가 신뢰할 수 있는 신호가 아니기 때문입니다. 없는 신호를 억지로 끼워 넣지 않는 것 — 이것도 (1)편의 "거짓 정밀도를 만들지 않는다"의 연장입니다.
그리고 스코어러가 실제로 신호를 받으려면 배선이 필요했습니다. 초기엔 조회수·구독자 수 컬럼이 production에서 0이었고 합성 seed로만 채워졌습니다. 이후 이 둘을 실제 런타임 경로에 연결했습니다.
- 상세 조회수:
/item/[id]를 열면 web → BFF(POST /items/:id/view) → market-api →market_interest_events로 기록합니다. 브라우저는 익명 localStorage 세션 id를 헤더로 보내고, market-api는 저장 전에 해시한 뒤 창 안에서 중복을 제거합니다. 로그인도, 서버 watchlist도 없습니다. - 실시간 구독자 수: realtime-gateway가 아이템별 실시간 구독자 수를 주기적으로 market-api에 보고해
tracked_items.active_subscriber_count에 반영합니다. 게이트웨이는 DB를 직접 쓰지 않고, 빠진 아이템은 0으로 감쇠하는 self-healing 전체 스냅샷을 보냅니다.
localStorage 즐겨찾기
즐겨찾기는 처음부터 서버를 두지 않기로 했습니다. 로그인 기능이 없는 단계에서 계정·watchlist 테이블을 먼저 만드는 건 과설계입니다. 대신 프론트엔드 규칙 그대로, localStorage에 최대 20개만 둡니다.
설계에서 신경 쓴 건 세 가지입니다. 깨지지 않을 것(SSR·시크릿 모드·quota 초과에서 절대 throw하지 않기), 미래 계정 병합을 대비할 것(id 우선 저장), 같은 탭에서도 반응할 것입니다.
// apps/market-web/src/entities/favorite/model/favorites.ts
export const FAVORITES_STORAGE_KEY = 'dunplay:market:v1:favorites';
export const MAX_FAVORITES = 20;
// 같은 문서 내 다른 훅이 다시 읽도록 커스텀 이벤트를 쏜다.
// 네이티브 'storage' 이벤트는 *다른* 탭에서만 발생하기 때문.
export const FAVORITES_EVENT = 'dunplay:favorites-changed';
export function toggleFavorite(item: FavoriteItem): FavoriteItem[] {
const current = readFavorites();
const next = isFavorited(item.itemId, current)
? current.filter((f) => f.itemId !== item.itemId)
: [{ itemId: item.itemId, name: item.name }, ...current].slice(0, MAX_FAVORITES);
return persist(next); // 최신 우선, 중복 제거, 20개 상한
}storage() 접근부터 JSON.parse까지 모든 단계를 try/catch로 감싸고, 실패하면 빈 목록을 돌려줍니다. 즐겨찾기는 best-effort 클라이언트 상태라, 저장에 실패해도 화면이 죽으면 안 됩니다. 항목을 { itemId, name } 형태로 id를 앞세워 저장한 건, 나중에 계정이 생기면 item ID로 병합할 수 있게 하려는 포석입니다(name은 표시용 캐시일 뿐입니다).
그리고 즐겨찾기한 아이템들도 대시보드의 실시간 아이템 구독 union에 들어갑니다. 홈에서 찜 목록의 가격이 다른 행들과 똑같이 실시간으로 갱신되는 이유입니다.
e2e smoke로 신호 검증
이 대시보드에는 여러 프로세스에 걸친 경로가 많습니다. 단위·통합 테스트로는 "market-api gRPC → BFF REST → 브라우저" 같은 프로세스 경계를 넘는 흐름을 못 잡습니다. 그래서 두 종류의 개발자용 smoke를 두고 손으로 돌려 확인했습니다.
대시보드 read smoke (local-market-smoke.mjs)는 item-api·market-api·market-bff를 dist에서 부팅하고, 4종 대시보드 엔드포인트가 모두 2xx JSON을 내는지 확인합니다. 여기서 중요한 판정 기준 하나 — 데이터가 부족해 목록이 비어도 에러가 아니라 성공으로 봅니다. 대시보드는 "준비 중"을 정상 상태로 다루기 때문입니다.
dashboard read endpoints (task 0807):
GET /api/v1/home → 2xx JSON
GET /api/v1/popular-items?window=24h&limit=10 → 2xx JSON
GET /api/v1/market-movers?direction=up&window=24h&limit=5 → 2xx JSON
GET /api/v1/market-chart?range=30d&metric=soldTotalGold → 2xx JSON로컬 docker 런타임을 복구한 뒤 이 smoke를 끝까지 돌려 11개 체크를 통과했고, 그 시점에 /와 /populars가 소비하는 BFF 대시보드 경로가 살아 있음을 live로 재확인했습니다.
production 신호 smoke (realtime-signal-smoke.mjs)는 한 걸음 더 갑니다. market-api·market-bff·realtime-gateway를 함께 띄우고, 인기 점수의 두 런타임 신호를 live PostgreSQL로 e2e 검증합니다.
POST /api/v1/items/:id/view→market_interest_events에 세션 중복 제거된 해시 행이 쌓이는지- WebSocket 구독 → 게이트웨이의 주기 보고 →
tracked_items.active_subscriber_count에 반영되고, 소켓을 닫으면 그 값이 0으로 감쇠하는지
프로세스가 분리돼 있어 유닛 테스트가 못 건드리던 바로 그 경계를, 실제 DB 행으로 확인하는 검증입니다.
정리하면
이 편의 전제는 하나였습니다. 랭킹은 정렬이 아니라 "무엇을 인기로 정의하느냐"의 문제다.
- 대시보드는 read 전용 4종이고, 저장된 집계만 읽으며 공급자를 호출하지 않는다. 랭킹은 미리 계산한 스냅샷, 무버스는 굴려 둔 집계에서 뽑는다.
- 무버스는 활성 매물이 아니라 체결 시세로 재고, 얇은 표본·과소 분모는 SQL과 도메인 규칙에서 이중으로 걸러 조작과 blip을 막는다.
- 실시간은 리스트를 밀지 않는다. "바뀌었다"는 신호만 보내고 REST가 진짜 계산을 한다. 값은 실시간 patch로, 순서는 스냅샷 재조회로 — 두 경로를 섞지 않는다.
- 인기 점수는 여러 신호를 모집단 대비 백분위로 정규화해, 어떤 고자릿수 신호도 순위를 지배하지 못하게 했다. 신뢰할 수 없는 신호(찜 수)는 억지로 넣지 않았다.
- 즐겨찾기는 서버 없이 localStorage로 두되, 절대 깨지지 않고 미래 병합을 대비하게 만들었다.
정렬 쿼리 한 줄로 끝날 줄 알았던 화면이, 결국 "정의"에 관한 이야기가 됐습니다. 무엇을 인기로 볼지, 무엇을 등락으로 볼지 정하고 나니 나머지는 그 정의를 정직하게 옮겨 적는 일이었습니다.
다음 편에서는 이 대시보드가 보여 주는 숫자 자체로 내려가, 시세 총액과 매물 총액을 어떻게 다르게 정의하고 계약을 다시 짰는지 — 잘못 정의하면 사용자를 오도하는 가격 용어 이야기를 다루겠습니다.
