
더 안전한 타입스크립트 Map과 배열 다루기 고급 패턴 탐구
타입스크립트의 배려, 때로는 오지랖이 될 때
자바스크립트로 코드를 작성할 때 우리는 아주 자연스럽게 특정 패턴을 사용합니다.
`Map` 객체에서는 `.has()`로 키의 존재를 먼저 확인한 뒤, `.get()`으로 안전하게 값을 가져옵니다.
배열에서는 `.length`로 길이를 확인하여, 존재하지 않는 인덱스에 접근하는 오류를 피합니다.
이러한 방어적인 코딩 습관은 매우 합리적이고 안전해 보입니다.
하지만 타입스크립트의 세계로 넘어오는 순간, 이 당연했던 패턴들은 종종 우리의 기대를 배신하고는 합니다.
타입스크립트는 우리의 '의도'를 온전히 이해하지 못하고, 이미 안전하다고 확신하는 상황에서도 '값이 `undefined`일 수 있다'며 깐깐하게 경고를 보내기 때문입니다.
이 글에서는 타입스크립트에서 이러한 '타입 추론의 한계'가 왜 발생하는지, 그리고 이 깐깐한 컴파일러를 만족시키면서도 더 안전하고 우아한 코드를 작성하기 위한 다양한 해결책과 고급 패턴들을 심층적으로 탐구해 보고자 합니다.
Map.has의 믿음과 타입스크립트의 불신
자바스크립트 개발자에게 너무나도 익숙한 코드를 먼저 살펴보겠습니다.
if (someMap.has('key')) {
const value = someMap.get('key');
// value는 반드시 존재하므로 안전하게 사용
}
if 문을 통해 키의 존재를 확인했으니, 블록 안에서 .get('key')의 결과는 결코 undefined가 될 수 없습니다.
하지만 타입스크립트는 이 논리적 흐름을 완벽하게 따라오지 못합니다.
function translateHello(translations: Map<string, string>): void {
if (translations.has('hello')) { // (A) 키 존재 확인
const translation = translations.get('hello'); // (B) 값 가져오기
// (C) 하지만 타입스크립트는 여전히 translation이
// 'string | undefined' 타입일 수 있다고 판단합니다.
// Error: Object is possibly 'undefined'.
console.log(translation.toUpperCase());
}
}
분명 (A)에서 'hello'라는 키가 존재함을 확인했지만, 타입스크립트는 (B)에서 translations.get('hello')의 결과 타입을 string으로 좁혀주지 못하고, 여전히 string | undefined로 추론합니다.
왜 이런 일이 발생할까요.
이는 타입스크립트의 .has() 메서드 타입 정의가, 그저 boolean 값을 반환하는 함수로만 되어 있기 때문입니다..has()의 결과가 true라는 사실이 .get() 메서드의 반환 타입에 아무런 영향을 주지 못하는 것이죠.
이 문제를 해결하기 위한 몇 가지 패턴이 존재합니다.
해결책 1 가장 간단하지만 명시적인 해결, 단언 연산자(!)
가장 손쉬운 방법은 'Non-null 단언 연산자(`!`)'를 사용하는 것입니다.
이 연산자는 개발자가 타입스크립트에게 '이 값은 절대로 `null`이나 `undefined`가 아니니 내 말을 믿어!'라고 강하게 주장하는 것과 같습니다.
function translateHello(translations: Map<string, string>): void {
if (translations.has('hello')) {
const translation = translations.get('hello')!; // '!' 추가
// 이제 translation의 타입은 'string'으로 단언됩니다.
console.log(translation.toUpperCase()); // OK!
}
}
이 방법은 매우 간편하지만, 타입스크립트의 검사를 무력화하고 모든 책임을 개발자가 지는 것이므로, 논리적으로 100% 확신할 수 있는 상황에서만 신중하게 사용해야 합니다.
해결책 2 발상의 전환, 값으로 직접 확인하기
또 다른 해결책은 코드의 패턴 자체를 바꾸는 것입니다.
키의 존재 여부가 아닌, '값'의 존재 여부를 직접 확인하는 방식입니다.
function translateHello(translations: Map<string, string>): void {
const translation = translations.get('hello');
// 키가 아닌, 가져온 값이 undefined가 아닌지 직접 확인합니다.
if (translation !== undefined) {
// 이 블록 안에서 translation의 타입은 'string'으로 좁혀집니다.
console.log(translation.toUpperCase()); // OK!
}
}
이 패턴은 타입스크립트의 타입 좁히기(Type Narrowing)가 아주 잘 작동하기 때문에 효과적입니다.
다만, Map에 값으로 undefined가 의도적으로 저장될 수 있는 경우에는 사용할 수 없다는 사소한 한계가 있습니다.
해결책 3 가장 우아하지만 깊은 이해가 필요한, 타입 가드
가장 흥미롭고 강력한 해결책은 타입스크립트의 '타입 선언 보강(Declaration Merging)'과 '타입 가드(Type Guard)'를 이용해 `Map.has` 메서드의 동작 자체를 재정의하는 것입니다.
`globals.d.ts`와 같은 선언 파일에 다음과 같은 코드를 추가하면, 프로젝트 전역의 `Map.has`가 더 똑똑하게 동작하게 됩니다.
// globals.d.ts
interface Map<K, V> {
has<Key extends K>(key: Key): this is { get(key: Key): V } & this;
}
이 코드는 마법처럼 보이지만, 그 원리는 다음과 같습니다.
- 기존
Map인터페이스에 새로운has메서드 시그니처를 '병합(merge)'합니다. - 반환 타입에
this is ...구문을 사용하여has를 '사용자 정의 타입 가드'로 만듭니다. has의 결과가true이면,if블록 안에서this(즉,Map인스턴스)의 타입이{ get(key: Key): V } & this로 변경됨을 타입스크립트에게 알려줍니다.&연산자를 통해 기존Map타입에.get(key: Key)의 반환 타입을V로 '덮어쓰는(override)' 새로운 타입을 교차(intersection) 시킵니다.
이제 이 마법을 적용한 코드를 보면, 타입 추론이 놀랍도록 정확해지는 것을 볼 수 있습니다.
function translateHello(translations: Map<string, string>): void {
// `has`가 타입 가드로 동작합니다.
if (translations.has('hello')) {
// 이 블록 안에서 translations.get('hello')의 반환 타입은 'string'이 됩니다.
const translation1 = translations.get('hello'); // 타입: string (OK!)
console.log(translation1.toUpperCase());
// 하지만 다른 키에 대한 타입은 그대로 유지됩니다.
const translation2 = translations.get('abc'); // 타입: string | undefined
}
}
하지만 이 강력한 방법에도 한계는 있습니다.
만약 has 메서드에 전달되는 키의 타입이 구체적인 리터럴 타입('hello')이 아니라 넓은 string 타입이라면, 타입스크립트는 어떤 키에 대해 타입을 좁혀야 할지 알 수 없게 되어, 모든 .get()의 반환 타입을 string으로 좁혀버리는 과잉 일반화의 오류를 범할 수 있습니다.
배열 인덱스 접근의 함정
이러한 문제는 배열에서도 비슷하게 나타납니다.
특히 `tsconfig.json`에서 `"noUncheckedIndexedAccess": true` 옵션을 활성화하면, 타입스크립트는 모든 배열의 인덱스 접근 결과를 `T | undefined`로 추론하여 안정성을 높입니다.
// "noUncheckedIndexedAccess": true 설정 시
function main(args: string[]): void {
if (args.length < 1) { // 길이를 확인했지만...
throw new Error('Need at least one argument');
}
const filePath = args[0]; // filePath의 타입은 여전히 'string | undefined' 입니다.
}
이 경우에도 우리는 Map에서 사용했던 해결책들을 유사하게 적용할 수 있습니다.! 단언 연산자를 사용하거나, 값을 변수에 할당한 뒤 undefined 여부를 직접 체크하는 것입니다.
흥미롭게도, 배열에서는 in 연산자를 사용한 타입 가드가 매우 효과적으로 작동합니다.
function main(args: string[]): void {
if (!(0 in args)) { // `in` 연산자로 인덱스 0의 존재를 확인
throw new Error('Need at least one argument');
}
const filePath = args[0]; // filePath의 타입은 'string'으로 좁혀집니다! (OK!)
const otherPath = args[1]; // otherPath의 타입은 'string | undefined' 입니다.
}
in 연산자는 특정 인덱스가 배열에 '존재하는지'를 명확하게 확인해주기 때문에, 타입스크립트가 해당 인덱스의 타입을 안전하게 좁혀줄 수 있는 것입니다.
어떤 방법을 선택해야 할까
지금까지 여러 가지 해결책을 살펴보았습니다.
타입 가드를 이용한 선언 보강 같은 방법은 매우 똑똑하고 우아해 보이지만, 프로젝트의 복잡성을 높이고 다른 개발자가 코드를 이해하기 어렵게 만들 수 있다는 단점이 있습니다.
특히 팀 프로젝트나 오픈소스 라이브러리에서는 코드의 '이식성'과 '명확성'이 매우 중요합니다.
따라서 대부분의 상황에서는 가장 널리 알려지고 직관적인 방법을 사용하는 것이 현명한 선택일 수 있습니다.
Map의 경우: 값을 먼저.get()으로 가져온 뒤,undefined인지 확인하는 패턴을 우선적으로 고려하는 것이 좋습니다.- 배열의 경우:
in연산자를 사용한 타입 가드는 매우 명확하고 안전한 방법이 될 수 있습니다.
결국 중요한 것은 '정답'을 찾는 것이 아니라, 각 방법의 장단점과 한계를 명확히 이해하고, 현재 마주한 상황과 팀의 컨벤션에 가장 적합한 패턴을 '선택'하는 것입니다.
타입스크립트의 깐깐함은 때로 우리를 귀찮게 하지만, 그 이면에는 우리의 코드를 더 견고하고 예측 가능하게 만들려는 깊은 배려가 숨어있습니다.
그 배려를 이해하고 적극적으로 활용할 때, 우리는 비로소 타입스크립트라는 강력한 도구를 온전히 지배할 수 있게 될 것입니다.
'Javascript' 카테고리의 다른 글
| ECMAScript 2025 최종 승인 무엇이 달라졌나? (0) | 2025.07.13 |
|---|---|
| 암호 해독 가이드 더 이상 두렵지 않은 자바스크립트 정규표현식 (0) | 2025.07.13 |
| 자바스크립트의 배신 타입스크립트는 Iterator 이름 충돌을 어떻게 해결했나 (0) | 2025.07.13 |
| 2025년 최고의 자바스크립트(JavaScript) 대안, 리스크립트(ReScript) 파헤치기! (1) | 2025.05.20 |
| fetchpriority (페치프라이오리티)로 리소스 로딩 최적화하기 (0) | 2025.05.17 |