본문 바로가기

호기심 천국

타입스크립트는 자바스크립트의 상위집합(superset)일까?

겨울 참새 세 마리. Unsplash 에 P A 님이 올림.

 

요약

ㅇㅇ

사실 답지가 있어요

TypeScript is a language that is a superset of JavaScript: JS syntax is therefore legal TS. Syntax refers to the way we write text to form a program
- 타입스크립트 공식 문서 曰

 

공식 문서에서 대놓고 타입스크립트가 상위집합이라고 광고한다. 사실 이렇게 간단하게 찾을 줄은 몰랐다. 여기서 끝내기는 아쉬우니 이 글에서는 '왜 상위집합인지' 조금 더 자세하게 적어본다.

 

나에게는 '상위집합'이라는 말보다 '부분집합'이 더 익숙하다. 그러니까 문제를 바꿔서 '자바스크립트가 타입스크립트의 부분집합인 이유'를 찾아보도록 하자.

이 글에서는..

  1. 촘스키 위계를 바탕으로 언어 사이의 포함 관계를 정의한다.
  2. 일부 올바른 자바스크립트가 올바른 타입스크립트임을 확인해 본다.
  3. 확인 과정에서 생길 수 있는 타입 오류 관련 의문을 컴파일러 구조를 통해 해결한다.

언어가 다른 언어의 부분집합이라는 것은?

수학의 관점에서 부분집합은 굉장히 직관적이다.

 

A = { 1, 2, 3 }
B = { 1, 2, 3, 4, 5 }
A ⊆ B

 

어떤 한 집합 안의 모든 원소가 다른 집합에도 속한다면 부분집합 관계가 성립한다.

비슷한 논리를 두 언어 사이의 관계에 적용해보자. 어떤 언어 A가 다른 언어 B의 부분집합이 되기 위해서는 A의 모든 '원소'가 B에 속하면 된다. 그렇다면 이 '원소'라는 건 언어에서 어떻게 정의하면 될까?

 

다행히 이 분야에는 살아있는 전설, 촘스키가 있다.

촘스키 위계

일찍이 촘스키는 형식 언어를 만들어내는 형식 문법 사이의 관계를 정의한 적이 있다. 위키백과의 설명을 보자.

 

The Chomsky hierarchy is a containment hierarchy of classes of formal grammars.

A formal grammar describes how to form strings from a language's vocabulary (or alphabet) that are valid according to the language's syntax.
- 위키피디아 曰

 

촘스키 위계는 형식 문법(formal grammar) 사이의 포함 관계를 나타낸다.

형식 문법은 언어의 통사 구조(syntax)를 거스르지 않는 문자열을 만들어내는 방법을 기술한다.

 

촘스키 위계에 나오는 네 가지 형식 언어는 개인적으로 좋아하는 주제이지만 이 글과는 한참 다른 얘기라 따로 언급하지는 않겠다.

 

촘스키 위계에서 A 언어가 B 언어의 부분집합이 되기 위해서는 B 언어의 문법으로 표현 가능한 모든 문장이 A 언어의 문법으로 만들어내는 모든 문장을 포함하고 있으면 된다. 실제 촘스키 위계의 구성 요소들로 간단하게 설명을 하고 싶었는데 실력 부족으로 도저히 쉽게 풀어 쓸 자신이 없어서 다른 예시를 준비했다.

 

한글 한 글자

[시작] → [자음][모음][자음일지도]
[자음] → ㄱ | ㄴ | ㄷ | ... | ㅎ
[모음] → ㅏ | ㅑ | ㅓ | ... | ㅣ
[자음일지도] → [자음] | ε

 

이건 '한글의 한 글자'를 표현하는 어떤 언어의 생성 규칙이다. 대괄호로 감싸서 표현한 친구들은 '비말단 기호(non-terminal symbol)'라고 부른다.ㄱ, ㄴ, ..., ㅏ, ㅑ, ... 이 친구들은 '말단 기호(terminal symbol)'라고 부른다. ε는 빈 문자열을 나타내는 기호다.


생성 규칙으로부터 올바른 문자열을 뽑아내는 법은 간단하다. [시작]에서 출발해서 모든 비말단 기호가 없어지고 말단 기호만 남을 때까지 화살표 규칙들로 하나씩 바꿔나가면 된다. '참'과 '새'를 만드는 여러 가지 방법 중 하나를 적어놓겠다.

 

