본문으로 건너뛰기
Advertisement

3.4 인덱스 시그니처와 Record

프로그래밍에서는 키-값 쌍을 동적으로 관리해야 하는 상황이 자주 등장한다. 번역 문자열 테이블, 캐시 저장소, HTTP 헤더 맵, 설정 딕셔너리 등이 그 예다. TypeScript는 이런 동적 객체를 타입 안전하게 다루기 위해 인덱스 시그니처(index signature)와 Record 유틸리티 타입을 제공한다.


인덱스 시그니처 기본 문법

인덱스 시그니처는 객체가 동적 키를 가질 수 있음을 선언하는 방법이다. [키변수명: 키타입]: 값타입 형태로 작성한다.

문자열 인덱서 [key: string]: T

interface StringDictionary {
[key: string]: string;
}

const translations: StringDictionary = {
hello: "안녕하세요",
goodbye: "안녕히 가세요",
thank_you: "감사합니다",
};

// 동적으로 키 추가 가능
translations["good_morning"] = "좋은 아침이에요";

// 존재하지 않는 키 접근 → 런타임 undefined, 컴파일 타임 string
const result = translations["unknown"]; // 타입: string (실제로는 undefined)

키 변수명(위에서 key)은 가독성을 위한 이름일 뿐, 어떤 이름이든 상관없다.

interface HttpHeaders {
[headerName: string]: string;
}

interface Cache {
[cacheKey: string]: unknown;
}

숫자 인덱서 [key: number]: T

interface NumericArray {
[index: number]: string;
}

const fruits: NumericArray = {
0: "apple",
1: "banana",
2: "cherry",
};

console.log(fruits[0]); // "apple"
console.log(fruits[3]); // undefined (런타임), string (컴파일 타임)

JavaScript 런타임에서 숫자 키는 문자열로 변환되므로, 숫자 인덱서의 값 타입은 문자열 인덱서 값 타입의 서브타입이어야 한다.

interface Mixed {
[key: string]: string | number;
[key: number]: string; // string은 string | number의 서브타입 → 정상
}

인덱스 시그니처의 제한

명시적 프로퍼티와의 타입 호환성

인덱스 시그니처가 있는 인터페이스에 명시적 프로퍼티를 추가하려면, 그 프로퍼티의 타입이 인덱서의 값 타입과 호환되어야 한다.

interface GoodMixed {
[key: string]: string | number | boolean;
name: string; // string ⊂ (string | number | boolean) → 정상
age: number; // number ⊂ (string | number | boolean) → 정상
active: boolean; // boolean ⊂ (string | number | boolean) → 정상
}

// 오류 케이스
interface BadMixed {
[key: string]: string;
// count: number; // 오류: number는 string과 호환되지 않음
// data: object; // 오류: object는 string과 호환되지 않음
}

이 제한 때문에 인덱스 시그니처의 값 타입을 unknown이나 any로 넓히는 경우가 많은데, 이는 타입 안전성을 잃는 대가다.

optional 프로퍼티 불가

인덱스 시그니처 자체에는 ?를 붙일 수 없다. 값 타입에 undefined를 포함시키는 방식으로 대신한다.

// interface Bad { [key: string]?: string; } // 문법 오류

interface AllowUndefined {
[key: string]: string | undefined; // undefined 허용
}

Record<K, V> 유틸리티 타입

Record<K, V>는 키 타입 K와 값 타입 V로 구성된 객체 타입을 생성하는 유틸리티다. 내부적으로는 mapped 타입으로 구현되어 있다.

// Record의 내부 구현
type Record<K extends keyof any, T> = {
[P in K]: T;
};

기본 사용

// 모든 string 키에 number 값
type ScoreMap = Record<string, number>;
const scores: ScoreMap = { Alice: 95, Bob: 87, Charlie: 92 };

// 특정 키들만 허용
type DayOfWeek = "mon" | "tue" | "wed" | "thu" | "fri" | "sat" | "sun";
type WeeklySchedule = Record<DayOfWeek, string[]>;

