Stevy's wavyLog 🌊

타입스크립트는 왜 낯설까

March 2022

car

  나는 타입스크립트 공부를 시작한 지는 1년 정도 되었고 실무에 적용한 지는 3달 정도 되었다. 공부하면서 무수한 시중의 타입스크립트 책을 읽었고 관련 블로그들을 공부하면서 이해의 폭을 점차 넓혀 갔다. 하지만 타입스크립트는 자바스크립트와 다르게 언어의 숙련도 증가가 크게 느껴지지 않았고 쓰면서도 제대로 하는 게 맞는지 의문이 들었다. 자바스크립트의 구문을 그대로 쓰고 있는 언어기 때문에 큰 어려움 없이 쓰고 있지만 반면, 타입스크립트라는 언어 자체로는 뭔가 와닿는 게 적다. 입문을 자바스크립트가 아닌 타입스크립트로 한 사람들 또, 자바스크립트로 입문했지만 타입스크립트를 쓰는데 크게 와닿지 않는 사람들을 위해서 타입스크립트를 쓰면서 느꼈던 점을 적어본다.

타입스크립트는 왜 낯설까

타입스크립트는 자바스크립트로 가기 위한 길

  타입스크립트 공부를 시작할 때, 가장 먼저 볼 수 있는 설명은 타입스크립트는 자바스크립트의 슈퍼셋 언어라는 것이다. 타입스크립트는 태생적으로 자바스크립트를 모태로 하고 있고 자바스크립트의 한계를 넘기 위해서 탄생했다. 자바스크립트 구문에 타입을 추가해서 단순한 프론트앤드 외의 분야에서도 자바스크립트 구문으로 좀 더 안정적인 개발을 하는 것을 목표로 하고 있다.

90년대, 그리고 아마도 5-10년 전까지만 해도 자바스크립트 애플리케이션에 유형이 없다는 절충안은 괜찮았습니다. 구축되는 프로그램의 크기와 복잡성이 웹사이트의 프론트앤드에만 국한되었기 때문입니다. 그러나 오늘날 자바스크립트는 컴퓨터에서 실행되는 거의 모든 것을 구축하기 위해 거의 모든 곳에서 사용되고 있습니다. 많은 모바일 및 데스크톱 앱이 내부적으로 자바스크립트와 웹 기술을 사용합니다. 자바스크립트만으로는 이것들을 모두 개발하기는 어렵습니다. 타입을 추가한다면 해당 프로그램을 개선하는 복잡도가 크게 줄어듭니다.- 타입스크립트 공식 문서

  타입스크립트는 브라우저에서 독자적인 사용이 어렵고 개발 환경의 도움을 받아 자바스크립트로 변환이 된 후에야 브라우저에서 제대로 동작이 된다. 이 점이 가장 처음 만날 수 있는 타입스크립트가 낯선 이유이다. 타입스크립트는 독자적인 언어의 역할을 하는 게 아니라 자바스크립트로 가기 위한 가교 구실만 한다. 보통 언어들도 다 컴파일을 통해서 상대적으로 사람보다 컴퓨터와 친숙한 낮은 레벨인 바이트 코드나 바이너리 코드로 변환된 후 운용이 되기에 크게 다른 점이 없을 수도 있다. 하지만 이런 변환이 사람과 친숙한 높은 레벨 언어 사이에서 일어나기 때문에 혼란스럽다. 또 자바스크립트로 변환하기 위해서 여러 라이브러리를 이용한 개발 환경 세팅이 필요하므로 이점 역시 불편하다. 타입스크립트를 사용자에맞게 쓰기 위한 tsconfig 세팅도 러닝 커브가 높다.

복잡한 타입간의 관계

  타입스크립트의 타입들은 타입 시스템 안에서 서로의 관계를 만들어 간다. 타입은 값의 범위라고 이해하면 좋다. 집합의 개념으로 이해해도 좋은데 어떠한 변수에 어떤 값을 할당할 수 있으면 그 값의 타입은 그 변수 타입의 부분 집합이고 반대로 어떠한 값을 어떠한 변수가 할당받을 수 있으면 그 값 타입의 합집합이다.

