- Published on
ES2024 Records & Tuples와 TS 타입추론 실전
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서로 다른 레이어(런타임/타입시스템)에서 “불변(immutable) 데이터”를 다루다 보면 늘 같은 질문으로 돌아옵니다.
- 값이 바뀌지 않는다는 것을 런타임이 보장할 수 있는가
- 깊은 동등성(deep equality)을 언어 차원에서 지원하는가
- TypeScript가 그 불변성을 얼마나 정확히 추론해 주는가
ES2024 Records & Tuples는 바로 이 지점을 정면으로 다루는 제안입니다. 다만 현실에서는 “표준에 들어왔는가”, “TS가 얼마나 지원하는가”, “대체 패턴은 무엇인가”를 함께 봐야 실전에서 쓸 수 있습니다. 이 글은 그 간극을 메우는 가이드입니다.
참고: 글에서는 제안 문법 표기를 위해
#[],#{}형태를 사용합니다. MDX 빌드 특성상 부등호 문자는 본문에 노출하지 않도록 모든 관련 표기는 인라인 코드로 감쌉니다.
Records & Tuples 한 장 요약
목표: 구조적 불변 + 깊은 동등성
Records & Tuples는 다음 성질을 언어 차원에서 제공하는 것을 목표로 합니다.
- 깊은 불변(Deep immutability): 내부 중첩까지 변경 불가
- 깊은 동등성(Deep equality): 값이 같으면 같은 것으로 취급
- 구조적 공유(Structural sharing) 가능성: 구현에 따라 메모리 효율
- JSON 친화적: 함수/클래스 인스턴스 같은 “행동”보다 “데이터” 중심
직관적으로는 “객체/배열이지만 값 타입처럼 동작하는” 데이터 구조입니다.
문법(제안): #{} 와 #[]
- Record:
#{ a: 1, b: 2 } - Tuple:
#[1, 2, 3]
그리고 핵심은 동등성 비교가 “참조”가 아니라 “값”에 가깝다는 점입니다.
// 제안 기반 예시(현재는 엔진/트랜스파일러 지원이 필요)
const a = #{ x: 1, y: #[2, 3] };
const b = #{ x: 1, y: #[2, 3] };
// 목표: a 와 b 는 깊은 값이 같으므로 동등
// (정확한 연산자 의미는 제안/구현에 따라 달라질 수 있음)
현업에서 중요한 질문은 이것입니다.
- “이게 실제로 배포 가능한가?”
- “TypeScript가 타입을 얼마나 잘 잡아주나?”
아래에서 실전 관점으로 풀어보겠습니다.
현재(2026년 초) 실전 적용 관점: 표준 vs 도구
Records & Tuples는 “ES2024에 확정 포함”이라기보다 TC39 제안 흐름에서 논의되는 기능으로 이해하는 편이 안전합니다. 즉, 다음을 전제로 접근해야 합니다.
- 런타임(브라우저/Node.js)에서 기본 지원이 제한적일 수 있음
- Babel/트랜스파일/폴리필이 필요할 수 있음
- TypeScript는 “새 런타임 타입”을 바로 모델링하기보다, **기존 타입 시스템(리터럴, readonly, const 추론)**으로 유사한 경험을 제공하는 방식이 많음
따라서 실전에서는 두 갈래로 나뉩니다.
- Records & Tuples를 “미래 대비”로 이해하고, 지금은 TS 패턴으로 최대한 비슷하게 만든다
- 특정 빌드 파이프라인에서 실험적으로 도입하고, 타입은 별도 래핑으로 관리한다
이 글은 1번을 중심으로, 2번도 가능한 형태로 설명합니다.
TypeScript 타입추론: 핵심은 as const와 readonly의 조합
Records & Tuples가 주는 가장 큰 개발자 경험은 “이 값은 데이터이며, 바뀌지 않는다”를 자연스럽게 표현한다는 점입니다. TS에서는 이를 다음 조합으로 근사합니다.
- 리터럴 타입 유지:
as const - 불변 배열/객체:
readonly또는Readonly/ReadonlyArray - 깊은 불변:
DeepReadonly유틸리티(직접 정의)
as const가 바꾸는 것: widening 방지
const user1 = { role: "admin", level: 3 };
// user1.role: string (widening)
const user2 = { role: "admin", level: 3 } as const;
// user2.role: "admin"
// user2.level: 3
// user2는 readonly 프로퍼티를 갖는 리터럴로 추론
Records & Tuples가 목표로 하는 “값 중심 데이터”는 사실 TS에서 as const만으로도 꽤 많은 부분을 얻습니다.
튜플 추론: as const 없으면 배열로 무너진다
const a = ["pending", 200];
// a: (string | number)[]
const b = ["pending", 200] as const;
// b: readonly ["pending", 200]
type Status = typeof b[0]; // "pending"
type Code = typeof b[1]; // 200
여기서 “튜플처럼 쓰고 싶다”는 요구는 Records & Tuples의 Tuple과 정확히 겹칩니다.
깊은 불변(Deep immutability) 모델링: DeepReadonly
Records & Tuples는 기본적으로 “중첩까지 불변”을 지향합니다. TS의 Readonly는 얕은(shallow) 불변이라서 중첩 객체가 뚫립니다.
type Shallow = Readonly<{ a: { b: number } }>;
// a는 readonly지만 a.b는 변경 가능
실전에서는 아래처럼 DeepReadonly를 정의해 사용합니다.
type Primitive = string | number | boolean | bigint | symbol | null | undefined;
type DeepReadonly<T> =
T extends Primitive ? T :
T extends (...args: any[]) => any ? T :
T extends readonly (infer U)[] ? ReadonlyArray<DeepReadonly<U>> :
T extends Map<infer K, infer V> ? ReadonlyMap<DeepReadonly<K>, DeepReadonly<V>> :
T extends Set<infer M> ? ReadonlySet<DeepReadonly<M>> :
T extends object ? { readonly [K in keyof T]: DeepReadonly<T[K]> } :
T;
이제 Records/ Tuples의 “깊은 불변”에 가까운 타입을 만들 수 있습니다.
type State = DeepReadonly<{
user: { id: string; roles: string[] };
flags: { beta: boolean };
}>;
이 패턴은 백오프/재시도 같은 유틸을 만들 때도 “상태 객체는 변경하지 않는다”는 계약을 강제하는 데 유용합니다. 관련해서는 Decorator+Generator로 재시도·백오프 20줄 구현 글의 “상태/설정 객체 불변 유지” 관점과도 잘 맞습니다.
깊은 동등성: TS 타입추론보다 런타임 전략이 중요
Records & Tuples의 두 번째 축은 “깊은 동등성”입니다. TS 타입추론은 동등성의 런타임 의미를 바꾸지 못합니다. 따라서 실전에서는 다음 중 하나를 택합니다.
- 데이터 구조를 불변으로 유지하고, 비교는
fast-deep-equal같은 함수로 수행 - 해시 기반 비교(캐시 키)로 우회
- Immer/Immutable.js 같은 라이브러리의 구조적 공유 + 참조 비교를 활용
실전 패턴: 캐시 키를 위한 안정적 직렬화
API 캐시, React Query 키, 메모이제이션 키에서 “값이 같으면 같은 키”가 필요할 때가 많습니다.
function stableStringify(value: unknown): string {
if (value === null || typeof value !== "object") return JSON.stringify(value);
if (Array.isArray(value)) return `[${value.map(stableStringify).join(",")}]`;
const obj = value as Record<string, unknown>;
const keys = Object.keys(obj).sort();
const entries = keys.map(k => `${JSON.stringify(k)}:${stableStringify(obj[k])}`);
return `{${entries.join(",")}}`;
}
const key = stableStringify({ b: 2, a: 1 });
// 항상 {"a":1,"b":2} 형태로 안정화
Records & Tuples가 제공하려는 “값 기반 동일성”을 지금 당장 달성하려면, 결국 위 같은 런타임 전략이 필요합니다.
TS에서 Record/Tuple “같은” 개발 경험 만들기
1) const 팩토리로 리터럴 유지 + 깊은 readonly 부여
as const는 선언 지점에서만 강력합니다. 함수 인자로 들어가면 widening이 다시 발생하기 쉽습니다. 이때 팩토리 함수로 감싸면 추론이 좋아집니다.
function tuple<const T extends readonly unknown[]>(...t: T): T {
return t;
}
function record<const T extends Record<string, unknown>>(r: T): T {
return r;
}
const t = tuple("pending", 200, true);
// t: readonly ["pending", 200, true]
const r = record({ role: "admin", level: 3 });
// r: { role: "admin"; level: 3 }
여기서 const 타입 파라미터는 “리터럴을 최대한 유지하라”는 의도를 전달합니다.
주의: 제네릭 표기에서 부등호가 필요하지만, 본문에 노출하면 MDX가 JSX로 오인할 수 있습니다. 그래서 위 코드는 코드 블록 안에서만 표기했습니다.
2) 깊은 불변까지 강제하는 deepFreeze + 타입 결합
런타임에서도 실제로 얼려버리면(개발 모드에서 특히) 불변 위반을 빨리 잡을 수 있습니다.
function deepFreeze<T>(obj: T): DeepReadonly<T> {
if (obj && typeof obj === "object") {
Object.freeze(obj);
for (const key of Object.keys(obj as any)) {
deepFreeze((obj as any)[key]);
}
}
return obj as any;
}
const config = deepFreeze({
retry: { max: 3, baseDelayMs: 200 },
endpoints: ["/api/a", "/api/b"]
});
// config.endpoints.push("/api/c") // 컴파일 타임/런타임 모두에서 막히는 형태를 목표
Records & Tuples가 “언어 차원에서 기본 제공하려는 안전성”을, TS에서는 이런 식으로 구현하는 경우가 많습니다.
3) 패턴 매칭처럼 쓰기: 유니온 + 튜플
Tuple은 상태 머신/이벤트 모델에 특히 잘 맞습니다.
type Event =
| readonly ["USER_LOGIN", { id: string }]
| readonly ["USER_LOGOUT"]
| readonly ["ERROR", { message: string; retryable: boolean }];
function handle(e: Event) {
const [type] = e;
switch (type) {
case "USER_LOGIN": {
const payload = e[1];
return payload.id;
}
case "USER_LOGOUT":
return "bye";
case "ERROR":
return e[1].retryable ? "retry" : "fail";
}
}
이 스타일은 “데이터는 불변, 이벤트는 값”이라는 Records & Tuples 철학과 합이 좋고, TS의 타입추론도 매우 강합니다.
마이그레이션 전략: 지금 코드에 어떻게 섞을까
단계 1: 경계에서만 불변을 강제
모든 곳을 한 번에 바꾸면 비용이 큽니다. 보통은 다음 경계부터 시작합니다.
- API 응답 정규화 결과
- 캐시 키/쿼리 키
- 도메인 이벤트
- 설정(config) 객체
이 지점들에 DeepReadonly + deepFreeze를 먼저 적용하면 효과가 큽니다.
단계 2: “바꿀 수 있는 객체”와 “값 객체”를 분리
예를 들어 결제/주문 같은 도메인에서는 값 객체를 강하게 유지하는 것이 버그를 줄입니다. 사가 패턴에서 중복 결제를 막는 흐름처럼, 상태 전이가 명확해야 하는 곳일수록 불변 데이터가 유리합니다. 관련 맥락은 MSA 사가(Saga) 패턴 구현으로 중복결제 방지하기에서도 확인할 수 있습니다.
단계 3: 향후 Records & Tuples 도입을 위한 “표현”을 정리
나중에 런타임에서 #[], #{}를 도입할 여지가 생기면, 아래처럼 “표현 계층”을 분리해 두는 게 좋습니다.
- 내부에서는
tuple()/record()팩토리로 생성 - 외부 직렬화는
stableStringify같은 함수로 일관성 유지 - 비교는
deepEqual(a, b)같은 단일 함수로 통일
이렇게 해두면, 실제 Records & Tuples가 안정적으로 지원되는 시점에 구현만 갈아끼우기 쉽습니다.
흔한 함정 5가지와 해결책
1) as const 남발로 타입이 너무 좁아짐
리터럴이 지나치게 좁아져서 재사용이 어려워질 수 있습니다. 이때는 “값은 const, 타입은 넓게”를 분리합니다.
const env = "prod" as const;
type Env = "dev" | "staging" | "prod";
const currentEnv: Env = env;
2) 함수 인자에서 리터럴이 widening됨
앞서 소개한 record()/tuple() 팩토리 또는 const 타입 파라미터를 사용하세요.
3) Readonly는 깊지 않다
중첩 구조가 있는 순간 DeepReadonly가 필요합니다.
4) JSON 직렬화가 동등성의 전부가 아니다
undefined, NaN, 순서 없는 키, 날짜 객체 등은 JSON만으로는 의미 보존이 어려울 수 있습니다. 캐시 키 목적이면 “지원 범위를 명확히 제한”하는 게 안전합니다.
5) 불변을 강제하면 업데이트가 불편하다
불변 업데이트 헬퍼를 두면 해결됩니다.
function set<T extends object, K extends keyof T>(obj: T, key: K, value: T[K]): T {
return { ...obj, [key]: value };
}
const s1 = { a: 1, b: 2 } as const;
const s2 = set(s1, "b", 3);
// s2는 새 객체
Records & Tuples가 지향하는 세계에서는 이런 “새 값 생성”이 기본 비용이 되므로, 팀 차원에서 업데이트 패턴을 표준화하는 게 중요합니다.
언제 도입하면 좋은가: 체크리스트
다음 조건이면 Records & Tuples(또는 그에 준하는 TS 패턴)의 ROI가 큽니다.
- 상태/이벤트가 복잡하고, 변경 추적이 어렵다
- 캐시 키/메모이제이션에서 깊은 동등성이 자주 필요하다
- 설정 객체가 여기저기서 바뀌며 장애를 만든다
- 프론트엔드에서 불변 데이터 기반 렌더 최적화가 필요하다
특히 렌더링/리페인트 최적화처럼 “불필요한 변경”을 줄이는 것이 성능에 직결되는 문제에서는 불변 데이터 모델이 도움이 됩니다. 성능 최적화 관점 참고로 Safari iOS 스크롤 끊김 - 레이어·리페인트 최적화도 함께 읽어보면 연결되는 지점이 많습니다.
결론: 지금은 TS로, 미래는 표준으로
Records & Tuples는 “데이터를 값으로 다루는 언어 기능”을 목표로 하며, 불변성과 깊은 동등성을 더 자연스럽게 만들려는 시도입니다. 다만 실전에서는 런타임 지원과 TS 지원이 완전히 맞물리기 전까지 공백이 존재합니다.
그 공백을 메우는 가장 현실적인 접근은 다음 3가지입니다.
as const와const타입 파라미터로 리터럴/튜플 추론을 최대한 살린다DeepReadonly+deepFreeze로 “깊은 불변” 계약을 타입/런타임 양쪽에서 강화한다- 깊은 동등성은 비교 함수/안정적 직렬화로 일관된 전략을 세운다
이렇게 해두면, Records & Tuples가 더 널리 지원되는 시점에 “표현만 교체”하는 방식으로 자연스럽게 진화시킬 수 있습니다.