'참'을 만드는 방법

[시작]
→ [자음][모음][자음일지도]
→ ㅊ[모음][자음일지도]
→ ㅊ[모음][자음]
→ ㅊ[모음]ㅁ
→ ㅊㅏㅁ
'새'를 만드는 방법

[시작]
→ [자음][모음][자음일지도]
→ ㅅ[모음][자음일지도]
→ ㅅㅐ[자음일지도]
→ ㅅㅐ

 

아무튼 이런 식으로 만들어지는 글자들을 모은 게 '한글의 한 글자' 언어로 표현할 수 있는 올바른 문장들이다. 그러면 이제 또 다른 언어, '받침 없는 한글 한 글자'의 생성 규칙을 살펴보자.

 

받침 없는 한글 한 글자

[시작] → [자음][모음]
[자음] → ㄱ|ㄴ|ㄷ| ... |ㅎ 
[모음] → ㅏ|ㅑ|ㅓ| ... |ㅣ

 

그럼 여기서 문제. '받침 없는 한글 한 글자'의 모든 올바른 문장은 '한글 한 글자'의 생성 규칙들로 만들어낼 수 있을까? 그렇다! 이러면 '받침 없는 한글 한 글자'는 '한글 한 글자'의 부분집합이라고 할 수 있다.

자바스크립트가 타입스크립트의 부분집합이 되려면

다시 원래 이야기로 돌아와 보자. 자바스크립트가 타입스크립트의 부분집합이 되려면 올바른(valid) 자바스크립트 코드는 올바른 타입스크립트 코드여야 한다. 인터넷에 이 글의 주제에 관해 검색해보면 많은 사람들이 자바스크립트 코드를 타입스크립트에 그대로 옮겨서 실행해보는 방식으로 실험하는 것들을 볼 수 있다. 이 글에서도 가볍게 이 내용을 통해 부분집합임을 보여주고자 한다.

 

npm init
npm install --save-dev typescript ts-node

 

타입스크립트와 그걸 node.js로 실행하기 위해 ts-node를 설치해준다.

그 다음, 패키지 안쪽 아무데나 타입스크립트 파일을 만들고 올바른 자바스크립트를 적어준다.

 

let value = 1;
value = 2;
value = '삼';
console.log(value);

const sparrow = {};
sparrow.wingCount = 2;
console.log(sparrow.wingCount);

console.log(4 / []);

 

혹시 console 부분에서 타입 오류가 나온다면 npm install --save-dev @types/node를 통해 관련 타입을 알아차리게 도와주자.
만약 이게 .js 파일이었다면 콘솔에는 아래와 같이 찍힐 것이다.

 

삼
2
Infinity

 

하지만 타입스크립트로 작성한 이 코드를 npx ts-node {해당 파일 경로}로 실행하면 당연히 타입 오류가 터진다. 대충 string은 number가 아니고, wingCount가 없고, 수식의 오른쪽에 이상한 거 넣지 말라고 울부짖을 것이다.

올바른 자바스크립트가 올바른 타입스크립트가 아닌데요?

라는 생각이 들 수 있다. 자바스크립트에서는 멀쩡하게 잘 돌아가는 코드가 타입스크립트로 실행하려니 초장부터 말썽이니 말이다.

가만 보면 나를 괴롭히는 오류들은 모두 '타입'에 관련된 것이다. 그러면 이 오류만 무시하면 돌아가는 거 아닐까? 타입스크립트 컴파일러에는 타입 검사 자체를 무시하는 설정이 있다. tsconfig.json을 만들고 타입 검사를 하지 않겠다는 의지를 보여주자.

 

{
  "ts-node": {
    "transpileOnly": true
  }
}

 

다시 실행해 보면 오류가 나지 않고 콘솔에 정상적으로 결과가 찍히는 것을 볼 수 있다.

아쉽게 모든 자바스크립트 코드에 대해서는 검사를 할 수 었지만, 이런 방법을 통해 우리는 자바스크립트가 타입스크립트의 부분집합이라는 것을 알 수 있다!

아니 타입 오류 무시하는게 맞나요?

그런데 뭔가 찜찜할 수 있다. 우리는 앞선 실험에서 '의도적으로' 타입스크립트 컴파일러의 설정을 조작하여 타입 오류를 피했다. 그렇다면 원래는 통과하지 못하는 걸 억지로 보내준 셈인데 '진짜' 포함 관계가 성립한다고 할 수 있을까?

