0. 숫자를 화면에 어떻게 보여줄까?

통합 대시보드 파트를 맡았을 때는 어떻게 대시보드에 지표를 보여줘야 하는지, 또 리팩토링할 때는 따로 쓴 코드를 어떻게 통합해야 하는지 막연했다. 하지만 천천히 뜯어보니 코드 전체가 푸는 단 하나의 문제는 "숫자를 화면에 어떻게 보여줄까?" 이다.
위 그림이 그 해답의 전체 흐름이다! 백엔드가 준 숫자 하나가 바인딩을 지나 레지스트리에서 옷을 갈아입고 화면에 닿기까지, 내가 헤맸던 순서 그대로 다섯 단계로 정리해보겠다.
1. 레지스트리 - 흩어진 분기를 객체에 모으기
1234라는 숫자가 있다고 하자. 이걸 그냥 화면에 그대로 뿌리면 안 되고, 무슨 지표냐에 따라 다르게 보여줘야 한다.
- 클릭수면 → 1,234
- 비용이면 → ₩1,234
- 전환율이면 → 1.23%
이걸 가장 단순하게 구현한다면 컴포넌트 안에서 if로 분기하면 된다.
function formatValue(metric, value) {
if (metric === "clicks") return value.toLocaleString(); // "1,234"
if (metric === "spend") return `₩${value.toLocaleString()}`; // "₩1,234"
if (metric === "conversion") return `${value.toFixed(2)}%`; // "1.23%"
// ...
}
돌아가긴 하지만 문제는 이 if 덩어리가 카드 컴포넌트, 테이블, 차트 등에 똑같이 반복된다는 것이다.
클릭수 표시를 바꾸려면 세 군데를 고쳐야 하고, 한 군데라도 빠뜨리면 화면마다 다르게 보인다.
그래서 지표별 규칙을 하나에 모으기로 했다. → 레지스트리
거창해 보이지만 레지스트리는 그냥 객체(딕셔너리)이다.
const METRIC_REGISTRY = {
clicks: { label: "클릭수", format: (v) => v.toLocaleString() },
spend: { label: "비용", format: (v) => `₩${v.toLocaleString()}` },
conversion: { label: "전환율", format: (v) => `${v.toFixed(2)}%` },
};

이제 어느 화면에서든 이렇게 꺼내 쓸 수 있다.
METRIC_REGISTRY.clicks.format(1234); // "1,234"
METRIC_REGISTRY.spend.format(1234); // "₩1,234"
if 덩어리가 사라지고, 클릭수 표시를 바꾸려면 clicks 줄 하나만 고치면 끝이다.
레지스트리 패턴의 핵심은 흩어진 분기를 객체로 모은다는 것이다!
2. 값 타입별 포맷 기준 통일 - 같은 종류면 같은 규칙