interface Person {
    name : string
}

interface Developer {
    name : string
    skill : string
}

const person: Person = {
    name:'sung'
}

const dev: Developer = {
    name: 'kim',
    skill: 'java',
}

const man: Person = dev; // Person은 Developer의 합집합, Developer는 Person의 부분집합

const man2: Developer = person; 
// 반대의 경우( 합집합이면서 부분집합 )는 존재 할 수 없는게 타입스크립트의 기본 원칙이다
// Property 'skill' is missing in type 'Person' but required in type 'Developer'.

  막상 위와 같이 타입 간의 관계를 공부하고도 실제 코드를 짜다 보면 예외를 마주할 수 있어서 혼란스럽다. 일단 any의 경우는 둘 모두를 만족하는 존재이다. 어떤 타입을 만나더라도 합집합이면서 부분집합까지 되기 때문에 할당을 받을 수도, 할당이 될 수도 있는 타입이다. 타입스크립트의 기본 원칙을 무너뜨리기 때문에 any의 사용은 지양되도록 권고된다.

객체 리터럴을 썼을 때도 위의 타입 간의 관계와 또 다른 결과를 보여준다.

interface Person {
    name : string
}

interface Developer {
    name : string
    skill : string
}

const dev = {
    name: 'kim',
    skill: 'java',
}

const man: Person = dev;  // 할당 가능

const man3 : Person = { // 할당 불가
    name: 'kim',
    skill: 'java' 
// Type '{ name: string; skill: string; }' is not assignable to type 'Person'.
// Object literal may only specify known properties, and 'skill' does not exist in type 'Person'
}

변수에 바로 객체 리터럴을 할당하면 잉여 속성 체크를 수행하기 때문에 이와 같은 예외 숙지가 안 되어 있다면 타입 간의 관계를 오해하기 쉽다. 필자도 항상 객체 리터럴만을 할당해 왔고 위와 같은 예외 사항을 몰랐기 때문에 객체 타입끼리의 관계 이해가 잘되지 않았었다.

복잡한 타입과 값의 관계

  타입스크립트는 자바스크립트 구문을 쓰고 있어서 타입과 값이 한 구문 안에 모두 다 섞여 있다. 타입은 쉽게는 구문에서 찾아볼 수 있다. TS Playground에서 트랜스파일 시켰을 때 심벌이 구문에서 사라진다면 그 사라진 것은 명시적인 타입이다. 또, 다른 타입으로는 구문에서 찾아볼 수 없는 타입이 있다. 타입스크립트는 추론을 하므로 단순히 명시적으로 타입으로 정의하지 않아도 타입스크립트의 모든 값은 타입을 가지고 있다. 반면, 타입은 값을 가지지 않기 때문에 타입을 값으로서 사용 할 수 없다.

interface Student{
	name: string;
	age: number;
}

const Student = (name:string, age:number) => ({name, age});

// 전자의 Student는 타입, 후자의 Student는 값이다
// 이름은 같아도 둘은 상관이 없다
const test = (person: unknown) => {
	if(person instanceof Student){ // 이때 Student는 후자의 값인 Student

	}
}

  typeof 같은 경우는 타입과 값 모두에 쓰일 수 있는 연산자이다. 사전에 타입과 값의 차이를 인지하지 못했다면 혼동해서 사용할 위험이 크다. 같은 구문 안에 쓰여도 무엇이 타입이고 무엇이 값인지 정확하게 인지 해야 한다.

class Student{ // class는 enum과 함께 값과 타입 모두에 쓰일 수 있다
	name = 'stevy';
	age = 20;
}

