던플 개발기 (17): 골드를 읽기 좋게 — 조·억·만 단위와 숫자 포매팅
지난 편이 구조를 봤다면, 이번엔 아주 작지만 사용자가 매일 보는 디테일 — 큰 숫자를 어떻게 화면에 찍느냐 — 이야기입니다. 던플마켓은 게임 경제 시세 터미널이라, 화면 곳곳이 골드 금액입니다. 단가는 몇 백만, 매물 총액은 수백억, 시세 총액은 조 단위까지 올라갑니다. 이 숫자를 데이터베이스에 있는 그대로 찍으면 42000000000 같은 값이 표에 박히는데, 사람은 이 자릿수를 한눈에 못 셉니다.
이 편의 결론은 하나입니다. 정확한 숫자보다 읽히는 숫자가 UX였습니다. 다만 "읽기 좋게" 만드는 절삭에는 함정이 있었습니다. 소수점을 버리고 앞자리만 남기다 보면, 자칫 시장에 존재하지도 않는 가격을 화면에 만들어 낼 수 있습니다. 그래서 절삭 규칙은 반드시 한 방향(floor, 내림)으로 못박아야 했습니다.
한눈에 보면
- 큰 금액은
조(10^12) ·억(10^8) ·만(10^4) 한국식 만(myriad) 단위로 끊어 보여 줍니다.42000000000→420억. - 0인 단위는 생략하고, 세그먼트마다 3자리 comma를 넣습니다.
12,345,678,900→123억 4,567만 8,900. - 쓰는 곳마다 밀도가 달라, 포맷 함수를 셋으로 나눴습니다. 목록·표는 전체 분해, 차트 축은 앞 단위 하나, 차트 툴팁은 앞 단위+소수 첫째 자리.
- 골드는 소수가 없는 정수 화폐라, 모든 포맷 함수가 소수를 버립니다(floor/trunc). 절대 올림하지 않습니다.
- 금액과 수량을 다른 함수로 가릅니다. 수량(개수·명성)은 조·억·만도
골드도 붙이지 않습니다. - 이 규칙은 13편에서 정한 "매물 총액 / 시세 총액" 용어 계약과 같은 floor 원칙 위에 서 있습니다.
42,000,000,000은 못 읽는다
문제는 단순합니다. 사람은 42000000000을 보고 몇 자리인지 즉시 세지 못합니다. comma를 넣어 42,000,000,000으로 바꿔도 나아지긴 하지만, 여전히 "몇 억이지?"를 세느라 콤마 그룹을 손가락으로 짚게 됩니다. 서구식 세 자리 그룹(thousand-million-billion)은 한국어 화자의 머릿속 단위(만-억-조)와 어긋나기 때문입니다.
한국어로 큰 수를 읽을 때 우리는 10^4마다 단위를 바꿉니다. 만(10^4) → 억(10^8) → 조(10^12). 그래서 42,000,000,000을 읽으라고 하면 콤마가 아니라 뒤에서 네 자리씩 끊어 "사백이십억"으로 읽습니다. 화면도 그 방식으로 끊어 주면 됩니다. 420억이라고 찍으면 아무도 자릿수를 세지 않습니다.
던플마켓 골드 포맷은 전부 apps/market-web/src/shared/lib/format/gold.ts 한 파일에 모여 있습니다. 이 유틸은 market 슬라이스 지식이 없는 순수 숫자 포맷터라, FSD의 shared/lib/format에 두고 어느 위젯이든 슬라이스 public API로 가져다 씁니다. 시작은 세 개의 상수와 절삭 게이트입니다.
// apps/market-web/src/shared/lib/format/gold.ts
const TRILLION = 1_0000_0000_0000; // 조 (10^12)
const HUNDRED_MILLION = 1_0000_0000; // 억 (10^8)
const TEN_THOUSAND = 1_0000; // 만 (10^4)
const EM_DASH = '—';
function truncOrNull(value: number | null | undefined): number | null {
if (value === null || value === undefined || !Number.isFinite(value)) {
return null;
}
return Math.trunc(value);
}숫자 리터럴을 1_0000_0000_0000처럼 네 자리씩 끊어 적은 건 의도적입니다. 자바스크립트 관습대로 1_000_000_000_000으로 세 자리씩 끊으면 이게 조인지 억인지 눈으로 확인이 안 됩니다. 한국식 만 단위 체계를 다루는 코드라, 상수도 만 단위로 끊어 적어 두면 TRILLION이 정말 10^12인지 한눈에 검산됩니다.
truncOrNull은 두 가지를 한꺼번에 처리합니다. null · undefined · NaN · Infinity는 값이 없다는 뜻이므로 null로 접어 두었다가 화면엔 대시(—)를 찍습니다. 2편에서 정리한 원칙 — "없는 걸 0으로 위장하지 않는다" — 이 포맷 레이어에도 그대로 이어집니다. 없는 값은 0골드가 아니라 대시입니다. 그리고 유한한 값이면 Math.trunc로 소수를 버립니다. 이 trunc가 뒤에 나올 floor 정책의 시작점입니다.
조·억·만으로 끊기
핵심 함수 formatGold은 정수 골드를 큰 단위부터 나눠 떨어뜨립니다. 나눗셈으로 몫(세그먼트)을 뽑고, 나머지를 다음 단위로 넘기는 걸 조 → 억 → 만 → 나머지 순으로 반복합니다.
export function formatGold(value: number | null | undefined): string {
const truncated = truncOrNull(value);
if (truncated === null) {
return EM_DASH;
}
const sign = truncated < 0 ? '-' : '';
let rem = Math.abs(truncated);
const parts: string[] = [];
const jo = Math.floor(rem / TRILLION);
rem %= TRILLION;
if (jo > 0) parts.push(`${jo.toLocaleString('ko-KR')}조`);
const eok = Math.floor(rem / HUNDRED_MILLION);
rem %= HUNDRED_MILLION;
if (eok > 0) parts.push(`${eok.toLocaleString('ko-KR')}억`);
const man = Math.floor(rem / TEN_THOUSAND);
rem %= TEN_THOUSAND;
if (man > 0) parts.push(`${man.toLocaleString('ko-KR')}만`);
if (rem > 0) parts.push(`${rem.toLocaleString('ko-KR')}`);
if (parts.length === 0) return '0';
return `${sign}${parts.join(' ')}`;
}두 가지 규칙이 여기 박혀 있습니다. 0인 단위는 생략합니다. if (jo > 0)처럼 몫이 0인 단위는 parts에 넣지 않습니다. 그래서 1,000,000은 0억 100만 0이 아니라 그냥 100만으로 나옵니다. 그리고 모든 세그먼트가 0이면 "0"을 반환합니다. parts가 비어 있는 유일한 경우는 값이 0일 때인데, 이때 대시가 아니라 0을 찍습니다. 0은 "값이 없음"이 아니라 "정말 0골드"라는 뜻이기 때문입니다. 없음(대시)과 0(영)을 구분하는 건 시세 화면에서 특히 중요합니다.
값 12,345,678,900이 어떻게 분해되는지 단계별로 따라가 보겠습니다.
| 단계 | 나눗셈 | 몫(세그먼트) | 다음 나머지 |
|---|---|---|---|
| 조 | 12,345,678,900 ÷ 10^12 | 0 → 생략 | 12,345,678,900 |
| 억 | 12,345,678,900 ÷ 10^8 | 123 → 123억 |
45,678,900 |
| 만 | 45,678,900 ÷ 10^4 | 4,567 → 4,567만 |
8,900 |
| 나머지 | 8,900 | 8,900 → 8,900 |
0 |
결과는 123억 4,567만 8,900입니다. 흐름으로 보면 이렇습니다.
flowchart LR
V["12,345,678,900"] --> J["조로 나누기<br/>몫 0 → 생략"]
J --> E["억으로 나누기<br/>몫 123 → 123억"]
E --> M["만으로 나누기<br/>몫 4,567 → 4,567만"]
M --> R["나머지<br/>8,900 → 8,900"]
R --> OUT["123억 4,567만 8,900"]조만 이론상 자릿수 제한이 없어(수백조도 가능) 큰 값을 대비하고, 억·만·나머지는 구조상 각각 0~9,999 범위 안에 들어옵니다. 억 단위는 "10^12 미만을 10^8로 나눈 몫"이라 최대 9,999를 넘을 수 없기 때문입니다.
comma로 세그먼트 묶기
처음 조·억·만을 도입했을 땐 세그먼트에 콤마가 없었습니다. 123억 4567만 8900처럼요. 나쁘지 않지만, 네 자리 세그먼트(4567, 8900)를 다시 눈으로 세게 됩니다. 그래서 후속 커밋에서 각 세그먼트를 3자리 콤마로 그룹핑했습니다. toLocaleString('ko-KR')을 세그먼트마다 적용한 것이 위 코드의 ${eok.toLocaleString('ko-KR')}억 부분입니다.
전체 숫자에 콤마를 넣는 것과 뭐가 다르냐면, 조·억·만 경계가 이미 큰 덩어리를 끊어 주고, 콤마는 그 덩어리 안에서만 천 단위를 표시한다는 점입니다. 억·만·나머지 세그먼트는 최대 네 자리라 콤마가 많아야 하나씩 들어가고, 조는 커질 수 있어 완전한 천 단위 그룹핑이 유지됩니다.
| 입력(골드) | 콤마 없던 초기 | 세그먼트 콤마 적용 후 |
|---|---|---|
| 12,345,678,900 | 123억 4567만 8900 |
123억 4,567만 8,900 |
| 12,345,000,000,000 | 12조 3450억 |
12조 3,450억 |
| 80,000,000 | 8000만 |
8,000만 |
| 2,399 | 2399 |
2,399 |
| -53,120,000 | -5312만 |
-5,312만 |
2,399처럼 만 미만인 값은 조·억·만이 다 0이라 나머지만 남는데, 이때도 콤마 정수로 떨어집니다. 음수는 부호를 앞에 한 번만 붙이고 절댓값을 분해합니다. 던플에서 음수 골드가 직접 표시되는 일은 드물지만, 등락 델타 같은 파생값이 음수로 넘어올 수 있어 방어해 둡니다.
목록엔 전부, 축엔 앞자리만, 툴팁엔 소수 한 자리
같은 골드라도 놓이는 자리마다 필요한 밀도가 다릅니다. 표의 셀은 넓어서 전체 분해를 다 담을 수 있지만, 차트 세로축 눈금은 좁아서 6,920억 5,212만 4,200을 통째로 넣으면 축이 뭉개집니다. 그래서 포맷 함수를 세 개로 나눴습니다.
목록·표·단가는 formatGold — 전체 조·억·만 분해입니다. 차트 축·틱은 formatGoldCompact — 맨 앞 단위 하나만 남깁니다.
export function formatGoldCompact(value: number | null | undefined): string {
const truncated = truncOrNull(value);
if (truncated === null) return EM_DASH;
const sign = truncated < 0 ? '-' : '';
const abs = Math.abs(truncated);
if (abs >= TRILLION) {
return `${sign}${Math.floor(abs / TRILLION).toLocaleString('ko-KR')}조`;
}
if (abs >= HUNDRED_MILLION) {
return `${sign}${Math.floor(abs / HUNDRED_MILLION).toLocaleString('ko-KR')}억`;
}
if (abs >= TEN_THOUSAND) {
return `${sign}${Math.floor(abs / TEN_THOUSAND).toLocaleString('ko-KR')}만`;
}
return `${sign}${abs.toLocaleString('ko-KR')}`;
}692,052,124,200은 축에서 6,920억이 됩니다. 뒤 자리는 축의 스케일을 읽는 데 아무 도움이 안 되므로 과감히 버립니다.
문제는 축과 목록 사이, 차트 툴팁입니다. 마우스를 올렸을 때(모바일은 터치/클릭) 뜨는 툴팁에서 6,920억은 정보가 너무 적고, 전체 분해 6,920억 5,212만 4,200은 좁은 툴팁에 너무 깁니다. 그 중간을 위해 formatGoldCompactDecimal을 추가했습니다. 앞 단위 하나에 소수 첫째 자리까지만 붙입니다.
export function formatGoldCompactDecimal(value: number | null | undefined): string {
const truncated = truncOrNull(value);
if (truncated === null) return EM_DASH;
const sign = truncated < 0 ? '-' : '';
const abs = Math.abs(truncated);
const oneDecimal = (scaled: number): string =>
(Math.floor(scaled * 10) / 10).toLocaleString('ko-KR', {
minimumFractionDigits: 1,
maximumFractionDigits: 1,
});
if (abs >= TRILLION) return `${sign}${oneDecimal(abs / TRILLION)}조`;
if (abs >= HUNDRED_MILLION) return `${sign}${oneDecimal(abs / HUNDRED_MILLION)}억`;
if (abs >= TEN_THOUSAND) return `${sign}${oneDecimal(abs / TEN_THOUSAND)}만`;
return `${sign}${abs.toLocaleString('ko-KR')}`;
}여기서 oneDecimal의 Math.floor(scaled * 10) / 10이 중요합니다. toFixed(1)이나 maximumFractionDigits만 쓰면 반올림이 됩니다. 123.4567899억이 123.5억으로 올라가 버립니다. 우리가 원하는 건 내림이라, * 10 → floor → / 10으로 소수 둘째 자리 이하를 먼저 잘라내고, 그 다음 toLocaleString으로 자릿수 표기만 맞춥니다. minimumFractionDigits: 1은 5,312만이 5,312.0만처럼 소수 자리를 항상 유지하게 해 툴팁 안에서 세로 정렬이 흔들리지 않게 합니다.
같은 값 하나가 세 함수를 거치면 이렇게 갈립니다.
| 함수 | 692,052,124,200 출력 |
쓰는 곳 |
|---|---|---|
formatGold |
6,920억 5,212만 4,200 |
목록·표·단가 |
formatGoldCompact |
6,920억 |
차트 축·틱 |
formatGoldCompactDecimal |
6,920.5억 |
차트 툴팁·지표 |
formatQuantity |
692,052,124,200 |
(금액 아닌 수량일 때만) |
어떤 함수를 고를지는 "금액인가 / 어디에 두는가" 두 질문으로 결정됩니다.
flowchart TD
A[표시할 숫자] --> B{금액인가 수량인가?}
B -->|수량·개수·명성| Q["formatQuantity<br/>콤마 정수, 단위 없음"]
B -->|금액 골드| C{어디에 두나?}
C -->|목록·표·단가| F["formatGold<br/>조·억·만 전체 + 세그먼트 콤마"]
C -->|차트 축·틱| K["formatGoldCompact<br/>앞 단위 하나"]
C -->|차트 툴팁·지표| D["formatGoldCompactDecimal<br/>앞 단위 + 소수 1자리(내림)"]금액과 수량을 가른 건 사소해 보여도 중요합니다. 총 판매량 1,234,567개를 123만 4,567로 찍으면 골드처럼 읽힙니다. 그래서 수량은 formatQuantity로 조·억·만도 골드 접미사도 붙이지 않고 순수 콤마 정수로만 표시합니다. 조·억·만 절삭은 금액 전용 표기법이라는 걸 함수 경계로 못박은 셈입니다.
왜 반올림이 아니라 floor인가
여기가 이 편에서 가장 조심한 지점입니다. 큰 수를 줄이면 반드시 정보를 버려야 하는데, 버리는 방향이 곧 사용자에게 하는 약속이기 때문입니다.
골드는 소수가 없는 정수 게임 화폐입니다. 1,234.9골드짜리 매물은 시장에 존재하지 않습니다. 그래서 모든 포맷 함수의 첫 관문 truncOrNull이 Math.trunc로 소수를 버리고, 앞자리만 남기는 축약도 Math.floor로 내림합니다. 규칙은 한 문장입니다. 절대 올리지 않는다 — 시장이 표현할 수 없는 가격을 화면에 만들지 않는다.
반올림을 택했다면 어떻게 어긋나는지 표로 보겠습니다.
| 입력 | 코드(내림) 결과 | 반올림했다면 | 왜 내림인가 |
|---|---|---|---|
1,234.9 |
1,234 |
1,235 |
존재하지 않는 0.9골드를 만들지 않음 |
12,345,678,990 (툴팁 축약) |
123.4억 |
123.5억 |
시세를 실제보다 위로 부풀리지 않음 |
-1,234.9 (음수 델타) |
-1,234 |
-1,235 |
크기를 0쪽으로만 자르고 과장하지 않음 |
0 |
0 |
0 |
없음(—)과 구분되는 진짜 0 |
가운데 행이 특히 오해를 부르기 쉽습니다. 123.4567899억을 툴팁에 123.5억으로 올려 찍으면, 사용자는 시세가 반올림 전보다 높다고 착각합니다. 시세 화면에서 위로 부풀린 숫자는 그냥 부정확한 게 아니라, 살 때 비싸 보이고 팔 때 손해 보게 만드는 잘못된 신호입니다. 그래서 소수 축약에서도 Math.floor(scaled * 10) / 10으로 내림을 고수합니다.
한 가지 미묘한 구분을 짚고 넘어가겠습니다. 원값 절삭은 Math.trunc(0 방향 절삭), 세그먼트·소수 계산은 Math.floor(음의 무한대 방향 내림)입니다. 양수 골드에서는 둘이 완전히 같습니다. 차이는 음수에서만 납니다. Math.trunc(-1234.9)는 -1234, Math.floor(-1234.9)는 -1235입니다. 원값에 trunc를 쓴 이유는, 음수 델타에서 floor를 쓰면 -1234.9가 -1235로 더 큰 손실처럼 과장되기 때문입니다. 크기를 부풀리지 않는다는 같은 원칙이, 양수에선 floor로, 원값 절삭에선 0 방향 trunc로 구현된 것입니다. 다만 실제 세그먼트 분해는 항상 절댓값(Math.abs)에 대해 이뤄지므로, 그 안에서 floor와 trunc는 결과가 같습니다.
시세 용어(13편)와의 일관성
이 포맷 규칙은 혼자 정한 게 아니라, 13편에서 다시 설계한 가격 용어 계약 위에 서 있습니다. 그 편에서 매물 총액 = 최저가 × 매물 총량, 시세 총액 = 체결된 거래의 골드 합계로 정의하고, 경매장 평균가는 UI에 노출하지 않기로 못박았습니다. 그리고 그 총액들도 골드라서 floor로 계산합니다.
포맷 레이어의 floor와 도메인 레이어의 floor가 같은 방향이어야, 사용자가 보는 숫자가 앞뒤로 어긋나지 않습니다. 예를 들어 차트 툴팁의 시세 총액(formatGoldCompactDecimal)과 그 아래 총액 표(formatGold)가 같은 원본을 다르게 반올림하면, 스크롤 한 번에 값이 오락가락하는 것처럼 보입니다. 둘 다 내림이라 항상 "표시값 ≤ 실제값"이 성립하고, 축약 정도만 다를 뿐 방향이 일치합니다.
차트에서 두 계열의 툴팁이 실제로 이 함수를 쓰는 모습입니다.
// market-totals-chart 툴팁 — 경매장 매물 총액 / 시세 총액
`<span class="text-muted-foreground">경매장 매물 총액</span>` +
`<span>${formatGoldCompactDecimal(listingVal)}</span>` +
`<span class="text-muted-foreground">시세 총액</span>` +
`<span>${formatGoldCompactDecimal(soldVal)}</span>`;용어(무엇을 세느냐)는 13편에서, 표기(그 값을 어떻게 찍느냐)는 이 편에서 정한 셈입니다. 둘이 같은 floor 원칙을 공유하기 때문에 하나의 일관된 화면으로 읽힙니다.
정리하면
작은 주제였지만 원칙은 지난 편들과 같았습니다. 읽히지 않는 정확함보다, 읽히는 표기가 UX였습니다 — 단, 그 표기가 없는 값을 지어내지 않는 선에서.
- 큰 금액은 서구식 세 자리 그룹이 아니라 한국식 조·억·만(10^4 단위)으로 끊고, 0인 단위는 생략하며 세그먼트마다 콤마를 넣는다.
- 놓이는 자리의 밀도에 따라
formatGold(전체) ·formatGoldCompact(앞 단위) ·formatGoldCompactDecimal(앞 단위 + 소수 1자리)로 나눈다. - 골드는 정수 화폐라 모든 표기가 내림(floor/trunc)이다. 반올림하면 존재하지 않는 가격을 만들고 시세를 위로 부풀린다.
- 금액과 수량을 함수 경계로 갈라, 조·억·만·
골드표기가 금액 전용임을 코드로 못박는다. - 없음(
—)과 0을 구분하고, 이 floor 원칙을 13편의 가격 용어 계약과 정렬한다.
다음 편에서는 이 포맷된 총액을 실제로 그리는 총액 차트를 모바일에서 띄우려다 라이브러리 다섯 개를 오간 잔혹사 — iOS WebKit 터치와 컨테이너 측정 0 문제 — 를 풀어 보겠습니다.