레지스트리에 규칙을 모으고 나니 다음 고민이 찾아왔다. 팀 프로젝트이다 보니 대시보드도 다른 팀원이 구현하여
카드에선 소수 2자리, 좁은 테이블에선 소수 1자리, 테이블 합계 행에서는 반올림 등과 같은 위치별 포맷이 달랐다. 이걸 하나씩 따라가다 보면 포맷 함수가 화면 위치 별로 달라지고 기껏 정리한 레지스트리가 다시 복잡해졌다.
그래서 기준의 축을 아예 바꾸기로 했다. 포맷을 표시 위치가 아니라 값의 타입에 묶는 것이다.
지표가 많아도 값은 결국 네 종류 중 하나로 떨어진다!
- 정수형 카운트 → 천 단위 콤마 (1,234)
- 비율 / 퍼센트 → toFixed(2)% (12.34%)
- 금액 (KRW) → ₩ + 반올림 + 콤마 (₩1,234)
- 증감률 → toFixed(2)% + 절댓값
이렇게 묶으니 같은 타입을 위치를 가리지 않고 똑같이 보일 수 있었다. 비율인 CVR·ROAS·CTR은 카드든 테이블이든 전부 toFixed(2)%다. 금액 지표는 아예 포맷 객체 하나를 만들어 스프레드로 공유한다.
const currencyFormat = { format: formatCurrency } satisfies Pick<IMetricMeta, "format">;
spend: { label: "비용(지출)", ...currencyFormat } satisfies IMetricMeta,
revenue: { label: "매출", ...currencyFormat } satisfies IMetricMeta,
이렇게 하여 "같은 종류의 값은 어디서나 똑같이 보인다"는 일관성을 얻을 수 있었다. 새 지표를 추가할 때도 "이건 어느 타입이지?"만 정하면 포맷이 따라온다.
3. 객체에 타입을 붙이자: satisfies + as const
객체에 타입을 붙이는 방법은 세 가지가 있다. 작은 레지스트리 항목 하나를 예로 들어보자.
interface IMetricMeta {
lable: string;
format: (v:number) => string;
}
방법①. 아무것도 안 붙이기
const clicks = {
label: "클릭수",
format: (v) => v.toLocaleString(),
};
이러면 Typescript가 타입을 알아서 추론한다. clicks.label의 타입은 string이 아니라 "클릭수"구체적인 값(리터럴)으로 잡힌다.
이 경우의 문제는 검사를 해주지 않는다는 것이다. format을 빠뜨려도, 오타로 내도 검사를 하지 않는다.
방법②. 타입 명시하기(: IMetricMeta)
const clicks: IMetricMeta = {
label: "클릭수",
format: (v) => v.toLocaleString(),
};
타입을 명시하면 검사를 받게 된다. 하지만 여기에는 대가가 있다. clicks.label의 타입이 "클릭수"가 아니라 그냥 string으로 넓어진다.
즉 "이 변수는 IMetricMeta다"라고 선언하는 순간, TypeScript는 그 변수를 인터페이스의 눈으로만 보기 때문에 "클릭수"라는 구체적인 정보는 사라진다.
방법③. satisfies 붙이기
const clicks = {
label: "클릭수",
format: (v) => v.toLocaleString(),
} satisfies IMetricMeta;
satisfies는 이 객체가 IMetricMeta를 만족하는지 검사만 해주는데 타입을 인터페이스로 바꾸지는 말라는 의미이다.
- format을 빠뜨리면 → 에러가 나고 (검사 O)
- clicks.label의 타입은 → "클릭수" 그대로 남는다. (추론 보존 O)
정리하면

| 방법 | 검사(오타·누락) | 추론(구체 타입) |
| ① 아무것도 안 붙임 | ✗ | O |
| ② IMetricMeta | O | ✗ |
| ③ satisfies | O | O |
satisfies만 둘 다 잡는다. 그래서 레지스트리에서 이게 중요하다!!
레지스트리는 "키 목록"이 정확해야 쓸모가 있는데, 객체 끝에 as const를 붙이고 각 항목에 satisfies를 붙이면 키가 정확한 문자열 유니온으로 뽑힌다.
type TMetricKey = keyof typeof METRIC_REGISTRY;
// "clicks" | "impressions" | "conversion" | "roas" | ... 정확히 이 목록
만약 키가 그냥 string으로 뭉개졌다면 getKpiMetric("clikcs") 같은 오타도 못 잡는다. 정리하면 역할이 깔끔하게 나뉜다.
- satisfies → 각 항목이 인터페이스를 제대로 만족하는지 검사 (빠진 함수, 오타 잡기)
- as const → 객체 전체를 리터럴로 단단히 고정 (키·label 값을 정확한 타입으로 끝까지 끌고 가기)
4. 인터페이스 상속 계층 - 능력을 한 칸씩 쌓기
satisfies로 검사를 걸려고 보니 또 다른 고민이 생겼다. 지표마다 필요한 능력이 다르다는 것이다.
증감률(formatDelta)이 필요한 지표도 있고, 카드 제목용 kpiLabel이 필요한 지표도 있고, 차트 툴팁 단위가 필요한 지표도 있다.
이걸 인터페이스 하나에 다 욱여넣으면 어떻게 될까?
큰 인터페이스 하나에 다 때려넣기
interface IMetricMeta {
label: string;
format: (v: number) => string;
formatDelta?: (v: number) => string; // 어떤 지표만
kpiLabel?: string; // conversion만
chartTooltipUnit?: string; // clicks만
// ...
}
이렇게 인터페이스가 전부 넣는 경우에는 지표마다 필요한 게 다르기 때문에 대부분 ?(optional)이 된다.
그리고 optional은 두 가지 문제가 생긴다!
- 있는지 없는지를 매번 확인해야 한다.
- 빠뜨려도 잡히지 않는다. - conversion 카드에서 kpiLabel이 필요한데, optional이니까 빠뜨려도 TypeScript는 가만히 있고, 런타임에 undefined가 뜨는 문제가 발생
해법 - 능력을 한 단계씩 쌓기
그래서 모든 지표가 가진 능력을 바닥에 두고, 필요한 능력만 한 칸씩 더해 올라가는 구조로 쪼개서 만들었다.