const test = typeof Student // "function", 값에 typeof를 썼을 경우
type test = typeof Student // Student, 타입에 typeof를 썼을 경우 

  타입 연산과 값 연산은 다르다. 값의 영역은 주로 자바스크립트가 맡지만, 타입의 영역은 타입스크립트가 맡는다. 무수한 타입을 위한 타입 연산자들이 존재한다. 위와 같이 구문 안에 값 연산과 타입 연산이 혼재된 경우도 있고, 따로 타입들의 연산만 있는 경우도 있다. 타입 연산과 값 연산을 구별하여 파악해야 한다. 타입 연산은 타입스크립트를 모태인 자바스크립트와 또 다르게 독자적인 언어로서 존재하도록 만들어 준다. 다른 구문들은 자바스크립트의 것들을 빌렸지만 타입 연산만큼은 타입스크립트의 것이다. 단순한 타입 추론만 해주는 checker나 lint의 역할을 넘어서 사용자는 타입스크립트의 타입 연산을 통해 만든 로직으로 새로운 타입을 만들고 추론하도록 유도할 수 있다.

타입 연산 디버깅은 어렵다

  필자는 타입 연산으로 타입스크립트의 독자적인 언어로서의 존재를 인정할 수 있었다. 문제는 타입 디버깅이 어렵다는 것이다. 타입은 계속 흐른다. 또 type checker는 흐르는 타입을 따라 타입 검사를 계속한다. 사용자는 흐르는 타입 안에 타입 로직을 만들고 타입 검사를 다른 식으로 흘러가게 만들 수 있다. 문제는 우리는 일상의 개발과 같이 로직을 검증하며 개발할 수 없고 타입이 도달하는 곳에 값에 할당한 후에 타입만 체크 가능하다.

interface Person {
    name : string
}

interface Developer {
    name : string
    skill : string
}

interface WrappedPerson<T = Person, U = Person > {
  person: U extends Developer ? U : T;
}

type PersonType = WrappedPerson<{name: string}, {name:string,skill:string}>

// 마우스를 올려놨을때 보이는 타입 추론
// 타입을 값에 명시적으로 할당하기 전에는 타입 연산을 실시하지 않고있는 걸로 보인다

// type PersonType = WrappedPerson<{
//     name: string;
// }, {
//     name: string;
//     skill: string;
// }>

// 타입 연산자에 타입을 넣어 타입을 만들고 나서도 값에 도달하기 전에는 타입 디버깅이 어렵다

const personObj: PersonType = {

}
// Property 'person' is missing in type '{}' but required in type 'WrappedPerson<{ name: string; }, { name: string; skill: string; }>'.
// 값의 할당이 끝나고 난 후에야 타입에러가 뜬다
 
const personObj: PersonType = {
    person:{
        name: 'stevysung',
        skill: 'typescript'
    }
}
// (property) WrappedPerson<{ name: string; }, { name: string; skill: string; }>.person: {
//     name: string;
//     skill: string;
// }
// 원하는 타입이 도출 되어있다

대부분 라이브러리들이 타입 정의가 잘되어있다

  이외에도 많은 라이브러리들이 @types에 타입 정의를 잘해 놨기 때문에 라이브러리 사용자 입장에서는 ts나 tsx확장자의 파일을 쓴다고 해도 직접 타입을 구현할 일이 많지 않다. 나는 리액트로 타입스크립트를 연습하면서 타입스크립트라는 것을 쓰고 있는데 왜 자바스크립트만 쓰고 있는지 불안감도 들었다. 내가 직접 타입스크립트의 명시적 타입을 정의하지 않고 타입 보호를 받는게 최고의 타입스크립트 사용인데 이걸 몰랐었다.

타입스크립트와 어떻게 친해질 수 있을까

타입스크립트도 도구다

  우리는 타입스크립트가 왜 탄생했는지 다시 한번 상기해야 한다. 자바스크립트는 매우 유연하고 생산성이 좋은 언어긴 하지만 이런 장점은 개발 복잡도가 심해짐에 따라서 안정적인 프로덕트 구축하기 어렵게 한다. 이런 자바스크립트의 문제를 넘어서 안정적인 프로덕트를 구축하도록 도와주는 게 타입스크립트의 타입이다.

  반대로 타입의 문제점은 개발에 따른 의도치 않은 사이드 이펙트를 적절하게 막아주면서 생산성을 증가시켜 주기는 하지만 자바스크립트가 가지는 유연함에서 오는 생산성을 제한하게 된다. 어디까지나 타입스크립트도 뭔가를 만들기 위한 도구임을 인지하고 유연함에서 오는 생산성을 너무 해치지 않는 선에서 도입하는게 좋다. 안정적인 개발을 도와주는 린트의 확장된 개념으로 받아들여도 좋을 것 같다. 타입 체크를 위한 수많은 타입스크립트의 옵션을 켜고 끄면서 본인과 본인의 팀에 맞는 최적의 옵션을 찾아봐야 한다. 필요하다면 지양되도록 권고받는 any를 쓸 수 도 있다. 참고로 필자는 개인적인 작은 사이드 프로젝트에서는 타입스크립트를 쓰고 있지 않다. 왜냐면 간단한 프로젝트에서는 타입스크립트에 대한 필요성을 못 느끼기 때문이다.