const schedule: WeeklySchedule = {
mon: ["스탠드업 미팅", "코드 리뷰"],
tue: ["기획 회의"],
wed: ["스프린트 플래닝"],
thu: ["1:1 미팅"],
fri: ["회고"],
sat: [],
sun: [],
};

// 누락된 키가 있으면 컴파일 오류 → 모든 키를 강제로 정의해야 함

제네릭과 결합

// 상태 머신의 전이 테이블
type TransitionMap<S extends string> = Record<S, Partial<Record<S, boolean>>>;

type TrafficLightState = "red" | "yellow" | "green";
const transitions: TransitionMap<TrafficLightState> = {
red: { green: true }, // red → green 허용
yellow: { red: true }, // yellow → red 허용
green: { yellow: true }, // green → yellow 허용
};

인덱스 시그니처 vs Record vs Map 비교

특성인덱스 시그니처Record<K, V>Map<K, V>
타입 레벨 존재컴파일 타임컴파일 타임런타임 + 타입
키 타입 제한string | number | symbolkeyof any 서브타입모든 타입
특정 키 집합 강제불가가능 (리터럴 유니온 사용 시)불가
런타임 크기 조회Object.keys().lengthObject.keys().length.size
순서 보장삽입 순서(비공식)삽입 순서(비공식)삽입 순서(공식)
JSON 직렬화직접 가능직접 가능별도 처리 필요
iterablefor...infor...infor...of
// 인덱스 시그니처: 구조가 유연하지만 키 집합 강제 불가
interface Config {
[key: string]: unknown;
version: string; // 혼합 가능 (타입 호환 조건 충족 시)
}

// Record: 특정 키 집합 강제 가능, JSON 친화적
const permissions: Record<"read" | "write" | "admin", boolean> = {
read: true,
write: false,
admin: false,
// extra: true, // 오류: 허용되지 않은 키
};

// Map: 객체를 키로 사용 가능, 런타임 순서 보장
const userCache = new Map<string, User>();
userCache.set("user-1", { id: "1", name: "Alice", email: "a@b.com", username: "alice", isActive: true });
console.log(userCache.size); // 1

언제 무엇을 쓸까:

  • 키가 string 전체이고 JSON 직렬화가 필요하면 → 인덱스 시그니처 또는 Record<string, V>
  • 특정 키 집합을 강제해야 하면 → Record<리터럴유니온, V>
  • 객체를 키로 사용하거나 삽입 순서가 중요하면 → Map

keyof typeof 패턴으로 동적 객체 키 타입 추출

런타임 객체에서 키 타입을 추출하는 강력한 패턴이다.

const HTTP_STATUS = {
OK: 200,
CREATED: 201,
NO_CONTENT: 204,
BAD_REQUEST: 400,
UNAUTHORIZED: 401,
FORBIDDEN: 403,
NOT_FOUND: 404,
INTERNAL_ERROR: 500,
} as const;

// typeof로 런타임 객체의 타입 추출
type HttpStatusObject = typeof HTTP_STATUS;
// { readonly OK: 200; readonly CREATED: 201; ... }

// keyof로 키 타입 추출
type HttpStatusKey = keyof typeof HTTP_STATUS;
// "OK" | "CREATED" | "NO_CONTENT" | "BAD_REQUEST" | ...

// 값 타입 추출
type HttpStatusValue = (typeof HTTP_STATUS)[HttpStatusKey];
// 200 | 201 | 204 | 400 | 401 | 403 | 404 | 500

function getStatusMessage(key: HttpStatusKey): string {
const messages: Record<HttpStatusKey, string> = {
OK: "성공",
CREATED: "생성됨",
NO_CONTENT: "내용 없음",
BAD_REQUEST: "잘못된 요청",
UNAUTHORIZED: "인증 필요",
FORBIDDEN: "접근 금지",
NOT_FOUND: "찾을 수 없음",
INTERNAL_ERROR: "서버 오류",
};
return messages[key];
}

console.log(getStatusMessage("NOT_FOUND")); // "찾을 수 없음"