코드로 확인하면 extends로 한 칸씩 쌓이는 구조이다.
interface IMetricMeta { // 바닥: 모두 공통
label: string;
format: (v: number) => string;
}
interface IMetricMetaWithDelta extends IMetricMeta { // + 증감률
formatDelta: (v: number) => string;
}
interface IKpiMetricMeta extends IMetricMetaWithDelta { // + 카드 제목
kpiLabel: string;
}
interface IConversionMetricMeta extends IKpiMetricMeta { // + 좁은 영역용
formatCompact: (v: number) => string;
}
결국 IConversionMetricMeta는 결국 label · format · formatDelta · kpiLabel · formatCompact 다섯 개를 전부 갖게 된다.
그리고 여기서 satisfies가 중요하다. 각 지표 객체에 자기에게 맞는 인터페이스를 붙이만 하면 된다.
conversion: {
label: "CVR(전환율)",
kpiLabel: "전환율",
format: (v) => `${v.toFixed(2)}%`,
formatCompact: (v) => `${v.toFixed(1)}%`,
formatDelta: formatPercentDelta,
} satisfies IConversionMetricMeta, // 하나라도 빠지면 → 컴파일 에러
spend: {
label: "비용(지출)",
format: formatCurrency,
} satisfies IMetricMeta, // label · format만 있으면 됨
이러면 아까 optional의 두가지 문제가 모두 사라진다.
- conversion에서 kpiLabel을 빠뜨리면 이제 컴파일 단계에서 빨간 줄이 뜬다 (optional이 아니라 필수니까).
- 반대로 spend는 애초에 IMetricMeta만 만족하면 되니, 쓸데없는 formatDelta? 같은 게 안 붙는다.
필요한 능력만, 빠짐없이를 타입으로 강제하는 것이다.
5. API 바인딩 - 백엔드와 잇는 방법
이제 백엔드 응답을 레지스트리에 연결할 차례이다. 백엔드가 주는 필드명과 우리가 만든 레지스트리 키가 다르다.
- 백엔드 응답: ROAS, ROASChangeRate, cvrChangeRate, clickChangeRate…
- 레지스트리 키: roas, conversion, clicks…
만약 컴포넌트가 응답을 직접 받아서 레지스트리를 찾으면, 백엔드 네이밍(ROASChangeRate 같은)이 화면 코드 곳곳에 새어 들어온다. 그러다 백엔드가 필드명 하나 바꾸면 컴포넌트를 다 뒤져야 한다. 그래서 둘 사이에 "다리"를 하나 놓는 것이다.

그 다리가 바로 바인딩 객체이다. 하나가 세 가지를 들고 있다.
interface IOverviewKpiBinding {
registryKey: TKpiMetricKey; // 어느 레지스트리 항목에서 포맷·label을 가져올지
valueField: TMetricApiField; // 응답에서 "값"을 꺼낼 필드명
deltaField: TMetricApiField; // 응답에서 "증감률"을 꺼낼 필드명
}
export const OVERVIEW_KPI_BINDINGS: readonly IOverviewKpiBinding[] = [
{ registryKey: "roas", valueField: "ROAS", deltaField: "ROASChangeRate" },
{ registryKey: "conversion", valueField: "conversion", deltaField: "cvrChangeRate" },
// ...
];
변환 함수(metricsToKpis)는 이 배열을 한 줄씩 순회하면서, valueField로 응답에서 값을 꺼내고,
registryKey로 레지스트리에서 포맷을 가져다 쓴다. 덕분에 두 가지 이득이 생긴다.
- 백엔드 필드명이 바뀌어도 바인딩 한 줄만 고치면 된다 - 컴포넌트는 roas라는 레지스트리 키만 알면 된다.
- TMetricApiField가 사실 keyof IMetricsResponse -즉 "응답 타입에 실제로 있는 필드명"의 목록이기 때문에 valueField에 응답에 없는 필드명을 오타로 적으면 타입 에러가 난다.
예시: 백엔드가 이런 JSON을 줬을 때
{
"ROAS": 250.5,
"ROASChangeRate": -12.3,
"clicks": 1234,
"clickChangeRate": 5.6
}
내가 화면 KPI 카드에 보여주고 싶은 건 이것이다!

