
Object.entries에 as 쓰시는 분, 잠시만요
TypeScript 프로젝트에서 객체를 다루다 보면 Object.keys, Object.values, Object.entries를 정말 자주 사용하게 되는데요.
키 목록을 가져오거나, 값 목록을 가져오거나, 혹은 둘 다 한 번에 가져올 때 아주 유용한 친구들입니다.
그런데 Object.keys나 Object.entries를 썼을 때, TypeScript가 반환해 주는 키의 타입이 언제나 string으로 나오는 걸 보고 고개를 갸웃한 적 없으신가요?
분명 우리 객체에는 'foo'나 'bar' 같은 아주 구체적인 키만 들어있는 게 확실하거든요.
하지만 TypeScript는 이 사실을 모르는 척, 그냥 아주 넓은 의미의 string이라고만 알려주는 셈입니다.
const obj = {
foo: "bar",
baz: 5,
} as const;
// values의 타입은 'string | number'로 잘 추론됩니다.
const values = Object.values(obj);
// 하지만 entries의 키 타입은 왜 '"foo" | "baz"'가 아니라 'string'일까요?
const entries = Object.entries(obj); // [string, string | number][]
이러다 보니 결국 우리는 이렇게 '타입 단언(as)'이라는 마법의 주문을 외우게 되는데요.
// 우리 모두가 한 번쯤은 써봤을 그 코드...
const entries = Object.entries(obj) as [keyof typeof obj, string | number][];
as는 강력하지만, 컴파일러에게 '내가 너보다 더 잘 아니까 그냥 내 말대로 해!'라고 말하는 것과 같아서 타입 안정성을 해칠 수 있는 위험한 방법입니다.
매번 이렇게 as를 쓰는 것 말고, 더 우아하고 안전한 방법은 정말 없는 걸까요?
1단계 문제의 근원 파헤치기
사실 TypeScript가 이렇게 동작하는 데에는 아주 깊은 철학적인 이유가 있는데요.
바로 TypeScript의 타입 시스템은 객체의 '최소한의 형태'만을 보장하기 때문입니다.
예를 들어, 어떤 함수가 { name: string } 타입의 인자를 받는다고 해보죠.
function greet(person: { name: string }) {
console.log(`Hello, ${person.name}`);
}
const alice = { name: 'Alice', age: 30 };
greet(alice); // OK!
greet 함수는 name 속성만 있으면 만족하거든요.
alice 객체에 age라는 추가 속성이 있어도 전혀 신경 쓰지 않습니다.
이게 바로 TypeScript의 '구조적 타이핑'의 핵심 원리입니다.
이걸 Object.keys의 관점에서 다시 보면, TypeScript는 obj의 타입만 보고는 '이 객체는 최소한 foo와 baz를 가지고 있겠구나'라고 생각하지만, '런타임에 누군가 obj.anotherKey = 'hello'처럼 속성을 추가했을 가능성'을 배제하지 않는 겁니다.
그래서 가장 안전한 타입인 string[]으로 타입을 추론해 주는 것이죠.
2단계 가장 빠르지만 위험한 해결책 전역 타입 덮어쓰기
이런 깊은 뜻이 있다지만, 당장 우리 프로젝트에서는 객체에 동적으로 속성을 추가하는 일이 없다는 게 확실하거든요.
이럴 때 가장 간단하게 시도해 볼 수 있는 방법이 바로 TypeScript의 기본 Object 타입을 우리가 원하는 방식으로 '덮어쓰는' 겁니다.
프로젝트 어딘가에 .d.ts 확장자를 가진 타입 선언 파일을 하나 만들고 아래 내용을 넣어주세요.
// object.d.ts
type Key = string | number | symbol;
declare global {
interface ObjectConstructor {
// K 타입의 키와 V 타입의 값을 가진 객체를 받으면, [K, V][]를 반환하도록 재정의
entries<K extends Key, V>(o: Record<K, V>): [K, V][];
entries(o: object): [string, unknown][];
keys<K extends Key>(o: Record<K, unknown>): K[];
keys(o: object): string[];
values<V>(o: Record<Key, V>): V[];
values(o: object): unknown[];
}
}
// 이 파일이 모듈임을 TypeScript에 알리기 위한 코드입니다.
export {};
이렇게 전역 ObjectConstructor의 타입을 확장하고 나면, 마법 같은 일이 벌어지는데요.
as를 쓰지 않았는데도 Object.entries가 정확한 키 타입을 추론해 내기 시작합니다.
const obj = {
foo: "bar",
baz: 5,
} as const;
// 짜잔! 이제 키 타입이 '"foo" | "baz"'로 정확하게 추론됩니다.
const entries = Object.entries(obj);
// const entries: ["foo" | "baz", string | number][]
정말 행복해지는 순간이죠.
하지만 이 방법에는 치명적인 단점과 고려해야 할 함정이 있습니다.
숫자 키의 함정
JavaScript에서 객체의 키는 내부적으로 모두 문자열로 취급되거든요.
그래서 { 5: "foo" } 라는 객체는 런타임에 { "5": "foo" }와 똑같이 동작합니다.
하지만 우리가 위에서 정의한 타입은 이 사실을 모르기 때문에, 키 타입을 number로 잘못 추론할 수 있는데요.
만약 이 키 값을 가지고 숫자 연산을 시도하면 런타임 에러로 이어질 수 있는 아찔한 상황입니다.
const numericObj = {
5: "foo",
} satisfies Record<number, string>;
const entries = Object.entries(numericObj);
// 타입: [5, string][] -> 키가 number 타입으로 잘못 추론됨!
// 실제 값: [ ['5', 'foo'] ] -> 런타임에는 키가 string!
전역 타입 오염의 위험
declare global은 프로젝트 전체에 영향을 미치는 강력한 기능인데요.
이것은 양날의 검과 같습니다.
내가 정의한 타입이 라이브러리나 다른 팀원이 작성한 코드에 예상치 못한 부작용을 일으킬 수 있기 때문입니다.
그래서 실무 프로젝트에서는 사용을 극도로 자제하는 것이 좋습니다.
3단계 더 안전하고 정확한 해결책 타입-세이프 헬퍼 함수
전역 타입을 건드리지 않으면서 타입 안정성을 높이는 가장 좋은 방법은, 우리만의 '타입-세이프 헬퍼 함수'를 만드는 건데요.
이름은 기존 함수와 헷갈리지 않게 짓는 것이 좋습니다.
// utils.ts
// 숫자 키가 문자열로 변환되는 것까지 타입으로 표현합니다.
export const objectKeys = <K extends string | number | symbol, V>(obj: Record<K, V>) => {
return Object.keys(obj) as Array<`${K}`>;
};
export const objectEntries = <K extends string | number | symbol, V>(obj: Record<K, V>) => {
return Object.entries(obj) as Array<[`${K}`, V]>;
};
여기서 핵심은 as Array<\${K}`>부분인데요.<br /> TypeScript의 '템플릿 리터럴 타입'을 사용해서,K` 타입(숫자일 수도 있는)을 반드시 문자열 타입으로 변환하여 반환하도록 명시한 겁니다.
이제 이 헬퍼 함수를 사용하면 전역 오염 걱정 없이, 그리고 숫자 키의 함정까지 피하면서 안전하게 타입을 추론할 수 있습니다.
import { objectKeys, objectEntries } from './utils';
const obj = {
foo: "bar",
baz: 5,
100: true,
} as const;
// 키 타입이 '"foo" | "baz" | "100"' 으로 완벽하게 추론됩니다.
const keys = objectKeys(obj);
const entries = objectEntries(obj);
이 방법이 바로 실무에서 가장 권장되는, 안정성과 정확성을 모두 잡은 해결책입니다.
심화 학습 프로토타입 체인과 keyof
사실 Object.keys의 타입을 keyof로 정의하는 것에는 근본적인 부정확함이 숨어있는데요.
Object.keys는 객체가 '직접' 소유한 속성들만 반환하고 부모 프로토타입의 속성은 무시하거든요.
하지만 TypeScript의 keyof 연산자는 프로토타입 체인을 타고 올라가서 부모가 가진 속성까지 모두 포함할 수 있습니다.
이 둘의 동작 방식이 미묘하게 다르기 때문에, Object.keys의 타입을 keyof로 강제하는 것은 엄밀히 말해 버그를 유발할 수 있는 것이죠.
물론 실무에서 프로토타입을 직접 건드리는 경우는 드물기 때문에 문제가 될 확률은 낮습니다.
하지만 TypeScript가 왜 보수적으로 타입을 추론하는지에 대한 또 하나의 중요한 이유가 되는 셈입니다.
마무리하며
오늘은 Object.keys와 Object.entries가 string[]을 반환하는 이유부터 시작해서, 이를 해결하기 위한 여러 단계의 방법들을 살펴봤는데요.
- 전역 타입 재정의: 가장 빠르지만, 프로젝트 전체를 오염시킬 수 있는 위험한 방법입니다.
- 타입-세이프 헬퍼 함수: 가장 안전하고 실용적이며, 팀과 함께하는 프로젝트에서 강력하게 권장되는 방법입니다.
단순히 as를 쓰지 않는 기술적인 트릭을 넘어, 그 이면에 있는 TypeScript의 설계 철학과 타입 시스템의 동작 원리를 이해하는 것이 핵심이거든요.
이제부터는 Object.entries를 만났을 때 무심코 as를 사용하기 전에, 오늘 배운 내용들을 떠올리며 우리 프로젝트에 가장 적합한, 더 안전하고 우아한 코드를 작성해 보시는 건 어떨까요?
'Javascript' 카테고리의 다른 글
| Prisma 마이그레이션 중 EPERM 에러가 뜬다면? 5분만에 해결하는 방법 (1) | 2025.09.22 |
|---|---|
| 자바스크립트 코드 이제 TPO에 맞게 쓰세요, 클린 코드를 위한 36가지 팁 (0) | 2025.09.07 |
| ES2016 이후 모던 JavaScript 완벽 가이드 한번에 끝내기 (3) | 2025.08.31 |
| nuqs 2.5.0 업데이트 완전 정리 타입 안전 URL 상태 관리의 다음 단계 (4) | 2025.08.24 |
| Next.js는 어떻게 React Compiler를 돌리는가 SWC와 Babel의 현명한 공존 (2) | 2025.08.24 |