객체를 열거형(enum) 대신 사용하는 패턴

const ROLES = {
USER: "user",
MODERATOR: "moderator",
ADMIN: "admin",
SUPERADMIN: "superadmin",
} as const;

type Role = (typeof ROLES)[keyof typeof ROLES];
// "user" | "moderator" | "admin" | "superadmin"

function hasPermission(userRole: Role, requiredRole: Role): boolean {
const hierarchy = Object.values(ROLES);
return hierarchy.indexOf(userRole) >= hierarchy.indexOf(requiredRole);
}

PropertyKey 타입

PropertyKey는 TypeScript 내장 타입으로 객체의 키로 사용 가능한 모든 타입을 나타낸다.

// PropertyKey의 정의
type PropertyKey = string | number | symbol;

인덱스 시그니처에 symbol 키까지 허용할 때 활용한다.

type AnyMap = Partial<Record<PropertyKey, unknown>>;

const metadata: AnyMap = {
name: "Alice",
42: "answer",
[Symbol.iterator]: function* () { yield 1; },
};

실전 예제: 다국어 번역 객체, 캐시 맵, 설정 딕셔너리

다국어 번역 시스템

type Locale = "ko" | "en" | "ja" | "zh";
type TranslationKey =
| "greeting"
| "farewell"
| "error.notFound"
| "error.unauthorized"
| "button.submit"
| "button.cancel";

// 모든 로케일과 키 조합을 강제
type TranslationTable = Record<Locale, Record<TranslationKey, string>>;

const translations: TranslationTable = {
ko: {
greeting: "안녕하세요",
farewell: "안녕히 가세요",
"error.notFound": "페이지를 찾을 수 없습니다",
"error.unauthorized": "로그인이 필요합니다",
"button.submit": "제출",
"button.cancel": "취소",
},
en: {
greeting: "Hello",
farewell: "Goodbye",
"error.notFound": "Page not found",
"error.unauthorized": "Login required",
"button.submit": "Submit",
"button.cancel": "Cancel",
},
ja: {
greeting: "こんにちは",
farewell: "さようなら",
"error.notFound": "ページが見つかりません",
"error.unauthorized": "ログインが必要です",
"button.submit": "送信",
"button.cancel": "キャンセル",
},
zh: {
greeting: "你好",
farewell: "再见",
"error.notFound": "页面未找到",
"error.unauthorized": "需要登录",
"button.submit": "提交",
"button.cancel": "取消",
},
};

function t(locale: Locale, key: TranslationKey): string {
return translations[locale][key];
}

console.log(t("ko", "greeting")); // "안녕하세요"
console.log(t("en", "button.submit")); // "Submit"

TTL 기반 캐시 맵

interface CacheEntry<T> {
value: T;
expiresAt: number; // Unix timestamp
}

class TypedCache<T> {
private store: Record<string, CacheEntry<T>> = {};

set(key: string, value: T, ttlMs: number): void {
this.store[key] = {
value,
expiresAt: Date.now() + ttlMs,
};
}

get(key: string): T | null {
const entry = this.store[key];
if (!entry) return null;
if (Date.now() > entry.expiresAt) {
delete this.store[key];
return null;
}
return entry.value;
}

has(key: string): boolean {
return this.get(key) !== null;
}

invalidate(key: string): void {
delete this.store[key];
}

purgeExpired(): number {
let count = 0;
const now = Date.now();
for (const key of Object.keys(this.store)) {
if (now > this.store[key].expiresAt) {
delete this.store[key];
count++;
}
}
return count;
}
}

// 사용 예
const userCache = new TypedCache<User>();
userCache.set("user-1", { id: "1", name: "Alice", email: "a@b.com", username: "alice", isActive: true }, 60_000);
const cached = userCache.get("user-1"); // User | null

설정 딕셔너리

type ConfigValue = string | number | boolean | string[] | null;

interface AppConfig {
[section: string]: Record<string, ConfigValue>;
}

