아이템 3. 코드 생성과 타입이 관계없음을 이해하기

Park Choong Ho·2021년 7월 13일
0

이 시리즈는 댄 밴더캄이 쓴 책인 Effective Typescript를 번역한 이펙티브 타입스크립트를 읽고 공부를 위해 정리한 책입니다.

아이템 3. 코드 생성과 타입이 관계없음을 이해하기

타입스크립트 컴파일러의 역할은 크게 2가지로 구분됩니다.

  1. 최신 타입스크립트/자바스크립트를 브라우저에서 동작할 수 있도록 구버전 자바스크립트로 트랜스파일(traspile)합니다.
  2. 코드 타입 오류를 체크합니다.

주목할 점은 이 2가지 특징이 서로 독립적이라는 것입니다. 즉, 타입스크립트가 자바스크립트로 변화할 때 코드의 타입에는 영향을 주지 않습니다. 또한 컴파일한 자바스크립트를 실행하는 런타임에서도 타입은 영향을 미치지 않습니다.

이러한 타입스크립트가 맡은 2가지 역할을 상기하면, 타입스크립트가 할 수 있는 일과 할 수 없는 일을 구분할 수 있습니다.

타입 오류가 있는 코드도 컴파일 할 수 있습니다.

컴파일은 타입 체킹과 독립적으로 동작하기에 타입 오류가 있는 타입스크립 코드도 자바스크립트로 변환 가능합니다.

linux> cat test.ts
/** @format */

let x = "hello";
x = 1234;

linux> tsc test.ts
test.ts:4:1 - error TS2322: Type '1234' is not assignable to type 'string'.

4 x = 1234;
  ~


Found 1 error.

linux> cat test.js
/** @format */
var x = "hello";
x = 1234;

타입 체크와 컴파일을 같이하는 C나 자바를 사용했던 개발자라면 이러한 상황이 조금 낯설게 느껴질 수도 있습니다. 타입스크립트 오류는 C나 자바 같은 정적언어의 경고와 비슷한 역할을 합니다. 버그가 발생할 수 있는 부분을 알려주지만 그렇다고 해서 빌드를 멈추지는 않습니다.

컴파일과 타입체크
대개 타입스크립트 코드에 오류가 있을 때, "컴파일에 문제가 있다."라고 말하는 경우가 종종 있습니다. 하지만 이는 기술적으로 명확하게 따졌을 때 틀린 표현입니다. 엄밀하게 "컴파일"이란 코드를 생성하는 것을 의미하므로 자바스크립트 코드가 어떻게든 생성되었다면 (설령 타입이 잘못되었다 할지라도) 컴파일은 성공한 것입니다. 따라서 코드에 오류가 발생했을 경우 "타입 체크에 문제가 있다" 라고 말하는 것이 더 정확합니다.

타입 오류가 있음에도 컴파일 되는 특징으로 인해 타입스크립트를 허술한 언어로 생각할 수도 있습니다. 그러나 코드에 오류가 있음에도 컴파일된 코드가 나오는 것이 실제로 도움이 되는 경우가 있습니다. 타입스크립트로 작성한 웹 애플리케이션이 컴파일 과정에서 타입 오류를 냈다고 가정해봅시다. 그 경우 오류가 있어도 컴파일 결과물을 컴파일러가 만들어내므로, 그 결과물로 오류가 발생하지 않는 다른 부분을 테스트 해볼 수 있습니다. 만약 오류 발생시 컴파일을 허용하지 않고 싶은 경우, tsconfig.jsonnoEmitOnError를 설정하거나 빌드 도구를 동일하게 적용하면 됩니다.

런타임에는 타입 체크가 불가능합니다

다음 코드를 한번 보겠습니다.

interface Square {
	width: number;
}

interface Rectangle extends Square {
	height: number;
}

type Shape = Square | Rectangle;

function calculateArea(shape: Shape) {
	if (shape instanceof Rectangle) {
						// ~~~~~~'Rectangle' only refers to a type, 
						// but is being used as a value here.ts(2693)
		return shape.width * shape.height;
								   // ~~~~ Property 'height' does not exist on type 'Shape'.
								   // Property 'height' does not exist on type 'Square'.ts(2339)
	} else {
		return shape.width * shape.width;
	}
}

