Navigation

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로 자리만 옮깁니다.

page/products/pg-products.tsxts
const maxAttempts = 42;

const isOverRetryLimit = (attempts: number): boolean => {
	return attempts > maxAttempts;
};

const toPreviewRows = (rows: Row[]): Row[] => {
	return rows.slice(0, 37);
};

constant 폴더에 선언하고 쓰는 자리에서 이름을 가리킵니다.

constant/retry.tsts
/**
 * 이 횟수를 넘으면 사용자에게 실패 표시
 */
export const retry_max_attempts = 42;
constant/preview.tsts
/**
 * 미리보기에 그릴 행 수. 서버가 한 번에 주는 최대치와 같음
 */
export const preview_row_count = 37;
page/products/pg-products.tsxts
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);
};

뜻이 없는 숫자에까지 이름을 붙입니다.

constant/table.tsts
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;
};

뜻이 없는 숫자는 그대로 둡니다.

page/products/pg-products.tsxts
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)};
page/product-detail/_function/to-summary-rows.tsts
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,
}));

함께 보기