던플 개발기 (15): 원시값을 타입으로 — branded types와 DTO→Model seam
지난 편에서 계층으로 경계를 세웠다면, 이번엔 타입으로 경계를 세운 이야기입니다. FSD로 "무엇이 무엇을 알아도 되는가"를 폴더 규칙으로 정리했지만, 정작 그 계층 사이를 오가는 값은 여전히 헐거웠습니다. 골드 총액도 number, 매물 개수도 number, 아이템 id도 string, 경매 번호도 string. 겉보기 타입이 같으니 컴파일러 입장에서는 서로 완전히 호환되는 값이었습니다.
던플마켓은 화면 하나에 숫자가 빽빽합니다. 최저가·시세 평균·시세 총액·매물 총량·등락률·인기 점수가 한 카드에 같이 놓입니다. 이 중 상당수가 number라는 한 글자짜리 타입으로 뭉뚱그려져 있었고, 그래서 "골드를 넣어야 할 자리에 개수를 넣는" 종류의 실수를 컴파일러가 하나도 막아 주지 못했습니다. 이 글은 그 헐거움을 branded type으로 조이고, DTO와 도메인 Model 사이에 변환 seam을 세워 실수를 런타임이 아니라 컴파일 타임으로 앞당겨 잡은 기록입니다.
한눈에 보면
- TypeScript는 구조적 타입이라
Gold도listingCount도 그냥number면 서로 대입됩니다. 컴파일러가 못 막습니다. Brand<T, Name>으로Gold·ItemId·AuctionId에 이름표를 붙여 명목 타입처럼 다뤘습니다.- 브랜드는 컴파일 타임 전용이라 런타임에는 지워집니다.
Gold는 그대로number로 읽혀서, 소비자 코드는 손대지 않아도 됩니다. - 브랜딩은 경계에서만 합니다. 신뢰하는 BFF 응답은
as <Dto>캐스트로, raw wire 값은toGold·toItemId팩토리 한곳으로 통과시킵니다. - DTO(경계의 전송 모양)와 Model(내부 도메인 모양)을 나누는 seam을 세웠습니다.
MarketItemDto는shared/api가,MarketItem+toMarketItem은entities/item이 소유합니다. - 매핑이 항등(identity)이라 중복만 되는 곳은 일부러 안 나눴습니다. 터미널 표시 파생처럼 실제로 도메인 모양이 갈라지는 곳부터 Model로 떼어냈습니다.
number 하나가 부른 버그
문제를 한 줄로 요약하면 이렇습니다. TypeScript는 구조적(structural) 타입 시스템이라, 모양이 같으면 같은 타입으로 봅니다. 다음 두 필드는 이름과 의미가 전혀 다르지만 컴파일러에게는 구분이 없습니다.
readonly totalSoldGold?: number; // 시세 총액 (골드)
readonly listingCount: number; // 매물 개수그래서 다음과 같은 코드가 아무 경고 없이 통과합니다.
// 골드를 표시해야 하는 자리에 개수를 넣었지만 컴파일러는 침묵합니다
formatGold(row.listingPriceSummary.listingCount);
// 등락률(0~100대 숫자)로 총액을 계산해도 타입은 통과합니다
const total = mover.priceChangeRate * quantity;던플마켓의 가격 용어 설계편에서 "매물 총액"과 "시세 총액"을 엄격히 구분했는데, 정작 그 값을 나르는 타입은 둘 다 number였습니다. 도메인에서 그렇게 공들여 나눈 개념이, 코드 레벨에서는 자유롭게 섞일 수 있었던 셈입니다. 골드가 들어갈 자리에 개수가, 개수가 들어갈 자리에 등락률이 들어가도 타입 검사는 초록불이었습니다.
flowchart TB
subgraph before["브랜딩 이전: 전부 number / string"]
G1["totalSoldGold : number"]
C1["listingCount : number"]
R1["priceChangeRate : number"]
I1["itemId : string"]
A1["auctionNo : string"]
end
before --> X["컴파일러: 모양이 같으니 서로 자유롭게 대입 가능<br/>→ 골드 자리에 개수를 넣어도 통과"]이런 버그는 런타임까지 살아남아, 화면에 이상한 숫자로 찍히거나 등락 계산을 조용히 망가뜨립니다. 테스트가 잡아 주기도 하지만, "골드와 개수는 애초에 섞일 수 없다"는 사실을 타입에 새겨 두면 테스트 이전에 컴파일러가 먼저 막습니다. 실수를 앞당겨 잡는 것, 그게 이번 작업의 목표였습니다.
branded type으로 도메인 규칙을 새기다
방법은 의외로 단순합니다. 기존 타입에 실체 없는 이름표 필드를 교차(intersection)로 붙여, 구조적으로는 원래 타입이면서도 서로 구분되게 만듭니다.
// apps/market-web/src/shared/api/market-bff/brand.ts
type Brand<T, Name extends string> = T & { readonly __brand: Name };
/** Integer Gold (the game currency). Frontend Gold is always an integer. */
export type Gold = Brand<number, 'Gold'>;
/**
* The single Gold conversion point: truncates fractional gold to an integer
* (never rounds up), matching how the UI displays it.
*/
export function toGold(value: number): Gold {
return Math.trunc(value) as Gold;
}Gold는 결국 number & { __brand: 'Gold' }입니다. __brand는 실제로 존재하지 않는 팬텀(phantom) 필드라서, 런타임 값은 그냥 숫자입니다. 하지만 컴파일러 눈에는 Gold와 listingCount: number가 이제 다른 타입입니다. 골드 자리에 그냥 number를 대입하려 하면 거부당합니다.
id 두 개도 같은 틀로 감쌌습니다.
/** A catalog item id (the BFF/Neople item key). */
export type ItemId = Brand<string, 'ItemId'>;
export function toItemId(value: string): ItemId {
return value as ItemId;
}
/** An auction listing/sale id (the Neople `auctionNo`). */
export type AuctionId = Brand<string, 'AuctionId'>;
export function toAuctionId(value: string): AuctionId {
return value as AuctionId;
}여기서 브랜딩의 범위를 신중하게 골랐습니다. 진짜 골드인 필드만 Gold로 올리고, 개수·수량·순위·비율·명성(fame)은 number 그대로 뒀습니다. Gold끼리는 더하고 빼도 말이 되지만 Gold + listingCount는 말이 안 되니까요. DTO를 보면 이 구분이 그대로 드러납니다.
export interface TerminalPriceSummary {
readonly lowestListingUnitPrice?: Gold; // 골드 → 브랜딩
readonly highestListingUnitPrice?: Gold; // 골드 → 브랜딩
readonly averageListingUnitPrice?: Gold; // 골드 → 브랜딩
readonly listingCount: number; // 개수 → number 그대로
readonly observedTotalQuantity: number; // 수량 → number 그대로
readonly latestSaleUnitPrice?: Gold; // 골드 → 브랜딩
readonly soldAverageUnitPrice?: Gold; // 골드 → 브랜딩
readonly isTruncated: boolean;
}market-bff DTO 전체에 걸쳐 골드 필드 25개, 아이템 id 필드 8개, 그리고 터미널 매물/판매의 경매 번호가 각각 Gold·ItemId·AuctionId로 올라갔습니다. 중요한 건 이게 타입만 바꾸는 변경이라는 점입니다. 브랜드는 런타임에 지워지므로 빌드 결과물은 한 글자도 바뀌지 않습니다. 커밋 메시지에도 "type-only, build output unchanged"라고 적어 뒀습니다.
toGold의 Math.trunc도 그냥 형변환이 아니라 도메인 규칙입니다. 던플마켓에서 골드는 항상 정수이고, 소수점 이하는 반올림이 아니라 버립니다(골드 포매팅에서 정한 floor 정책과 같습니다). 이 규칙을 함수 한곳에 못박아 두니, 골드를 만드는 모든 경로가 자동으로 같은 규칙을 따릅니다. 브랜딩의 진짜 이득은 "타입이 갈린다"보다 **"변환이 한곳으로 모인다"**에 있습니다.
브랜드는 어디서 발행하는가
브랜드는 아무 데서나 찍으면 의미가 없습니다. as Gold를 코드 곳곳에 흩뿌리면 결국 number와 다를 게 없어집니다. 그래서 발행(mint) 지점을 경계로 못박았습니다. 두 종류뿐입니다.
flowchart TB
trusted["신뢰하는 BFF 응답<br/>(fetch 결과)"] -->|"as <Dto> 캐스트 한 번"| b1["branded DTO"]
raw["raw wire 값<br/>(realtime 프레임 · route param)"] -->|"toGold / toItemId / toAuctionId"| b2["branded 값"]
b1 --> use["이후 앱 전체는 branded 타입으로만 다룸"]
b2 --> use첫째, BFF에서 스키마가 보장되는 응답은 as <Dto> 캐스트가 그 필드들을 한 번에 브랜딩합니다. 둘째, unknown에서 손으로 조립하는 값(실시간 게이트웨이 프레임, 라우트 파라미터)은 반드시 팩토리를 거칩니다. 실시간 패치를 파싱하는 곳이 대표적입니다.
// shared/lib/realtime/realtime-message.ts — raw wire를 브랜딩하는 유일한 생성자
return {
itemId: toItemId(message.itemId),
observedAt: message.observedAt,
freshness: (message.freshness ?? 'NO_DATA') as Freshness,
version: message.version,
listingPriceSummary: {
...(listing.averageUnitPrice !== undefined
? { averageUnitPrice: toGold(listing.averageUnitPrice) }
: {}),
...(listing.lowestUnitPrice !== undefined
? { lowestUnitPrice: toGold(listing.lowestUnitPrice) }
: {}),
listingCount: listing.listingCount ?? 0,
},
// ...
};여기서 흥미로운 지점 하나. Gold는 number에 **대입 가능(assignable)**합니다(교차 타입이니까요). 그래서 formatGold(gold)처럼 number를 받는 함수는 Gold를 그냥 받습니다. 반대로 number를 Gold 자리에 넣는 것만 막힙니다. 이 비대칭 덕분에 ItemId를 도입해도 이걸 string으로 받던 실시간 훅·쿼리 키·찜 목록 스토어가 하나도 안 깨졌습니다. 브랜드는 데이터가 들어오는 경계만 지키고, 소비하는 쪽으로는 파급되지 않습니다. 이 "cascading 없이 경계만 지킨다"가 도입을 현실적으로 만든 핵심이었습니다.
한 가지 대조를 덧붙이면, 프론트의 toItemId는 신뢰 캐스트지만 백엔드 도메인의 같은 이름 함수는 검증까지 합니다.
// libs/item/domain/src/lib/item-id.ts — 외부 입력을 검증하고 브랜딩
export type ItemId = string & { readonly __brand: 'ItemId' };
export function toItemId(value: string): ItemId {
const trimmed = value.trim();
if (trimmed.length === 0) {
throw new InvalidItemIdError(value);
}
return trimmed as ItemId;
}같은 브랜딩 기법이라도 신뢰 수준에 따라 팩토리의 무게가 다릅니다. 프론트는 BFF가 걸러 준 값을 받으니 통과 캐스트로 충분하고, 백엔드 도메인은 외부 문자열을 처음 받는 자리라 빈 문자열을 던져 냅니다. 브랜딩은 "이 값이 어디서 왔는가"를 함수 시그니처로 드러내는 일이기도 합니다.
DTO는 경계, Model은 내부
브랜딩으로 원시값을 조인 다음, 더 큰 경계를 세웠습니다. DTO와 도메인 Model을 분리하는 seam입니다.
문제 상황은 이랬습니다. shared/api(전송 계층)와 entities/item(도메인 계층)에 MarketItem이라는 같은 이름의 타입이 중복으로 있었습니다. 하나는 BFF가 내려주는 wire 모양이고, 하나는 UI가 의존하는 도메인 모양인데, 둘이 우연히 같아서 그냥 한쪽을 다른 쪽에서 재사용하고 있었습니다. 지금은 같아도 미래에 갈라질 수 있는데(예: 게임 내부 itemType을 사용자용 카테고리로 정규화), 그때 손댈 자리가 없었습니다.
그래서 역할을 명확히 갈랐습니다. shared/api는 wire DTO를, entities/item은 도메인 Model과 매퍼를 소유합니다.
// shared/api/market-bff/market-bff.ts — wire DTO (전송 모양)
/** Catalog item as returned by the BFF `/items` endpoint (wire DTO). */
export interface MarketItemDto {
readonly itemId: ItemId;
readonly name: string;
readonly rarity?: string;
readonly itemType?: string;
readonly itemTypeDetail?: string;
readonly imageUrl?: string;
}// entities/item/model/item.types.ts — 도메인 Model + 단일 seam
export interface MarketItem {
readonly itemId: ItemId;
readonly name: string;
readonly rarity?: string;
readonly itemType?: string;
readonly itemTypeDetail?: string;
readonly imageUrl?: string;
}
/**
* Map the wire DTO to the domain model. A structural pass-through today; this is
* the single seam where future normalization will live (e.g. mapping a
* game-internal itemType to a user-facing category) without touching consumers.
*/
export function toMarketItem(dto: MarketItemDto): MarketItem {
return { ...dto };
}toMarketItem은 오늘 기준 그냥 펼쳐 복사하는 항등 매퍼입니다. 하지만 이 함수의 존재 자체가 "여기가 wire와 도메인이 만나는 유일한 지점"이라는 선언입니다. 나중에 카테고리 정규화가 필요해지면, 소비자 코드를 뒤지지 않고 이 함수 한곳만 고치면 됩니다. 데이터를 가져오는 클라이언트는 경계에서 매핑합니다.
// features/item-search/api/items.api.ts — 경계에서 DTO를 Model로
export async function searchItems(query: string): Promise<MarketItem[]> {
const trimmed = query.trim();
if (trimmed.length === 0) {
return [];
}
const { data } = await http.get<MarketItemDto[]>('/items', {
params: { query: trimmed },
});
return data.map(toMarketItem); // ← seam을 통과시킨다
}전체 흐름을 그리면 이렇게 됩니다. wire JSON이 경계에서 브랜딩되며 DTO가 되고, 매퍼를 통과해 Model이 되며, 그 Model만 feature와 widget으로 흘러갑니다.
flowchart LR
wire["BFF JSON 응답<br/>(raw wire)"] --> mint
subgraph shared["shared/api · 데이터 접근 경계"]
mint["fetch + as MarketItemDto 캐스트<br/>(id·gold 필드 브랜딩)"]
dto["MarketItemDto"]
mint --> dto
end
dto --> mapper
subgraph entity["entities/item · 도메인"]
mapper["toMarketItem (seam)"]
model["MarketItem (Model)"]
mapper --> model
end
model --> feat["features · widgets · UI"]이 정리를 하면서 죽은 코드도 걷어냈습니다. shared/api에는 서버용 searchItems/getItem이 있었는데, 실제 검색은 axios 클라이언트가 담당하고 있어서 이 서버 함수들은 자기 테스트만 돌리는 유령이었습니다. 이들을 지우니 테스트 5개가 함께 사라졌지만, 살아 있는 커버리지는 하나도 잃지 않았습니다. seam을 세우는 김에 "wire를 도메인으로 흘리는 경로"를 하나로 통일한 것입니다. FSD 계층에서 shared는 entities를 import할 수 없으므로, Model과 매퍼가 소비하는 entity 쪽에 사는 것도 계층 규칙과 자연스럽게 맞아떨어졌습니다.
터미널 표시 파생을 domain model로
item 패밀리의 seam이 항등 매퍼였다면, 터미널에는 진짜로 모양이 갈라지는 seam이 있었습니다. 터미널 위젯 안에 도메인 계산이 인라인으로 박혀 있었던 것입니다. 분류 문자열을 조립하고, 차트에서 등락을 계산하고, "준비 중" 상태를 판정하는 로직이 렌더 코드와 뒤엉켜 있었습니다.
이 파생들을 entities/market-price/model로 순수 함수로 뽑아냈습니다. 이게 터미널 패밀리의 실제(비항등) DTO→Model seam입니다. 위젯은 이제 도메인 사실을 계산하지 않고 이미 준비된 사실을 렌더만 합니다.
// entities/market-price/model/terminal-view-model.ts
import type { Gold, TerminalSnapshot } from '@/shared/api/market-bff';
/** "<itemType> · <itemTypeDetail>" 분류 문자열; 둘 다 없으면 빈 문자열. */
export function terminalClassification(item: {
readonly itemType?: string;
readonly itemTypeDetail?: string;
}): string {
return [item.itemType, item.itemTypeDetail]
.filter((part): part is string => Boolean(part))
.join(' · ');
}
/** 첫 수집 이전인지("준비 중" 상태) 판정. */
export function isTerminalPreparing(snapshot: TerminalSnapshot): boolean {
return !snapshot.freshness.hasCompletedCollection;
}
/**
* 차트 구간의 평균 매물가 등락(첫 → 마지막 값 기준)과 방향.
* 값 있는 구간이 둘 미만이거나 기준이 0이면 null.
*/
export function terminalPriceChange(
snapshot: TerminalSnapshot
): { readonly percent: number; readonly up: boolean } | null {
const valued = snapshot.chart.points
.map((point) => point.listingAverageUnitPrice ?? point.listingMinUnitPrice)
.filter((value): value is Gold => value !== undefined);
const first = valued[0];
const last = valued[valued.length - 1];
if (first === undefined || last === undefined || first === 0) {
return null;
}
const percent = ((last - first) / first) * 100;
return { percent, up: percent >= 0 };
}terminalPriceChange의 filter((value): value is Gold => …)를 보면, 브랜딩과 seam이 어떻게 맞물리는지 드러납니다. 차트 포인트의 골드 필드가 Gold로 브랜딩돼 있으니, 파생 함수도 number가 아니라 Gold로 좁힙니다. 원시값 브랜딩이 도메인 파생까지 타고 흐르는 것입니다.
위젯 쪽은 이렇게 홀쭉해졌습니다.
// widgets/terminal-view/ui/terminal-view.tsx — 계산 대신 파생 호출만
const change = terminalPriceChange(snapshot);
const classification = terminalClassification(item);
const preparing = isTerminalPreparing(snapshot);flowchart LR
snap["TerminalSnapshot DTO"] --> deriv
subgraph mp["entities/market-price/model · 도메인"]
deriv["terminalClassification<br/>terminalPriceChange<br/>isTerminalPreparing"]
end
deriv --> facts["준비된 도메인 사실<br/>분류 문자열 · 등락(percent·up) · 준비중 여부"]
facts --> widget["terminal-view 위젯<br/>(계산 없이 렌더만)"]부수 효과로 테스트 가능성이 올라갔습니다. 위젯 안에 있던 로직은 컴포넌트를 렌더해야만 검증할 수 있었는데, 순수 함수로 나오니 단위 테스트 9개를 곧장 붙였습니다. 분류 문자열이 한쪽 필드만 있을 때, 등락이 하락일 때, 값 있는 구간이 하나뿐일 때 같은 경계를 각각 못박았습니다. 도메인 규칙을 UI에서 떼어내면, 규칙은 규칙대로 검증하고 UI는 UI대로 가벼워집니다.
브랜딩의 비용과 이득
솔직하게 비용부터 적겠습니다.
- 발행 지점을 지켜야 합니다. 브랜드는 경계에서만 찍어야 의미가 있습니다.
as Gold를 아무 데나 쓰면 무너집니다. 그래서 팩토리(toGold등)와as <Dto>캐스트로 발행구를 좁혀 뒀습니다. - 항등 매퍼는 순수 중복입니다.
toMarketItem처럼 지금은 그냥 복사만 하는 매퍼를 모든 타입 패밀리에 만들면, 유지할 코드만 늘고 얻는 건 없습니다.
그래서 의도적으로 안 나눈 부분이 있습니다. 대시보드·터미널 BFF 타입들은 브랜딩까지만 하고 *Dto/*Model 분리는 하지 않았습니다. 지금 이들의 매퍼는 항등일 테니까요. 규칙은 하나입니다. "wire 모양과 도메인 모양이 실제로 갈라지는 순간"에만 seam을 판다. item 패밀리는 미래 정규화 자리를 남기려고 참조 seam으로 먼저 세웠고, 터미널 파생은 이미 인라인 계산이라는 형태로 모양이 갈라져 있어서 Model로 떼어냈습니다. 나머지는 그날이 오기 전까지 branded DTO로 충분합니다.
이득은 이렇게 정리됩니다.
| 항목 | 브랜딩 이전 | 브랜딩 이후 |
|---|---|---|
| 골드 자리에 개수 대입 | 통과(런타임 버그) | 컴파일 에러 |
| 골드 변환 규칙(floor) | 산발적으로 반복 | toGold 한곳 |
| wire↔도메인 경계 | 타입 중복, 암묵적 | *Dto/*Model + 매퍼로 명시 |
| 도메인 파생 위치 | 위젯에 인라인 | 순수 함수, 단위 테스트 |
| 소비자 코드 영향 | — | 없음(브랜드 런타임 소거) |
| 런타임/번들 | — | 변화 없음 |
핵심은 브랜딩이 거창한 안전망이 아니라 값싼 규칙 새기기라는 점입니다. number 하나에 이름표를 붙이는 것만으로 "이 둘은 섞일 수 없다"는 도메인 규칙이 타입에 각인되고, 그 규칙을 어기는 코드는 실행되기 전에 걸립니다. 런타임 비용은 0이고, 도입 비용은 발행구를 지키는 규율뿐입니다. 지난 편의 FSD가 폴더로 "무엇이 무엇을 알아도 되는가"를 정했다면, 브랜딩과 seam은 타입으로 "무엇이 무엇으로 변할 수 있는가"를 정한 셈입니다.
정리하면
이 편의 전제는 하나였습니다. number끼리는 서로 섞여도 컴파일러가 못 막는다.
Brand<T, Name>으로Gold·ItemId·AuctionId에 이름표를 붙여, 구조적 타입 위에 명목 구분을 얹었습니다. 브랜드는 런타임에 지워지므로 공짜입니다.- 브랜드는 경계에서만 발행합니다. 신뢰 응답은
as <Dto>, raw wire는toGold·toItemId팩토리 한곳으로.toGold의 floor는 도메인 규칙을 한 지점에 못박습니다. - DTO와 Model을 가르는 seam을 세웠습니다.
item은 참조 seam(항등 매퍼)으로, 터미널 파생은 실제 seam(순수 도메인 함수)으로. - 매핑이 중복만 되는 곳은 일부러 안 나눴습니다. 모양이 갈라질 때 seam을 판다는 규칙을 지켰습니다.
(2)편에서 "모르는 걸 모른다고 말하는 시스템"을 이야기했는데, 타입도 마찬가지입니다. "이 숫자가 골드인지 개수인지 모른다"를 number 하나로 뭉개지 않고, 타입에 또렷이 적어 두는 편이 길게 봤을 때 실수를 줄입니다.
다음 편에서는 다시 백엔드로 넘어가, 스스로 백엔드 아키텍처를 감사한 이야기를 다루겠습니다. "다 갈아엎자"가 아니라 무엇을 지금 안 건드려도 되는지를 판정하고, HIGH로 분류한 부채 몇 건에 우선순위를 매긴 기록입니다.