instanceof 체크는 런타임에서 발생하지만 Rectangle은 타입이기에 런타임에서는 아무런 역할도 할 수 없습니다. 타입스크립트에서 타입은 제거가능 즉, erasable합니다. 실제 자바스크립트로 컴파일 되는 과정에서 인터페이스, 타입, 타입 구문은 제거됩니다.

해당 코드에서 shape 변수의 타입을 더 명확히 하고자 한다면, 런타임에 타입 정보를 유지하는 방법이 필요합니다. 하나는 height 속성이 있는지 체크하는 것입니다.

function calculate(shape: Shape) {
	if('height' in shape){
		return shape.width * shape.height;
	} else {
		return shape.width * shape.width;
	}
}

이런 경우 속성 체크는 런타임에 접근 가능한 값에만 관련되지만, 타입 체커 또한 shape 타입을 Rectangle로 보정함으로써 오류를 제거합니다.

또 다른 방법은 런타입에 접근 가능한 타입 정보를 명시적으로 저장하는 "태크" 기법입니다.

interface Square {
	kind: 'square';
	width: number;
}

interface Rectangle {
	kind: 'rectangle';
	height: number;
	width: number;
}

type Shape = Square | Rectangle;

function calculateArea(shape: Shape) {
	if(shape.kind === 'rectangle') {
		shape; // 타입이 Rectangle
		return shape.width * shape.height;
	} else {
		shape; // 타입이 Square
		return shape.width * shape.width;
	}
}

Shape 타입은 '태그된 유니온(tagged union)'의 한 예입니다. 이렇게 하면 런타임에 타입 정보를 유지할 수 있기 때문에 타입스크립트에서 해당 패턴을 종종 볼 수 있습니다.

타입(런타임 접근 불가)과 값(런타임 접근 가능)을 동시에 사용하는 기법이 있는데 이 기법은 바로 클래스입니다. 타입을 클래스로 만들면 됩니다. Square와 Rectangle을 클래스로 만들어 오류를 해결할 수 있습니다.

class Square {
	constructor(public width: number) {}
}

class Rectangle extends Square {
	constructor(public width: number, public height: number) {
		super(width);
	}
}
type Shape = Square | Rectangle;

function calculateArea(shape: Shape) {
	if(shape instanceof Rectangle) {
		shape; // 타입이 Rectangle
		return shape.width * shape.height;
	} else {
		shape; // 타입이 Square
		return shape.width * shape.width;
	}
}

인터페이스는 타입으로만 사용할 수 있지만, 클래스는 타입과 값으로 모두 사용할 수 있습니다. type Shape = Square | Rectangle에서 Rectangle은 타입으로 참조되고 shape instanceof Rectangle에서는 값으로 참조됩니다.

타입 연산은 런타임에 영향을 주지 않습니다

string 또는 number 타입인 값을 항상 number로 전환하는 함수를 생각해 보겠습니다. 다음 코드는 타입 체커를 통과하지만 올바른 방법이 아닙니다.

function asNumber(val: number | string): number {
	return val as number;
}

변환된 자바스크립트 코드를 보면 위 타입스크립트 코드가 실제 어떻게 동작하는지 볼 수 있습니다.

function asNumber(val) {
	return val;
}

자바스크트 코드를 보면 정제 과정이 없는 것을 확인할 수 있습니다. as number는 타입 연산이고 런타임 동작에는 그 어떤 영향도 주지 않습니다. 값을 정제하고자 한다면, 런타임 타입을 체크하고 자바스크립트 연산을 통해 타입 변환을 수행해야 합니다.

function asNumber(val: number | string):number {
	return typeof(val) === 'string' ? Number(val) : val;
}

런타임 타입은 선언된 타입과 다를 수 있습니다.

아래 코드에 있는 함수를 보고 마지막 console.log가 실행될 수 있을지 생각해봅시다.

function seLightSwitch(value: boolean) {
	switch (value) {
		case: true:
			turnLightOn();
			break;
		case: false:
			turnLightOff();
			break;
		default:
			console.log('실행되지 않을까 봐 걱정됩니다.');
	}
}

타입스크립트는 대개 실행되지 못하는 죽은 코드를 발견해내지만, 여기서는 strict를 설정해 놓더라도 찾지 못합니다.

위 코드에서 value: boolean에서 boolean 타입은 런타임에서 제거됩니다. 자바스크립트에서는 실수로 setLightSwitch("ON") 호출할 수 있습니다.