스택오버플로우에 비슷한 이유로 '자바스크립트는 타입스크립트의 부분집합이 아니다' 라고 주장하는 사람들의 답변을 살펴보자.

 

function f(a) { return a; }; console.log(f<Function>(f));

It will print the function f
- 스택오버플로우의 답변 중

var foo={}; foo.bar=42;

Your example that I referenced earlier emits this error: error TS2339: Property 'bar' does not exist on type '{}'.
I think we can call this a failed compilation (and thus invalid TypeScript)
-스택오버플로우의 답변 중

 

두 주장 다 주어진 코드가 자바스크립트에서는 정상적으로 실행해서 콘솔 출력까지 하지만, 타입스크립트에서는 오류가 나기 때문에 올바르지 않다는 내용이다. 참고로 두 코드 모두 transpileOnly: true 설정으로 오류가 나오지도 못하게 만들어버릴 수 있다.

이 사람들의 주장처럼 타입 오류로 인해 자바스크립트는 타입스크립트의 부분집합이라고 볼 수 없는 걸까?

타입 오류를 무시해도 되는 이유

아니다!
정말 놀랍게도 모든 타입 오류는 무시해도 된다. 그 이유를 알기 위해서는 컴파일러의 구조를 간단하게나마 이해할 필요가 있다.

컴파일러

이하 이론적인 부분은 Alfred V. Aho 외 3인이 집필한 Compilers: principles, techniques, and tools (2nd Edition) 라는 책의 내용을 많이 참고하였음을 밝힙니다.

Compilers: principles, techniques, and tools라는 책에서는 컴파일러의 구조를 아래 그림처럼 크게 여섯 개의 단계로 표현한다. 자바스크립트/타입스크립트가 아니라 보다 일반적인 예시를 드는 것이기 때문에 실제 타입스크립트 컴파일러의 작동 방식과는 차이가 있을 수 있다.

 

컴파일러의 여섯 단계

 

각 단계에서 하는 일을 간단하게 알아보자.

Lexical Analysis: 어휘 분석

이 단계에서 컴파일러는 주어진 소스 코드(문자열 덩어리)를 어휘소(lexeme) 단위로 쪼갠 후, 해당 어휘소를 컴퓨터가 이해하기 좋은 '토큰'으로 바꾼다. 바꾼 토큰들에 대한 정보는 symbol table이라는 곳에 따로 저장도 해 둔다.

 

어휘소와 토큰

 

이 단계에서는 잘못된 토큰을 만났을 때 Lexical Error를 던진다.

Syntax Analysis: 구문 분석

여기에서 컴파일러는 언어의 문법(생성 규칙, syntax)를 이용해서 일종의 트리를 만든다. 앞에서 다뤘던 '참'과 '새'를 만드는 방법의 생성 규칙들을 따라가면서 일어나는 일들을 전부 기록하는 것이다. 생성 규칙에 따라서 각종 연산의 진행 순서도 그릴 수 있다. 이렇게 만든 것은 추상 구문 트리(Abstract Syntax Tree, AST)라고 한다.

 

추상 구문 트리의 예

 

바로 위의 예제를 AST 형태로 만들면 이런 느낌으로 할 수 있다. 트리의 모양을 보고 최종 계산을 위해서는 가장 먼저 곱셈을 해야 한다는 우선순위도 유추할 수 있다.

 

만약 이 단계에서 컴파일러가 언어의 문법에 맞는 AST 만들기에 실패한다면 Syntax Error가 터진다.

Semantic Analysis: 의미 분석

의미 분석에는 앞서 어휘 분석 단계에서 만든 symbol table과 구문 분석 단계에서 만든 AST를 이용한다. 이걸로 주어진 소스 코드의 의미를 분석하는데, 크게 타입 검사타입 강제 변환이라는 두 가지 일을 한다.

 

타입 검사(Type check)는 우리가 타입스크립트 컴파일러를 돌렸을 때 타입 오류가 나는 그 부분들을 다 잡아내는 것이다. 어렵게 말하면 각 연산자(operator)가 올바른 피연산자(operand)를 가졌는지 확인하는 것이고, 쉽게 말하면 '이게 말이 되는 코드인지' 검사한다고 할 수 있겠다.

 