타입의 흐름을 읽어야 한다

  한번 정의되고 추론된 타입이 멈춰 있는 게 아니라 문맥에 따라 달라짐을 알아야 한다. 값과 타입의 경계를 명확하게 보는 훈련도 필요하고 값 사이에 흐르는 타입들을 읽어내야 한다. 이런 문맥의 흐름을 이용해서 원치 않는 타입 에러를 피하고자 타입 가드를 세울 수도 있다.

  • primitive type은 typeof으로 타입 가드를 만들 수 있다
    function doSomething(x: number | string) {
      if (typeof x === 'string') { // TypeScript는 이 조건문 블록 안에 있는 `x`는 백퍼 `string`이란 걸 알고 있습니다.
    	  console.log(x.subtr(1)); // Error: `subtr`은 `string`에 존재하지 않는 메소드입니다.
    	  console.log(x.substr(1)); // ㅇㅋ
      }
      x.substr(1); // Error: `x`가 `string`이라는 보장이 없죠.
    }
  • 객체 타입은 in 연산자로 속성을 분석해서 타입 가드를 만들 수 있다
    interface Cat {
      kind: 'cat';
      numberOfLives: number;
    }
    interface Dog {
      kind: 'dog';
      isAGoodBoy: boolean;
    }
    
    function getAnimal(): Cat | Dog {
      return {
        kind: 'cat',
        numberOfLives: 7
      }
    }
    
    let animal = getAnimal();
    animal.numberOfLives // Error. Property 'numberOfLives' does not exist on type 'Cat | Dog'
    
    if ('numberOfLives' in animal) {
      animal.numberOfLives // OK
    }

이 같은 문맥의 흐름을 이용한 타입 추론은 값을 이용해서 의도대로 타입을 흐르게 하는 것이고 값과 타입의 경계를 넘기 때문에 훈련이 되어있지 않으면 혼란스러울 수 있다.

추론가능한 타입을 사용해 장황한 코드를 방지하자

  위와 같이 타입은 흐른다. 명시적인 타입 표기의 목적은 저 타입의 흐름을 좀 더 원활하거나 또 명확하게 코드를 짜기 위함이다. 무분별하게 타입을 명시적으로 중복으로 표기하면 코드 가독성이 떨어지고 추후 수정 시 잘못된 타입으로 인한 부가적인 문제가 생긴다. 타입스크립트의 타입은 생성될 때 결정된다 타입의 원천은 하나로 세우고 적절하게 흘러 내려오도록 설계해야 한다. 이상적인 타입스크립트 코드는 함수에 타입을 표기하고 함수 내의 지역 변수에는 타입을 포함하지 않는다. 타입 구문이 적절하게 생략 되면서 의도한대로 타입 시스템이 동작하는 것을 지향해야 한다.

마치며

  타입스크립트를 쓰면서 내가 느꼈던 점을 공유해봤다. 타입스크립트는 겉보기에는 자바스크립트와 비슷해서 쉬워 보이지만 막상 써보면 숨겨진 타입들을 이해하고 그것을 이용해서 연산하는 수준으로는 발전되기 어려운 언어다. 이 글을 통해 타입스크립트를 이해하는 데 도움이 되면 좋겠다.

참고

이펙티브 타입스크립트 - 교보문고

All rights reserved © Stevy Sung, 2024