이번에는 이런 코드를 살펴보겠습니다.

interface LightApiResponse {
	lightSwitchValue: boolean;
}

async function setLight() {
	const response = await fetch('/light');
	const result: LightApiResponse = await reponse.json();
	setLightSwitch(result.lightSwitchValue);
}

위 코드는 네트워크 호출로부터 받아온 값으로 함수를 실행하는 코드입니다. /light 을 호출시, LightApiResponse 타입을 가지는 값을 반환하라고 선언했지만 항상 이를 보장해주지는 않습니다. API를 잘못 해석해서 lightSwitchValue가 실제로 문자열이었으면, 런타임에서는 이 값이 setLightSwitch 함수까지 전달되었을 겁니다. 또는 API가 나중에 변경이 되어서 lightSwitchValue이 타입이 변할 수도 있습니다.

이렇게 타입스크립트는 런타임 타입과 선언된 타입이 맞지 않을 수 있습니다. 타입이 달라지는 혼란스러운 상황은 가능한 피해야겠지만 항상 선언된 타입이 런타임 타입과 상이할 수 있다는 점을 명심해야 합니다.

타입스크립트 타입으로는 함수를 오버로드할 수 없습니다.

C++ 같은 언어에서는 동일한 이름의 함수더라도 매개변수가 다를 경우 각 함수들을 허용합니다. 이를 함수 오버로딩이라 합니다. 하지만 타입스크립트는 타입과 런타임 동작이 별개이기에, 함수 오버로딩이 불가능합니다.

/\*\* @format \*/

  

function add(a: number, b: number) {
	   //~~~ Duplicate function implementation.ts(2393)
	return a + b;
}

  

function add(a: string, b: string) {
		//~~~ Duplicate function implementation.ts(2393)
	return a + b;
}

타입스크립트에서 함수 오버로딩 기능을 지원하지만, 타입수준에서만 동작합니다. 한 함수에 대해 여러개 선언문이 존재할 수 있지만, 구현체(implementation) 는 하나여야합니다.

/\*\* @format \*/

function add(a: number, b: number): number;
function add(a: string, b: string): string;

function add(a, b) {
	return a + b;
}

const three = add(1, 2);

const twelve = add("1", "2");

위에 있는 2개의 선언문은 함수에 대한 타입 정보를 제공할 뿐입니다. 선언문은 자바스크립트로 컴파일 되는 과정에서 제거되고 구현체만 남게 됩니다.

타입스크립트 타입은 런타임 성능에 영향을 주지 않습니다.

타입과 타입 연산자는 자바스크립트로 변환되면서 제거되기에, 런타임 성능에 영향을 미치지 않습니다. 타입스크립트 정적 타입은 실제 비용이 들지 않습니다. 타입스크립트 대신, 런타임 오버헤드를 감수하며 타입체크를 하면, 타입스크립트 팀이 다음 주의 사항들을 얼마나 많이 고려했는지를 느낄 수 있습니다.

  • '런타임' 오버헤드는 없으나 타입스크립트 컴파일러는 '빌드타임' 오버헤드가 있습니다. 따라서 타입스크립트 컴파일러의 성능도 중요합니다. 타입스크립트 개발팀은 이 점을 인지하고 있기에 컴파일은 대개 빠른편입니다. 특히 증분(incremental) 빌드시에 이를 느낄 수 있습니다. 오버헤드가 지나치게 커지면, 빌드 도구에서 트랜스파일만(transpile only) 을 설정해 타입체크를 스킵할 수 있습니다.
  • 타입스크립트가 컴파일 하는 코드는 오래된 런타임 환경과의 호환성을 높이고 성능 오버헤드를 감수할지, 호환성을 포기하고 성능 중심 네이티브 구현체를 선택할지에 대한 문제에 직면할 수 있습니다. 예를 들어, 제너레이터 함수가 ES5로 컴파일 되려면, 컴파일러는 호환성을 위한 특정 헬퍼 코드를 추가할 것입니다. 이 경우가 호환성을 위한 오버헤드와 성능을 위한 네이티브 구현체 선택 문제입니다. 이런 호환성과 성능 사이의 선택 문제는 컴파일 타깃과 언어 레벨의 문제이지, 타입과는 무관합니다.
profile
백엔드 개발자 디디라고합니다.

0개의 댓글