더 안전한 타입스크립트 Map과 배열 다루기 고급 패턴 탐구


더 안전한 타입스크립트 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;
}

이 코드는 마법처럼 보이지만, 그 원리는 다음과 같습니다.

  1. 기존 Map 인터페이스에 새로운 has 메서드 시그니처를 '병합(merge)'합니다.
  2. 반환 타입에 this is ... 구문을 사용하여 has를 '사용자 정의 타입 가드'로 만듭니다.
  3. has의 결과가 true이면, if 블록 안에서 this(즉, Map 인스턴스)의 타입이 { get(key: Key): V } & this로 변경됨을 타입스크립트에게 알려줍니다.
  4. & 연산자를 통해 기존 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 연산자를 사용한 타입 가드는 매우 명확하고 안전한 방법이 될 수 있습니다.

결국 중요한 것은 '정답'을 찾는 것이 아니라, 각 방법의 장단점과 한계를 명확히 이해하고, 현재 마주한 상황과 팀의 컨벤션에 가장 적합한 패턴을 '선택'하는 것입니다.

타입스크립트의 깐깐함은 때로 우리를 귀찮게 하지만, 그 이면에는 우리의 코드를 더 견고하고 예측 가능하게 만들려는 깊은 배려가 숨어있습니다.

그 배려를 이해하고 적극적으로 활용할 때, 우리는 비로소 타입스크립트라는 강력한 도구를 온전히 지배할 수 있게 될 것입니다.