const config: AppConfig = {
database: {
host: "localhost",
port: 5432,
name: "myapp",
ssl: true,
poolSize: 10,
},
redis: {
host: "localhost",
port: 6379,
db: 0,
ttl: 3600,
},
auth: {
jwtSecret: "secret-key",
tokenExpiry: "7d",
allowedOrigins: ["https://app.example.com"],
},
};

function getConfig<T extends ConfigValue>(
section: string,
key: string,
defaultValue: T
): T {
const sectionConfig = config[section];
if (!sectionConfig) return defaultValue;
const value = sectionConfig[key];
return (value ?? defaultValue) as T;
}

const dbPort = getConfig("database", "port", 5432); // number
const jwtSecret = getConfig("auth", "jwtSecret", ""); // string

고수 팁: noUncheckedIndexedAccess 옵션으로 안전한 인덱스 접근

기본적으로 TypeScript는 인덱스 시그니처로 접근한 값의 타입에 undefined를 포함하지 않는다. 이는 런타임 오류로 이어질 수 있다.

const map: Record<string, number> = { a: 1 };

// noUncheckedIndexedAccess 없이: 타입이 number
const value = map["b"]; // 타입: number, 런타임: undefined
console.log(value.toFixed(2)); // 런타임 TypeError!

tsconfig.json에서 noUncheckedIndexedAccess를 활성화하면 인덱스 접근 결과에 자동으로 | undefined가 추가된다.

// tsconfig.json
{
"compilerOptions": {
"noUncheckedIndexedAccess": true
}
}
// noUncheckedIndexedAccess 활성화 후
const map: Record<string, number> = { a: 1 };

const value = map["b"]; // 타입: number | undefined
// console.log(value.toFixed(2)); // 오류: 'value'가 undefined일 수 있음

// 안전한 접근 패턴 1: optional chaining
console.log(value?.toFixed(2)); // undefined 출력

// 안전한 패턴 2: null 병합 연산자
const safe = value ?? 0;
console.log(safe.toFixed(2)); // "0.00"

// 안전한 패턴 3: 타입 가드
if (value !== undefined) {
console.log(value.toFixed(2)); // 안전
}

// 안전한 패턴 4: hasOwnProperty 검사
function safeGet<T>(obj: Record<string, T>, key: string): T | undefined {
return Object.prototype.hasOwnProperty.call(obj, key) ? obj[key] : undefined;
}

배열 접근에도 적용됨

// noUncheckedIndexedAccess 활성화 시
const arr = [1, 2, 3];
const first = arr[0]; // 타입: number | undefined
const last = arr[arr.length - 1]; // 타입: number | undefined

// 방어적 코드
function safeFirst<T>(arr: T[]): T | undefined {
return arr[0]; // noUncheckedIndexedAccess에서 안전
}

// 인덱스 접근 없이 구조 분해 사용
const [head, ...tail] = arr;
console.log(head); // 타입: number (undefined 아님, 구조 분해는 다르게 처리)

정리 표

도구문법특징적합한 상황
문자열 인덱서[key: string]: T모든 string 키 허용HTTP 헤더, 임의 딕셔너리
숫자 인덱서[key: number]: Tnumber 키, 배열 유사배열 유사 구조
Record<string, V>Record<string, V>인덱서와 동일, 더 간결일반 키-값 맵
Record<K, V> (리터럴)Record<"a" | "b", V>특정 키 강제열거형 유사 객체
Map<K, V>런타임 클래스객체 키, 순서 보장복잡한 키, 순서 중요
keyof typeofkeyof typeof obj런타임 객체에서 키 추출const 객체 기반 타입
noUncheckedIndexedAccesstsconfig 옵션인덱스 접근에 | undefined타입 안전성 최대화

다음 장에서는...

3.5 함수 타입에서는 TypeScript에서 함수의 타입을 정의하는 다양한 방법을 다룬다. 함수 오버로드, 콜백 타입, this 파라미터, 그리고 공변/반공변 개념을 통해 함수 타입의 호환성을 깊이 있게 이해할 수 있다.

Advertisement