4. 값과 자료구조
값을 다루는 관용구를 한 가지로 고정합니다. 넘겨받은 배열은 제자리에서 바꾸지 않고, 반복되는 조회는 Set과 Map으로 모읍니다. 객체의 값은 구조분해하지 않고 체인으로 읽어 출처를 남깁니다. 한 곳에서 쓸 값은 조회표로 우회하지 않고 사용처에서 직접 고릅니다. 뜻이 있는 숫자는 쓰는 자리에 적지 않고 상수로 선언합니다. 값 보조 함수는 직접 만들기 전에 es-toolkit에서 찾습니다. 날짜는 dayjs로 다룹니다. 같은 판정은 경계에서 한 번만 하고 결과를 데이터로 전달합니다.
4.1 원본을 바꾸지 않고 배열을 정렬합니다
프롭스, 상태, 모듈 상수에서 온 배열을 정렬할 때 원본이 바뀌는 버그를 피합니다.
정렬은 새 배열을 반환하는 es-toolkit 함수로 하고, 원본을 바꾸는 .sort()는 쓰지 않습니다.
프롭스, 상태, 매개변수, 모듈 상수로 받은 배열도 같습니다.
flowchart LR
q1{"비교 규칙을 정렬 키로<br>적을 수 있는가?"} -- 예 --> q2{"모든 키가<br>오름차순인가?"} -- 예 --> r2("sortBy")
q1 -- 아니요 --> r1("런타임이 지원할 때만<br>.toSorted")
q2 -- 아니요 --> r3("orderBy")같은 키의 항목도 입력 순서와 무관하게 정렬해야 하면 고유 식별자를 마지막 정렬 키로 더합니다.
입력을 수정하지 않는 정렬 함수는 readonly 배열을 매개변수로 받습니다.
매개변수로 받은 배열을 제자리에서 바꿉니다.
const toSortedUsers = (users: User[]): User[] => {
return users.sort((left, right) => left.name.localeCompare(right.name));
};
키 기준 정렬은 sortBy를 씁니다.
import {sortBy} from "es-toolkit";
const toSortedUsers = (users: readonly User[]): User[] => {
return sortBy(users, ["name"]);
};
방향이 섞이면 orderBy를 씁니다.
import {orderBy} from "es-toolkit";
const toSortedProducts = (products: readonly Product[]): Product[] => {
return orderBy(products, ["categoryId", "price"], ["asc", "desc"]);
};
비교 규칙을 키로 적을 수 없으면 .toSorted()를 씁니다.
const toSortedUsers = (users: readonly User[]): User[] => {
return users.toSorted((left, right) => left.name.localeCompare(right.name));
};
함께 보기
4.2 같은 조회를 반복하면 Set이나 Map으로 정리합니다
반복 조회 구조를 한 번 만들어 목록 전체를 되풀이해 비교하는 비용을 줄입니다.
반복 조회 바꾸기
같은 목록을 반복 조회하면 루프 밖에서 Set이나 Map을 한 번 만들고, 원본 목록이 바뀔 때 갱신합니다.
| 상황 | 처리 |
|---|---|
같은 목록을 루프나 map, filter, some 콜백 안에서 조회 | 포함 여부는 Set.has, 항목 조회는 Map.get으로 바꿉니다 |
| 같은 목록을 서로 다른 세 지점 이상에서 조회 | 한 번 만든 Set, Map을 공유합니다 |
| 위 조건에 해당하지 않거나 길이가 정해진 짧은 목록 | 기존 조회를 유지합니다 |
| 중복 제거, 차집합처럼 결과 목록을 만듦 | uniq, difference, without을 씁니다. 만든 뒤 has를 반복 호출할 때만 Set을 남깁니다 |
Map으로 바꾸기 전 확인
Map으로 바꾸기 전 확인 | 이유와 처리 |
|---|---|
keyBy의 객체를 조회용으로 쓰는지 | 프로토타입의 constructor, toString 키에 걸릴 수 있어 Map을 씁니다 |
| 없는 키를 타입이 드러내는지 | noUncheckedIndexedAccess가 꺼진 Record<string, T>와 달리 Map.get()은 항상 T | undefined입니다 |
| 키가 중복되는지 | find는 첫 항목, new Map(entries)는 마지막 항목을 남깁니다. 첫 항목을 유지하려면 uniqBy를 먼저 적용합니다 |
groupBy, keyBy는 목록을 재구성할 때 씁니다.
목록 연산의 선택은 규칙 4.6가 정합니다.
같은 배열을 반복 순회하며 포함 여부를 확인합니다.
const visibleProducts = products.filter((product) => allowedProductIds.includes(product.id));
const disabledProducts = archivedProducts.filter((product) => allowedProductIds.includes(product.id));
반복 조회는 Set으로 처리합니다.
const allowedProductIdSet = new Set(allowedProductIds);
const visibleProducts = products.filter((product) => allowedProductIdSet.has(product.id));
const disabledProducts = archivedProducts.filter((product) => allowedProductIdSet.has(product.id));
반복 키 조회는 Map으로 처리합니다.
// users는 서버 계약상 id가 고유
const userById = new Map(users.map((user) => [user.id, user]));
const owner = userById.get(ownerId);
const reviewer = userById.get(reviewerId);
const approver = userById.get(approverId);
길이가 정해진 짧은 목록은 includes를 그대로 씁니다.
const isEditableStatus = editable_order_statuses.includes(order.status);
4.3 객체는 구조분해하지 않고 체인으로 읽습니다
값이 어느 객체에서 왔는지가 쓰는 자리마다 남아 이름만 보고 출처를 되짚지 않습니다.
객체 필드는 구조분해나 별칭 없이 product.name처럼 체인으로 읽습니다.
쓰는 자리마다 값의 출처가 남아야 합니다.
| 형태, 상황 | 처리 |
|---|---|
| 객체 구조분해 | 체인으로 읽습니다. 이름을 바꿔 꺼내는 {status: orderStatus}도 같습니다 |
같은 필드에 이름만 붙인 지역 const | 제거합니다. 필드를 그대로 읽는 것은 계산이 아니며, 계산 결과의 이름은 규칙 3.8가 정합니다 |
| 짧은 함수, 좁은 스코프 | 예외를 두지 않습니다 |
| 배열, 튜플 구조분해 | 유지합니다. useState와 Object.entries처럼 위치로 꺼내는 값에는 지워질 필드 이름이 없습니다 |
| 외부 러너가 콜백 매개변수로 fixture 의존성을 해석 | 그 매개변수만 구조분해를 유지합니다. 일반 객체와 콜백 본문까지 예외를 넓히지 않습니다 |
| 깊어서 읽기 어려운 체인 | 값을 꺼내는 곳에서 별칭으로 끊지 않고, 그 형태를 만드는 곳을 검토합니다 |
이름을 바꿔 꺼내 출처와 원래 이름이 함께 사라집니다.
const {status: orderStatus, ownerId: orderOwnerId} = order;
if (orderStatus === "cancelled") {
notify(orderOwnerId);
}
체인으로 읽어 출처가 쓰는 자리마다 남습니다.
const toOrderLine = (input: OrderLineInput): OrderLine => {
return {
id: input.product.id,
label: input.product.name,
amount: input.product.price * input.quantity,
};
};
const toOrderTotal = (lines: OrderLine[]): OrderTotal => {
return {
currency: pricing_default_currency,
amount: sumBy(lines, (line) => line.amount),
};
};
if (order.status === "cancelled") {
notify(order.ownerId);
}
시그니처와 본문에서 구조분해해 출처가 사라집니다.
const toOrderLine = ({product, quantity}: OrderLineInput): OrderLine => {
const {name, price} = product;
return {
label: name,
amount: price * quantity,
};
};
배열과 튜플은 자리로 풀어도 됩니다.
const [keyword, setKeyword] = useState("");
for (const [key, value] of Object.entries(target.searchParams)) {
requestUrl.searchParams.set(key, value);
}
별칭 const로 끊어 이름만 남깁니다.
const currency = pricing_default_currency;
const toOrderTotal = (lines: OrderLine[]): OrderTotal => {
return {
currency,
amount: sumBy(lines, (line) => line.amount),
};
};
필드 읽기가 아니라 계산한 결과라 이름을 붙입니다.
const toOverdueLines = (order: Order): OrderLine[] => {
// 콜백 안으로 옮기면 줄마다 다시 생성
const overdueIds = new Set(order.overdueLineIds);
return order.lines.filter((line) => overdueIds.has(line.id));
};
외부 러너의 fixture 의존성 해석에 필요한 매개변수만 구조분해합니다.
import { expect, test } from "@playwright/test";
// Playwright의 매개변수 이름에 따른 fixture 의존성 해석 계약
test("제목이 표시된다", async ({ page }) => {
/**
* GIVEN
* 주문 목록의 진입 경로
*/
const path = "/orders";
/**
* WHEN
* fixture가 제공한 page로 진입
*/
await page.goto(path);
/**
* THEN
* 주문 목록 제목
*/
await expect(page.getByRole("heading", { name: "주문 목록" })).toBeVisible();
});
함께 보기
4.4 의미 있는 숫자는 사용처에 적지 않고 상수로 선언합니다
숫자의 의미를 이름으로 드러내고 한곳에서 변경할 수 있습니다.
제품 정책처럼 뜻이 있는 숫자는 상수로 선언하고, 사용처에서는 그 이름을 참조합니다.
같은 파일의 지역 const로 옮기기만 해서는 규칙을 충족하지 못합니다.
| 숫자의 용도 | 처리 |
|---|---|
재시도 횟수 3, 페이지 크기 20 등 제품 정책 | 작거나 흔한 숫자여도 상수로 선언합니다 |
일반 연산의 0, 1, 단위 변환의 60 | 그대로 적습니다 |
| 배열 인덱스, 선언 초기값, 상수 선언 자신의 값 | 그대로 적습니다 |
| 테스트의 표본값, 경계값, 기대값 | 직접 적습니다. 기대값을 제품 정책 상수에서 가져와 함께 바뀌게 하지 않습니다 |
??, || 오른쪽이나 기본 매개변수 | 규칙 5.1를 따릅니다 |
| 여러 숫자가 한 뜻을 이룸 | 배열 대신 {first: 0x1100, last: 0x115f}처럼 이름 있는 객체 필드로 둡니다 |
소유자를 지워도 남으면 루트 constant, 함께 사라지면 소유자의 _constant에 둡니다.
배치는 규칙 2.1가 정합니다.
뜻이 있는 숫자를 쓰는 자리에 적거나 지역 const로 자리만 옮깁니다.
const maxAttempts = 42;
const isOverRetryLimit = (attempts: number): boolean => {
return attempts > maxAttempts;
};
const toPreviewRows = (rows: Row[]): Row[] => {
return rows.slice(0, 37);
};constant 폴더에 선언하고 쓰는 자리에서 이름을 가리킵니다.
/**
* 이 횟수를 넘으면 사용자에게 실패 표시
*/
export const retry_max_attempts = 42;/**
* 미리보기에 그릴 행 수. 서버가 한 번에 주는 최대치와 같음
*/
export const preview_row_count = 37;import {preview_row_count} from "@/constant/preview";
import {retry_max_attempts} from "@/constant/retry";
const isOverRetryLimit = (attempts: number): boolean => {
return attempts > retry_max_attempts;
};
const toPreviewRows = (rows: Row[]): Row[] => {
return rows.slice(0, preview_row_count);
};뜻이 없는 숫자에까지 이름을 붙입니다.
export const table_first_row_index = 0;
export const table_page_step = 1;
// page/products/pg-products.tsx
import {table_first_row_index, table_page_step} from "@/constant/table";
const toFirstRow = (rows: Row[]): Row | undefined => {
return rows[table_first_row_index];
};
const toNextPage = (page: number): number => {
return page + table_page_step;
};뜻이 없는 숫자는 그대로 둡니다.
const toFirstRow = (rows: Row[]): Row | undefined => {
return rows[0];
};
const toNextPage = (page: number): number => {
return page + 1;
};함께 보기
4.5 한 곳에서 쓸 값을 조회표로 고르지 않습니다
값과 선택 조건이 사용처에 함께 남아 선택 기준을 바로 읽을 수 있습니다.
한 곳에서 쓸 값을 고르려고 객체나 Map으로 조회표를 만들지 않습니다.
같은 값은 그대로 넘기고 값이 달라질 때만 사용처에서 조건으로 고릅니다.
조회표는 여러 키의 대응 관계 자체가 도메인이나 외부 계약일 때만 두고, 선언 바로 위에 그 근거를 적습니다.
flowchart LR
q1{"자리마다 값이<br>달라지는가?"} -- 예 --> q2{"대응 관계 자체가<br>도메인, 외부 계약인가?"} -- 예 --> r2("근거를 적고 조회표를 둠")
q1 -- 아니요 --> r1("같은 값을 그대로 넘김")
q2 -- 아니요 --> r3("사용처에서 조건으로 고름")한 곳의 프롭 값을 고르려고 조회표를 만듭니다.
const chart_toolbar_variant_by_card_variant = {
default: "default",
fill: "default",
dialog: "dialog",
} satisfies Record<UiChartCardProps["variant"], UiChartToolbarProps["variant"]>;
<UiChart.Toolbar variant={chart_toolbar_variant_by_card_variant[props.variant]} />;
값이 달라지는 조건을 사용처에 적습니다.
<UiChart.Toolbar variant={props.variant === "fill" ? "default" : props.variant} />;
계약 조회표를 근거 없이 둡니다.
const order_status_by_api_code = {
P: "pending",
C: "completed",
D: "cancelled",
} as const satisfies Record<OrderStatusCode, OrderStatus>;
외부 코드와 화면 상태의 대응 관계가 계약이면 이유를 남기고 조회표를 둡니다.
/**
* GET /orders의 P, C, D 코드를 화면의 주문 상태 어휘로 바꾸는 API 경계 계약
*/
const order_status_by_api_code = {
P: "pending",
C: "completed",
D: "cancelled",
} as const satisfies Record<OrderStatusCode, OrderStatus>;
함께 보기
4.6 값을 다루는 보조 함수는 es-toolkit을 먼저 사용합니다
중복 제거와 표기 변환을 파일마다 다르게 만들지 않고 검증된 구현 하나로 모읍니다.
쓸 함수 고르기
값을 다루는 보조 함수는 es-toolkit을 기본으로 쓰고, lodash는 새로 들이지 않습니다.
| 작업 | 사용할 함수 |
|---|---|
| 중복 제거, 그룹, 색인 | uniq, uniqBy, groupBy, keyBy |
| 차집합, 교집합, 합집합, 값 제외, 토글 | difference, intersection, union, without, xor |
| 정렬, 분할, 조건 분류, 반복 범위 | sortBy, orderBy, chunk, partition, range |
| 객체 복사, 깊은 비교 | clone, cloneDeep, isEqual |
| 필드 선택, 제외, 값 변환 | pick, omit, mapValues |
| 문자열 표기, HTML 이스케이프 | camelCase, snakeCase, kebabCase, pascalCase, capitalize, escape |
| 호출 빈도, 횟수, 결과 저장 | debounce, throttle, once, memoize |
| 집계, 범위 제한, 최대, 최소 | sum, sumBy, mean, clamp, maxBy, minBy |
| 빈 값, 타입 검사 | isNil, isNotNil, isEmptyObject, isPlainObject |
| 비동기 지연, 시간 제한, 재시도 | delay, withTimeout, retry |
표에 없어도 es-toolkit 문서에 같은 의미의 함수가 있으면 사용합니다.
다만 map, filter, find, flat, at, Object.keys처럼 표준 메서드 하나로 끝나면 그대로 둡니다.
교체 전 확인
| 교체 전 확인 | 지킬 계약 |
|---|---|
| 이름은 같지만 제거 대상이 다름 | compact는 falsy를 모두 제거합니다 |
| 중복 제거 후 남는 항목과 순서 | Map은 마지막 항목과 키의 최초 삽입 순서, uniqBy는 첫 항목을 남깁니다. 배열을 뒤집어 교체할 때도 남는 항목과 결과 순서가 같은지 확인합니다 |
| 빈 목록의 최소, 최대 | minBy, maxBy 결과의 undefined만 검사합니다. 사전 length 검사와 값 추출용 중간 map은 제거합니다 |
| 표준 메서드로 끝나지 않는 연산 | 직접 여러 줄로 구현하기 전에 es-toolkit에서 찾습니다 |
날짜는 규칙 4.7, 정렬은 규칙 4.1을 따릅니다.
반복 조회는 규칙 4.2에 따라 Set, Map으로 처리합니다.
es-toolkit에 있는 함수를 손으로 다시 씁니다.
const uniqueOwnerIds = ownerIds.filter((ownerId, index) => ownerIds.indexOf(ownerId) === index);
const uniqueCategories = [...new Set(points.map((point) => point.x))];
const productsByCategory = products.reduce<Record<string, Product[]>>((grouped, product) => {
grouped[product.categoryId] = [...(grouped[product.categoryId] ?? []), product];
return grouped;
}, {});
const draftFilter = JSON.parse(JSON.stringify(savedFilter)) as ProductFilter;
const searchKey = rawKey.replace(/([A-Z])/g, "_$1").toLowerCase();
const tickTimes = Array.from({length: tick_count}, (_unused, tickIndex) => toTickTime(tickIndex));
es-toolkit 함수를 그대로 부릅니다.
import {cloneDeep, groupBy, range, snakeCase, uniq} from "es-toolkit";
const uniqueOwnerIds = uniq(ownerIds);
const uniqueCategories = uniq(points.map((point) => point.x));
const productsByCategory = groupBy(products, (product) => product.categoryId);
const draftFilter = cloneDeep(savedFilter);
const searchKey = snakeCase(rawKey);
const tickTimes = range(tick_count).map((tickIndex) => toTickTime(tickIndex));
빈 목록을 먼저 검사하고 중간 배열을 만들어 양 끝을 읽습니다.
const toChartBounds = (points: readonly ChartPoint[]) => {
const yValues = points.map((point) => point.y);
if (yValues.length === 0) {
return undefined;
}
return {min: Math.min(...yValues), max: Math.max(...yValues)};
};
빈 목록 판정을 minBy, maxBy의 결과로 합칩니다.
import {maxBy, minBy} from "es-toolkit";
const toChartBounds = (points: readonly ChartPoint[]) => {
const lowestPoint = minBy(points, (point) => point.y);
const highestPoint = maxBy(points, (point) => point.y);
if (lowestPoint === undefined || highestPoint === undefined) {
return undefined;
}
return {min: lowestPoint.y, max: highestPoint.y};
};
표준 메서드 하나로 끝나면 감싸지 않습니다.
const activeProducts = products.filter((product) => product.status === "active");
const trimmedKeyword = keyword.trim();
함께 보기
4.7 날짜는 dayjs로 다룹니다
날짜의 단위와 타임존을 드러내고 파싱과 표시 형식을 일관되게 유지합니다.
작업별 기준
날짜는 dayjs로 다루고, moment는 새로 들이지 않습니다.
계산 단위, 입력 형식, 표시 타임존을 계약에 맞게 구분합니다.
flowchart LR
q1{"서버가 표시 형식까지<br>확정했는가?"} -- 아니요 --> q2{"값을 더하거나<br>빼는가?"} -- 아니요 --> q3{"유효성을<br>보는가?"} -- 예 --> r3("유효성 검사")
q1 -- 예 --> r1("계약대로 문자열 표시")
q2 -- 예 --> r2("경과 시간과 달력<br>단위를 구분해 add")
q3 -- 아니요 --> r4("format 으로 표시")| 작업 | 기준 |
|---|---|
| 파싱, 유효성 검사 | new Date(text) 대신 dayjs(text)와 입력 형식에 맞는 검증을 사용합니다 |
| 경과 시간, 달력 날짜 계산 | add, subtract의 단위를 구분합니다. 정확히 24시간과 현지 다음 날은 서머타임 경계에서 다를 수 있습니다 |
| 밀리초, 월 계산 교체 | 밀리초 연산을 add(..., "day")로 일괄 치환하지 않습니다. 월 계산은 월말 처리 계약을 확인합니다 |
| 표시, 비교 | 수동 문자열 조합, toLocaleDateString 대신 format, getTime 비교 대신 isBefore, isAfter, isSame을 씁니다 |
입력, 표시 계약
| 입력, 표시 계약 | 처리 |
|---|---|
| 고정된 날짜 형식 | 파싱 후 같은 형식으로 되돌려 원문과 비교합니다. 2026-02-30처럼 보정되는 날짜도 거릅니다 |
| 여러 입력 형식, 엄격한 형식 검증 | CustomParseFormat을 초기화하고 dayjs(value, input_format, true).isValid()로 검사합니다 |
| 시각과 오프셋이 포함된 문자열 | 날짜만 되돌리는 비교를 적용하지 않습니다 |
| UTC, 특정 지역 시간 | utc, timezone 플러그인을 초기화합니다. 기본 dayjs(value)는 실행 환경의 로컬 타임존을 사용합니다 |
| 타임존 기본값, 날짜 계산 | dayjs.tz.setDefault()는 일반 dayjs(value)를 바꾸지 않습니다. 서머타임 전후 현지 시각과 오프셋을 실제로 확인합니다 |
| 서버가 표시 타임존, 형식까지 확정한 문자열 | 그대로 표시할 때는 파싱하지 않습니다. 문자열 자르기가 표시 규칙이면 유지합니다 |
| UTC 시각을 사용자 타임존으로 표시 | 먼저 타임존을 변환합니다 |
형식 문자열은 상수로 선언하며 입력 형식과 화면 표시 형식은 별도 상수로 둡니다. 배치는 규칙 2.1를 따릅니다.
정해진 경과 시간을 밀리초로 더하고 자릿수를 손으로 채웁니다.
// 만료 계약은 발급 시점부터 정확히 token_expiry_hours시간 뒤
const expiresAt = new Date(issuedAt.getTime() + token_expiry_hours * 60 * 60 * 1000);
const expiresLabel = `${expiresAt.getFullYear()}.${toPaddedDatePart(expiresAt.getMonth() + 1)}`;
더하기와 형식은 dayjs, 형식 문자열은 상수로 둡니다.
import dayjs from "dayjs";
import {date_expiry_month_format} from "@/constant/date";
// date_expiry_month_format은 원래 표시와 같은 YYYY.MM 형식
const expiresAt = dayjs(issuedAt).add(token_expiry_hours, "hour");
const expiresLabel = expiresAt.format(date_expiry_month_format);
형식만 보고 없는 날짜를 통과시킵니다.
const isValidDateText = /^\d{4}-\d{2}-\d{2}$/.test(dateText);
라운드트립으로 없는 날짜를 거릅니다.
import dayjs from "dayjs";
import {date_input_format} from "@/constant/date";
/**
* 형식은 맞지만 없는 날짜(2026-02-30 등) 제외
*/
export const parseEntryDateText = (dateText: string): string | undefined => {
return dayjs(dateText).format(date_input_format) === dateText ? dateText : undefined;
};
서버가 표시 형식까지 확정한 문자열은 필요한 부분만 자릅니다.
// 서버가 이미 표시 타임존으로 준 고정 형식. 시각 변환 없이 분까지만 표시
const response_date_time_minute_length = "YYYY-MM-DDTHH:mm".length;
const compactDateTime = responseDateTime.slice(0, response_date_time_minute_length).replace("T", " ");
함께 보기
4.8 판정은 한 번만 하고 결과를 데이터로 전달합니다
같은 판정을 반복하지 않고 사용처가 전달된 결과를 사용합니다.
값이 들어오는 경계에서 한 번 판정하고, 결과를 데이터 필드로 전달합니다. 사용처는 같은 판정 함수를 다시 호출하거나 공유 보조 함수로 추출하지 않고 그 필드를 읽습니다.
| 반복되는 처리 | 변경 |
|---|---|
| 포맷한 문자열을 사용처가 다시 파싱, 포맷 | 경계에서 한 번 포맷하고 그대로 사용합니다 |
정리한 값을 사용처가 다시 trim | 경계에서 한 번 정리합니다 |
| 두 함수가 같은 판정 함수를 호출 | 항목에 판정 결과를 담고 두 함수가 읽습니다 |
전달된 결과 옆에 ?? 다시 판정 폴백 | 폴백을 제거합니다 |
결과를 담을 필드가 없으면 소유자가 만드는 내부 항목 형태에 추가합니다. 외부 응답, 생성된 DTO, 공개 요청 계약을 바꾸거나 공유 캐시 원본을 직접 수정하지 않습니다.
경계에서 포맷한 값을 사용처가 다시 파싱해 포맷합니다.
// page/product-detail/pg-product-detail.tsx: ProductSummary를 만들며 이미 포맷
const productSummary = {averageRate: formatPercent(responseProductSummarySuspense.data.changeRate)};
// page/product-detail/_function/to-summary-rows.ts: 문자열을 다시 숫자로 읽어 재포맷
const rows = [{id: "changeRate", value: formatPercent(productSummary.averageRate)}];
경계에서 한 번 포맷하고 사용처는 전달된 값을 그대로 씁니다.
// page/product-detail/pg-product-detail.tsx: ProductSummary를 만드는 경계에서 한 번만 포맷
const productSummary = {averageRate: formatPercent(responseProductSummarySuspense.data.changeRate)};
const rows = [{id: "changeRate", value: productSummary.averageRate}];같은 색 판정을 범례와 차트 둘에서 하고 폴백으로 한 번 더 합니다.
// 범례
const legendSeries = seriesItems.map((series, seriesIndex) => ({
id: series.id,
colorToken: toSeriesColorToken(series.role, seriesIndex),
}));
// 차트 둘. 범례 팔레트를 읽고도 같은 판정 반복
const chartSeries = seriesItems.map((series, index) => ({
id: series.id,
colorToken: colorTokenById.get(series.id) ?? toSeriesColorToken(series.role, index),
}));
경계에서 한 번 정해 항목에 담고 차트는 읽기만 합니다.
// 범례를 만드는 자리에서 색을 정해 항목에 저장
const comparisonSeries = seriesItems.map((series, seriesIndex) => ({
...series,
colorToken: toSeriesColorToken(series.role, seriesIndex),
}));
// 차트 둘은 같은 항목의 색을 그대로 읽음
const chartSeries = comparisonSeries.map((series) => ({
id: series.id,
colorToken: series.colorToken,
}));
함께 보기