그런데 여기서 두가지가 안 맞는다.
- ROAS(대문자)라고 부르는데 내 레지스트리 키는 roas(소문자)다.
- 백엔드는 250.5라는 날것의 숫자를 줬는데 화면엔 250.50%라는 포맷된 문자열이 필요하다.
이 둘을 이어줄 무언가가 필요한데, 그게 바인딩이다.
{ registryKey: "roas", valueField: "ROAS", deltaField: "ROASChangeRate" }
이 한 줄은 이렇게 읽으면 된다. 즉 바인딩은 "백엔드 필드명 → 레지스트리 키"를 적어둔 번역 메모다.
- valueField: "ROAS" → "값은 응답의 ROAS 칸에서 꺼내라"
- deltaField: "ROASChangeRate" → "증감률은 응답의 ROASChangeRate 칸에서 꺼내라"
- registryKey: "roas" → "꺼낸 값은 레지스트리의 roas 규칙으로 포맷해라"
변환 함수가 이 메모로 하는 일
metricsToKpis는 바인딩 배열을 한 줄씩 돌면서, 메모대로 값을 꺼내고 포맷을 입힌다.
function metricsToKpis(response: IMetricsResponse) {
return OVERVIEW_KPI_BINDINGS.map((binding) => {
const meta = METRIC_REGISTRY[binding.registryKey]; // roas의 포맷·레이블
const rawValue = response[binding.valueField]; // response["ROAS"] → 250.5
const rawDelta = response[binding.deltaField]; // response["ROASChangeRate"] → -12.3
return {
title: meta.label, // "ROAS"
value: meta.format(rawValue), // format(250.5) → "250.50%"
delta: meta.formatDelta(rawDelta), // formatDelta(-12.3) → "12.30%"
};
});
}
roas 바인딩 한 줄이 처리되는 과정을 값으로 따라가면 이렇게 된다.

마무리 - 결국 하나의 흐름이다.
막연했던 코드를 다섯 단계로 줄이면 이렇게 정리된다.
- 레지스트리 — 흩어진 if 분기를 객체 하나에 모은다. 컴포넌트는 거기서 포맷을 꺼내 쓴다.
- 값 타입별 통일 — 포맷 기준을 표시 위치가 아니라 값의 타입(카운트·비율·금액·증감률)에 묶어, 같은 종류는 어디서나 똑같이 보이게 한다.
- satisfies + as const — 객체에 오타·누락이 없는지 검사는 받되, 키와 값의 구체적인 타입은 잃지 않는다.
- 인터페이스 상속 계층 — 공통 토대 위에 지표별 능력을 한 칸씩 쌓아, "필요한 능력만 빠짐없이"를 타입으로 강제한다.
- API 바인딩 — 백엔드 필드명과 레지스트리 키를 잇는 다리. 백엔드 사정을 컴포넌트에서 격리하고, 필드명 오타를 타입으로 막는다.
결국 규칙을 객체에 모으고(1·2), 그 객체를 TypeScript로 단단하게 만들고(3·4), 바깥 데이터와 안전하게 잇는다(5) 이 한 흐름이다.
처음에는 도대체 지표를 어떻게 정리하고 화면에 보여줘야 할까 막막했지만 이 흐름을 따라가기 어느정도 이해할 수 있고 정리된 코드가 된 것 같다.