let value = 1;
value = '둘'
// Type 'string' is not assignable to type 'number'.

console.log(4 / []); 
// The right-hand side of an arithmetic operation must be of type 'any', 'number', 'bigint' or an enum type.

 

타입 강제 변환(Type coercion)은 서로 다른 타입을 가진 두 친구들의 연산을 위해 타입을 서로 맞춰 주는 과정이다.

 

const a = 1;
const b = '1';
console.log(a + b); // '11'

 

여기서 2'2'TypeError: 어쩌구도 아닌 '11이 되는 이유는 요 언어의 의미론적 규칙에 '이런 경우에는 숫자를 문자열로 바꿔서 처리하세요~' 라는 내용이 있기 때문이다. 이 변환 과정을 의미 분석 단계에서 확인 후 처리하는 것이다. AST 또는 symbol table에 '이 값은 문자열로 변환한다' 라는 내용의 무언가를 붙여서 말이다. 이미 만들어진 AST에 살짝 덧칠하는 느낌이라 이 단계의 결과를 '꾸며진(Decorated)' AST라고 부르는 것이다.

 

Type Error는 이 단계에서 무언가 하자가 생겼을 때 나타난다.

Intermediate Code Generation: 중간 코드 생성

사실 여기부터는 이 글의 결론을 내리는 것과는 관련이 없다. 앞의 세 분석 단계보다도 살짝쿵 더 추상적인 내용이라 간단하게 넘어간다.

 

이 단계의 결과물인 '중간 표현(Intermediate Representation)'은 컴파일러의 입력과 출력 사이에 존재하는 소스 코드의 표현 방식(소스 코드의 모든 내용을 담고는 있지만 입출력 코드와는 생김새가 다른 녀석)이다. AST 역시 하나의 중간 표현인 셈이다.

Code Optimization: 코드 최적화

처음 소스 코드의 의미(semantic)를 해치지 않는 선에서 최적화를 진행한다. 속도, 메모리 등 어디에 초점을 두느냐에 따라 최적화의 방식이 다를 수 있다. 사용하지 않는 변수를 제거하거나 하는 과정들이 여기에 포함될 수 있다.

Code Generation: 코드 생성

이제 끝이다. 원하는 만큼의 중간 코드를 거치면 컴파일러는 최종적으로 목표 언어로 바뀐 프로그램을 내놓는다. 그게 기계어가 될 수도 있고, 타입스크립트 컴파일러처럼 자바스크립트일 수도 있다.

그래서 왜 타입 오류는 무시해도 되는 거죠?

  1. 촘스키 위계에서 언어의 포함 관계는 형식 문법의 포함 관계로 나타낼 수 있다.
  2. 형식 문법은 해당 언어의 통사 구조(syntax)에 맞는 문장들을 만든다.
  3. 컴파일러에서 문법의 통사 구조는 두 번째 단계인 구문 분석 과정에서 확인한다.
  4. 타입 오류는 세 번째 단계인 의미(semantic) 분석을 하면서 검사한다.
  5. 따라서 타입은 언어의 포함 관계와는 관련이 없는 내용이다.

결론

  1. 촘스키 위계에 따르면 언어의 포함 관계를 결정하는 것은 언어의 syntax이다.
  2. 타입 오류를 무시한다면 타입스크립트로 올바른 자바스크립트를 실행할 수 있고, 반대로 잘못된 자바스크립트는 실행할 수 없다. 즉 자바스크립트는 타입스크립트의 부분집합이다.
  3. 타입 검사는 언어의 syntax와는 관련이 없기 때문에 무시할 수 있다.

아쉽게도 자바스크립트의 구문과 타입스크립트의 구문이 완벽하게 같은지는 찾지 못했다. 하지만 나의 짧은 견문에 따르면 아직 두 개가 다른 경우 역시 보지 못했다.

참고자료

타입스크립트 공식 문서

개념 정리 - (3) 형식 언어와 오토마타 편

Wikipedia: Chomsky hierarchy

Is Typescript A Superset of Javascript?

StackOverflow: Is TypeScript really a superset of JavaScript?

Alfred V, A., Monica S, L., Ravi, S., & Jeffrey D, U. (2007). Compilers: principles, techniques, and tools (2nd Edition). Pearson Education: 4-11쪽